WIP: AGC 资源画布与替换改造 V3.0 #316
@@ -111,6 +111,7 @@ const allowedUncalledTauriCommands = [
|
||||
'chat_with_game_creator_agent',
|
||||
'check_ui_editor_font_glyph_coverage',
|
||||
'create_ui_design_resource',
|
||||
'normalize_local_project_raster_resource',
|
||||
'open_game_creator_launcher_window',
|
||||
'open_game_creator_workspace_window',
|
||||
'read_direct_project_conversation',
|
||||
|
||||
@@ -848,244 +848,6 @@ pub(crate) fn update_local_project_resource_canvas_layout(
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn create_local_project_asset_canvas_draft(
|
||||
input: CreateAssetCanvasDraftInput,
|
||||
) -> Result<CreateAssetCanvasDraftResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
create_asset_canvas_draft_at(&root, &input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_local_project_asset_canvas_draft(
|
||||
input: ReadAssetCanvasDraftInput,
|
||||
) -> Result<ReadAssetCanvasDraftResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
read_asset_canvas_draft_at(&root, &input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn discover_local_project_asset_canvas_draft(
|
||||
input: DiscoverAssetCanvasDraftInput,
|
||||
) -> Result<DiscoverAssetCanvasDraftResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
discover_asset_canvas_draft_at(&root, &input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn update_local_project_asset_canvas_draft(
|
||||
input: UpdateAssetCanvasDraftInput,
|
||||
) -> Result<UpdateAssetCanvasDraftResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
update_asset_canvas_draft_at(&root, &input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn acknowledge_local_project_asset_canvas_candidate_layers(
|
||||
input: AcknowledgeAssetCanvasCandidateLayersInput,
|
||||
) -> Result<AcknowledgeAssetCanvasCandidateLayersResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
acknowledge_candidate_layers_at(&root, &input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn import_local_project_asset_canvas_images(
|
||||
app: tauri::AppHandle,
|
||||
input: ImportAssetCanvasImagesInput,
|
||||
) -> Result<ImportAssetCanvasImagesResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||||
let mut dialog = app
|
||||
.dialog()
|
||||
.file()
|
||||
.set_title("导入图片")
|
||||
.add_filter("图片", &["png", "jpg", "jpeg", "webp"]);
|
||||
if let Some(window) = app.get_webview_window("client") {
|
||||
dialog = dialog.set_parent(&window);
|
||||
}
|
||||
dialog.pick_files(move |paths| {
|
||||
let _ = sender.send(paths);
|
||||
});
|
||||
let Some(paths) = receiver
|
||||
.await
|
||||
.map_err(|_| "图片文件选择器意外关闭".to_string())?
|
||||
else {
|
||||
return Ok(ImportAssetCanvasImagesResult::cancelled());
|
||||
};
|
||||
if paths.is_empty() {
|
||||
return Ok(ImportAssetCanvasImagesResult::cancelled());
|
||||
}
|
||||
let paths = paths
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
path.into_path()
|
||||
.map_err(|error| format!("读取导入图片路径失败:{error}"))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
tokio::task::spawn_blocking(move || import_asset_canvas_images_at(&root, &input, &paths))
|
||||
.await
|
||||
.map_err(|error| format!("图片导入任务异常结束:{error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn store_local_project_asset_canvas_media(
|
||||
input: StoreAssetCanvasMediaInput,
|
||||
) -> Result<StoreAssetCanvasMediaResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
store_asset_canvas_media_at(&root, &input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn stage_local_project_asset_canvas_image(
|
||||
input: StageAssetCanvasImageInput,
|
||||
) -> Result<StageAssetCanvasImageResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
stage_asset_canvas_image_at(&root, &input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn archive_failed_local_project_asset_canvas_generation(
|
||||
input: ArchiveAssetCanvasGenerationInput,
|
||||
) -> Result<ArchiveAssetCanvasGenerationResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
archive_failed_asset_canvas_generation_at(&root, &input).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn generate_local_project_asset_canvas_image(
|
||||
app: tauri::AppHandle,
|
||||
input: GenerateAssetCanvasImageInput,
|
||||
) -> Result<GenerateAssetCanvasImageResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
let progress_app = app.clone();
|
||||
let execution = generate_asset_canvas_image_at(&root, &input, move |payload| {
|
||||
let _ = progress_app.emit(ASSET_CANVAS_GENERATION_PROGRESS_EVENT, payload);
|
||||
})
|
||||
.await?;
|
||||
if let Some(event) = execution.event.as_ref() {
|
||||
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
|
||||
app.emit(
|
||||
"game-creator-local-asset-committed",
|
||||
asset_canvas_committed_public_event(payload),
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
});
|
||||
}
|
||||
Ok(execution.result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn finalize_local_project_asset_canvas_generation_failure(
|
||||
input: FinalizeAssetCanvasGenerationFailureInput,
|
||||
) -> Result<FinalizeAssetCanvasGenerationFailureResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
finalize_asset_canvas_generation_failure_at(&root, &input).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn recover_local_project_asset_canvas_generations(
|
||||
app: tauri::AppHandle,
|
||||
input: RecoverAssetCanvasGenerationsInput,
|
||||
) -> Result<RecoverAssetCanvasGenerationsResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
let progress_app = app.clone();
|
||||
let execution = recover_asset_canvas_generations_at(&root, &input, move |payload| {
|
||||
let _ = progress_app.emit(ASSET_CANVAS_GENERATION_PROGRESS_EVENT, payload);
|
||||
})
|
||||
.await?;
|
||||
for event in &execution.events {
|
||||
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
|
||||
app.emit(
|
||||
"game-creator-local-asset-committed",
|
||||
asset_canvas_committed_public_event(payload),
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
});
|
||||
}
|
||||
Ok(execution.result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn confirm_local_project_asset_canvas_generation_service_identity(
|
||||
input: ConfirmAssetCanvasGenerationServiceIdentityInput,
|
||||
) -> Result<ConfirmAssetCanvasGenerationServiceIdentityResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
confirm_asset_canvas_generation_service_identity_at(&root, &input).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_local_project_asset_canvas_media(
|
||||
input: ReadAssetCanvasMediaInput,
|
||||
) -> Result<ReadAssetCanvasMediaResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
read_asset_canvas_media_at(&root, &input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn discard_local_project_asset_canvas_draft(
|
||||
input: DiscardAssetCanvasDraftInput,
|
||||
) -> Result<DiscardAssetCanvasDraftResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
discard_asset_canvas_draft_at(&root, &input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn commit_local_project_asset(
|
||||
app: tauri::AppHandle,
|
||||
input: CommitAssetCanvasInput,
|
||||
) -> Result<CommitAssetCanvasResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
let execution = commit_asset_canvas_at(&root, &input)?;
|
||||
if let Some(event) = execution.event.as_ref() {
|
||||
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
|
||||
app.emit(
|
||||
"game-creator-local-asset-committed",
|
||||
asset_canvas_committed_public_event(payload),
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
});
|
||||
}
|
||||
Ok(execution.result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn commit_local_project_asset_canvas_candidate(
|
||||
app: tauri::AppHandle,
|
||||
input: CommitAssetCanvasCandidateInput,
|
||||
) -> Result<CommitAssetCanvasResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
let execution = commit_asset_canvas_candidate_at(&root, &input)?;
|
||||
if let Some(event) = execution.event.as_ref() {
|
||||
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
|
||||
app.emit(
|
||||
"game-creator-local-asset-committed",
|
||||
asset_canvas_committed_public_event(payload),
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
});
|
||||
}
|
||||
Ok(execution.result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn recover_local_project_asset_canvas_transactions(
|
||||
app: tauri::AppHandle,
|
||||
input: RecoverAssetCanvasTransactionsInput,
|
||||
) -> Result<RecoverAssetCanvasTransactionsResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
let execution = recover_asset_canvas_transactions_at(&root, &input.expected_project_id)?;
|
||||
for event in &execution.events {
|
||||
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
|
||||
app.emit(
|
||||
"game-creator-local-asset-committed",
|
||||
asset_canvas_committed_public_event(payload),
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
});
|
||||
}
|
||||
Ok(execution.result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn control_agent_run(
|
||||
app: tauri::AppHandle,
|
||||
|
||||
@@ -2585,24 +2585,6 @@ fn main() {
|
||||
read_local_project_resource_canvas_layout,
|
||||
read_local_project_resource_graph,
|
||||
update_local_project_resource_canvas_layout,
|
||||
create_local_project_asset_canvas_draft,
|
||||
read_local_project_asset_canvas_draft,
|
||||
discover_local_project_asset_canvas_draft,
|
||||
update_local_project_asset_canvas_draft,
|
||||
acknowledge_local_project_asset_canvas_candidate_layers,
|
||||
import_local_project_asset_canvas_images,
|
||||
store_local_project_asset_canvas_media,
|
||||
stage_local_project_asset_canvas_image,
|
||||
finalize_local_project_asset_canvas_generation_failure,
|
||||
archive_failed_local_project_asset_canvas_generation,
|
||||
generate_local_project_asset_canvas_image,
|
||||
recover_local_project_asset_canvas_generations,
|
||||
confirm_local_project_asset_canvas_generation_service_identity,
|
||||
read_local_project_asset_canvas_media,
|
||||
discard_local_project_asset_canvas_draft,
|
||||
recover_local_project_asset_canvas_transactions,
|
||||
commit_local_project_asset,
|
||||
commit_local_project_asset_canvas_candidate,
|
||||
get_local_game_project_revision,
|
||||
get_local_game_manifest,
|
||||
download_agc_update,
|
||||
|
||||
@@ -4,7 +4,6 @@ use similar::TextDiff;
|
||||
use std::io::{Seek, SeekFrom};
|
||||
|
||||
mod agent_db;
|
||||
mod asset_canvas;
|
||||
mod checkpoint;
|
||||
mod conversation;
|
||||
mod export;
|
||||
@@ -18,7 +17,6 @@ mod resource_layout;
|
||||
mod verification;
|
||||
|
||||
pub(crate) use agent_db::*;
|
||||
pub(crate) use asset_canvas::*;
|
||||
pub(crate) use checkpoint::*;
|
||||
pub(crate) use conversation::*;
|
||||
pub(crate) use export::*;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,82 +0,0 @@
|
||||
const COMMIT_ID_SUFFIX =
|
||||
/--[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const WINDOWS_RESERVED_NAMES = new Set([
|
||||
'CON',
|
||||
'PRN',
|
||||
'AUX',
|
||||
'NUL',
|
||||
'COM1',
|
||||
'COM2',
|
||||
'COM3',
|
||||
'COM4',
|
||||
'COM5',
|
||||
'COM6',
|
||||
'COM7',
|
||||
'COM8',
|
||||
'COM9',
|
||||
'LPT1',
|
||||
'LPT2',
|
||||
'LPT3',
|
||||
'LPT4',
|
||||
'LPT5',
|
||||
'LPT6',
|
||||
'LPT7',
|
||||
'LPT8',
|
||||
'LPT9',
|
||||
]);
|
||||
const FORBIDDEN_FILE_NAME_CHARACTERS = '/\\:*?"<>|';
|
||||
|
||||
function isForbiddenFileNameCharacter(value: string) {
|
||||
const code = value.codePointAt(0) ?? 0;
|
||||
return (
|
||||
code <= 0x1f ||
|
||||
code === 0x7f ||
|
||||
FORBIDDEN_FILE_NAME_CHARACTERS.includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeAssetBaseName(value: string, fallback: string) {
|
||||
const normalized = value
|
||||
.normalize('NFC')
|
||||
.split('')
|
||||
.filter((character) => !isForbiddenFileNameCharacter(character))
|
||||
.join('');
|
||||
const bounded = Array.from(normalized)
|
||||
.slice(0, 80)
|
||||
.join('')
|
||||
.replace(/[. ]+$/u, '');
|
||||
if (
|
||||
bounded.length === 0 ||
|
||||
WINDOWS_RESERVED_NAMES.has(bounded.split('.')[0]?.toUpperCase() ?? '')
|
||||
) {
|
||||
return fallback;
|
||||
}
|
||||
return bounded;
|
||||
}
|
||||
|
||||
export function canonicalAssetBaseName(
|
||||
localPath: string,
|
||||
fallback = '画布素材',
|
||||
) {
|
||||
const fileName = localPath.split(/[\\/]/u).filter(Boolean).pop() ?? '';
|
||||
let stem = fileName.replace(/\.[^.]+$/u, '');
|
||||
let next = stem.replace(COMMIT_ID_SUFFIX, '');
|
||||
while (next !== stem) {
|
||||
stem = next;
|
||||
next = stem.replace(COMMIT_ID_SUFFIX, '');
|
||||
}
|
||||
return sanitizeAssetBaseName(next, fallback);
|
||||
}
|
||||
|
||||
export function isValidAssetCanvasName(name: string) {
|
||||
if (
|
||||
name.length === 0 ||
|
||||
Array.from(name).length > 80 ||
|
||||
Array.from(name).some(isForbiddenFileNameCharacter) ||
|
||||
/[. ]$/u.test(name) ||
|
||||
WINDOWS_RESERVED_NAMES.has(name.split('.')[0]?.toUpperCase() ?? '')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5444,7 +5444,6 @@ iframe.preview-frame {
|
||||
box-shadow: var(--platform-nav-active-shadow);
|
||||
}
|
||||
|
||||
.game-workbench-stage[data-resource-view-state^='resources.asset-canvas'],
|
||||
.game-workbench-stage[data-resource-view-state='resources.ui-editor'] {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
@@ -5455,12 +5454,6 @@ iframe.preview-frame {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.game-workbench-stage[data-resource-view-state^='resources.asset-canvas']
|
||||
> .asset-canvas-surface {
|
||||
grid-row: 2;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.game-workbench-toolbar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,26 +11,13 @@ export type LocalProjectResourceEditKind =
|
||||
| 'agent-result'
|
||||
| 'version';
|
||||
|
||||
export type ProjectResourceEditCapability =
|
||||
| {
|
||||
route: 'image-canvas';
|
||||
sourceAssetId: string;
|
||||
normalizeSource: false;
|
||||
mediaType: 'image/png' | 'image/jpeg' | 'image/webp';
|
||||
}
|
||||
| {
|
||||
route: 'image-canvas';
|
||||
sourceAssetId: null;
|
||||
normalizeSource: true;
|
||||
mediaType: 'image/png' | 'image/jpeg' | 'image/webp';
|
||||
}
|
||||
| {
|
||||
route: 'derive';
|
||||
editKind: LocalProjectResourceEditKind;
|
||||
sourceMediaType: string;
|
||||
semanticNotice: string | null;
|
||||
generationMode?: 'create' | 'derive';
|
||||
};
|
||||
export type ProjectResourceEditCapability = {
|
||||
route: 'derive';
|
||||
editKind: LocalProjectResourceEditKind;
|
||||
sourceMediaType: string;
|
||||
semanticNotice: string | null;
|
||||
generationMode?: 'create' | 'derive';
|
||||
};
|
||||
|
||||
const extensionMediaTypes: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
@@ -140,21 +127,6 @@ export function resolveProjectResourceEditCapability(
|
||||
}
|
||||
|
||||
const mediaType = canonicalProjectedResourceMediaType(resource);
|
||||
if (['image/png', 'image/jpeg', 'image/webp'].includes(mediaType)) {
|
||||
return resource.manifestAssetId
|
||||
? {
|
||||
route: 'image-canvas',
|
||||
sourceAssetId: resource.manifestAssetId,
|
||||
normalizeSource: false,
|
||||
mediaType: mediaType as 'image/png' | 'image/jpeg' | 'image/webp',
|
||||
}
|
||||
: {
|
||||
route: 'image-canvas',
|
||||
sourceAssetId: null,
|
||||
normalizeSource: true,
|
||||
mediaType: mediaType as 'image/png' | 'image/jpeg' | 'image/webp',
|
||||
};
|
||||
}
|
||||
if (mediaType === 'image/svg+xml') {
|
||||
return {
|
||||
route: 'derive',
|
||||
|
||||
@@ -4347,7 +4347,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
/<div className="game-workbench-chat-wallet">\{walletEntry\}<\/div>/,
|
||||
);
|
||||
expect(projectDevelopmentSource).toMatch(
|
||||
/const showRunUnavailableHint\s*=\s*!runAvailable\s*&&\s*!focusedResource\s*&&\s*!assetCanvasRoute\s*&&\s*!resourceEditorRoute\s*&&\s*!uiEditorRoute/s,
|
||||
/const showRunUnavailableHint\s*=\s*!runAvailable\s*&&\s*!focusedResource\s*&&\s*!resourceEditorRoute\s*&&\s*!uiEditorRoute/s,
|
||||
);
|
||||
expect(projectDevelopmentSource).toMatch(
|
||||
/aria-describedby=\{\s*showRunUnavailableHint\s*\?\s*'run-unavailable-hint'\s*:\s*undefined\s*\}/s,
|
||||
@@ -4357,7 +4357,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
);
|
||||
});
|
||||
|
||||
it('invalidates only the committed asset preview after an in-place refine', () => {
|
||||
it('keeps the resource preview version cache wired and cleared on project switch', () => {
|
||||
const projectDevelopmentSource = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
@@ -4369,9 +4369,6 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
expect(projectDevelopmentSource).toMatch(
|
||||
/previewVersionByResourceId:\s*resourcePreviewVersionByResourceId/,
|
||||
);
|
||||
expect(projectDevelopmentSource).toMatch(
|
||||
/const committedResourceId = `asset:\$\{notification\.assetId\}`;[\s\S]*?next\.set\(committedResourceId, notification\.commitId\)/,
|
||||
);
|
||||
expect(projectDevelopmentSource).toMatch(
|
||||
/setResourcePreviewVersionByResourceId\(new Map\(\)\)/,
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,142 +7,8 @@ import type { GameCreationAppManifest } from '../../../packages/shared/src/contr
|
||||
const canvasFixture = vi.hoisted(() => ({
|
||||
manifest: null as GameCreationAppManifest | null,
|
||||
revision: 0,
|
||||
sequence: 0,
|
||||
recoveredDraftIds: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock('../src/features/asset-canvas/AssetCanvasSurface', async () => {
|
||||
const ReactModule = await import('react');
|
||||
return {
|
||||
AssetCanvasSurface: (props: {
|
||||
scope: {
|
||||
projectId: string;
|
||||
draftId: string;
|
||||
intent: 'create' | 'refine';
|
||||
sourceAssetId: string | null;
|
||||
};
|
||||
sessionId: string;
|
||||
initialAssetName?: string;
|
||||
initialAssetKind?: string;
|
||||
onCancel?: (result: {
|
||||
draftId: string;
|
||||
disposition: 'kept' | 'discarded';
|
||||
}) => void;
|
||||
onSaveAttempt?: (attempt: {
|
||||
saveAttemptId: string;
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
draftId: string;
|
||||
commitId: string;
|
||||
}) => void;
|
||||
onCommitted?: (notification: Record<string, unknown>) => void;
|
||||
host: {
|
||||
generation: {
|
||||
recoverImages: (input: {
|
||||
scope: {
|
||||
projectId: string;
|
||||
draftId: string;
|
||||
intent: 'create' | 'refine';
|
||||
sourceAssetId: string | null;
|
||||
};
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
}) => {
|
||||
ReactModule.useEffect(() => {
|
||||
void props.host.generation.recoverImages({ scope: props.scope });
|
||||
}, [props.host, props.scope]);
|
||||
return ReactModule.createElement(
|
||||
'section',
|
||||
{ 'aria-label': '测试素材创作画布' },
|
||||
ReactModule.createElement(
|
||||
'span',
|
||||
null,
|
||||
`${props.scope.intent}:${props.scope.sourceAssetId ?? 'none'}:${props.initialAssetName ?? 'unset'}:${props.initialAssetKind ?? 'unset'}`,
|
||||
),
|
||||
ReactModule.createElement(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
onClick: () =>
|
||||
props.onCancel?.({
|
||||
draftId: props.scope.draftId,
|
||||
disposition: 'kept',
|
||||
}),
|
||||
},
|
||||
'取消并返回',
|
||||
),
|
||||
ReactModule.createElement(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
onClick: () => {
|
||||
const base = canvasFixture.manifest;
|
||||
if (!base) throw new Error('missing manifest fixture');
|
||||
canvasFixture.sequence += 1;
|
||||
canvasFixture.revision += 1;
|
||||
const assetId =
|
||||
props.scope.intent === 'refine' && props.scope.sourceAssetId
|
||||
? props.scope.sourceAssetId
|
||||
: `canvas-output-${canvasFixture.sequence}`;
|
||||
const commitId = `commit-${canvasFixture.sequence}`;
|
||||
const existingAsset = base.assets.find(
|
||||
(asset) => asset.id === assetId,
|
||||
);
|
||||
const committedAsset = {
|
||||
id: assetId,
|
||||
kind: existingAsset?.kind ?? 'art-image',
|
||||
mediaType: 'image/png',
|
||||
localPath:
|
||||
props.scope.intent === 'refine'
|
||||
? `assets/canvas/${assetId}--${canvasFixture.sequence}.png`
|
||||
: `assets/${assetId}.png`,
|
||||
source:
|
||||
existingAsset?.source ??
|
||||
({
|
||||
kind: 'canvas' as const,
|
||||
taskId: null,
|
||||
resourceId: `canvas-resource-${canvasFixture.sequence}`,
|
||||
referenceResourceIds: [],
|
||||
} as const),
|
||||
};
|
||||
const nextManifest = {
|
||||
...base,
|
||||
assets: existingAsset
|
||||
? base.assets.map((asset) =>
|
||||
asset.id === assetId ? committedAsset : asset,
|
||||
)
|
||||
: [...base.assets, committedAsset],
|
||||
};
|
||||
canvasFixture.manifest = nextManifest;
|
||||
props.onSaveAttempt?.({
|
||||
saveAttemptId: `save-${canvasFixture.sequence}`,
|
||||
sessionId: props.sessionId,
|
||||
projectId: props.scope.projectId,
|
||||
draftId: props.scope.draftId,
|
||||
commitId,
|
||||
});
|
||||
props.onCommitted?.({
|
||||
source: 'command',
|
||||
projectPath: '/tmp/live-canvas-integration',
|
||||
projectId: props.scope.projectId,
|
||||
draftId: props.scope.draftId,
|
||||
commitId,
|
||||
assetId,
|
||||
manifest: nextManifest,
|
||||
projectRevision: canvasFixture.revision,
|
||||
committedProjectRevision: canvasFixture.revision,
|
||||
eventId: `event-${canvasFixture.sequence}`,
|
||||
});
|
||||
},
|
||||
},
|
||||
'完成测试保存',
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import ProjectDevelopmentView from '../src/view/project-development';
|
||||
import {
|
||||
act,
|
||||
@@ -343,43 +209,11 @@ function SwitchableDerivedWorkbench({
|
||||
);
|
||||
}
|
||||
|
||||
function TaskImageWorkbench() {
|
||||
const initial = createGameCreationAppManifest(
|
||||
'live-canvas-project',
|
||||
'实时画布项目',
|
||||
);
|
||||
const task = initial.tasks.find(
|
||||
(candidate) => candidate.id === 'art-asset-plan',
|
||||
);
|
||||
if (!task) throw new Error('missing art task fixture');
|
||||
task.status = 'completed';
|
||||
task.artifacts = ['assets/task-hero.png'];
|
||||
const [manifest, setManifest] = useState(initial);
|
||||
canvasFixture.manifest = manifest;
|
||||
|
||||
return (
|
||||
<ProjectDevelopmentView
|
||||
projectName={manifest.name}
|
||||
projectPath={projectPath}
|
||||
manifest={manifest}
|
||||
attachments={[]}
|
||||
recentRunStatus={null}
|
||||
recentRunStopReason={null}
|
||||
supervisor={<div>Supervisor</div>}
|
||||
onHomeOpen={() => undefined}
|
||||
onProjectsOpen={() => undefined}
|
||||
onManifestChange={(_path, nextManifest) => setManifest(nextManifest)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('project resource live canvas integration', () => {
|
||||
afterEach(() => {
|
||||
delete window.__TAURI__;
|
||||
canvasFixture.manifest = null;
|
||||
canvasFixture.revision = 0;
|
||||
canvasFixture.sequence = 0;
|
||||
canvasFixture.recoveredDraftIds = [];
|
||||
});
|
||||
|
||||
function installTauri(
|
||||
@@ -406,14 +240,12 @@ describe('project resource live canvas integration', () => {
|
||||
const layoutWrites: Array<Record<string, unknown>> = [];
|
||||
const graphReads: Array<Record<string, unknown>> = [];
|
||||
const deriveCalls: Array<Record<string, unknown>> = [];
|
||||
const normalizeCalls: Array<Record<string, unknown>> = [];
|
||||
const resumeCalls: Array<Record<string, unknown>> = [];
|
||||
const serviceIdentityRequestCalls: Array<Record<string, unknown>> = [];
|
||||
const serviceIdentityConfirmCalls: Array<Record<string, unknown>> = [];
|
||||
const archiveCalls: Array<Record<string, unknown>> = [];
|
||||
const pendingReadCalls: Array<Record<string, unknown>> = [];
|
||||
let pendingReadCount = 0;
|
||||
let discoverableDraftId: string | null = null;
|
||||
let pendingEdits =
|
||||
options.pendingResourceEdits ??
|
||||
(options.pendingResourceEdit
|
||||
@@ -430,20 +262,6 @@ describe('project resource live canvas integration', () => {
|
||||
: []);
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'discover_local_project_asset_canvas_draft') {
|
||||
const input = (args?.input ?? {}) as Record<string, unknown>;
|
||||
return discoverableDraftId
|
||||
? {
|
||||
status: 'found',
|
||||
draft: {
|
||||
draftId: discoverableDraftId,
|
||||
projectId: String(input.expectedProjectId),
|
||||
intent: 'refine',
|
||||
sourceAssetId: String(input.sourceAssetId),
|
||||
},
|
||||
}
|
||||
: { status: 'not-found', draft: null };
|
||||
}
|
||||
if (command === 'get_local_game_project_revision') {
|
||||
return { revision: canvasFixture.revision };
|
||||
}
|
||||
@@ -494,17 +312,6 @@ describe('project resource live canvas integration', () => {
|
||||
dataUrl: 'data:image/png;base64,AA==',
|
||||
};
|
||||
}
|
||||
if (command === 'recover_local_project_asset_canvas_generations') {
|
||||
const input = structuredClone(
|
||||
(args?.input ?? {}) as Record<string, unknown>,
|
||||
);
|
||||
discoverableDraftId = String(input.draftId);
|
||||
canvasFixture.recoveredDraftIds.push(String(input.draftId));
|
||||
return {
|
||||
resumedGenerationIds: [`generation-for-${String(input.draftId)}`],
|
||||
serviceIdentityConfirmations: [],
|
||||
};
|
||||
}
|
||||
if (command === 'read_local_project_text_preview') {
|
||||
return {
|
||||
path: String(args?.relativePath ?? ''),
|
||||
@@ -646,33 +453,6 @@ describe('project resource live canvas integration', () => {
|
||||
manifest: nextManifest,
|
||||
};
|
||||
}
|
||||
if (command === 'normalize_local_project_raster_resource') {
|
||||
const input = structuredClone(
|
||||
(args?.input ?? {}) as Record<string, unknown>,
|
||||
);
|
||||
normalizeCalls.push(input);
|
||||
const base = canvasFixture.manifest;
|
||||
if (!base) throw new Error('missing manifest fixture');
|
||||
const asset = {
|
||||
id: 'normalized-task-hero',
|
||||
kind: 'art-image',
|
||||
mediaType: 'image/png',
|
||||
localPath: String(input.sourcePath),
|
||||
source: {
|
||||
kind: 'generated' as const,
|
||||
taskId: String(input.producerTaskId),
|
||||
resourceId: String(input.sourceResourceId),
|
||||
},
|
||||
};
|
||||
canvasFixture.revision += 1;
|
||||
const nextManifest = { ...base, assets: [...base.assets, asset] };
|
||||
canvasFixture.manifest = nextManifest;
|
||||
return {
|
||||
committedProjectRevision: canvasFixture.revision,
|
||||
asset,
|
||||
manifest: nextManifest,
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
@@ -684,7 +464,6 @@ describe('project resource live canvas integration', () => {
|
||||
deriveCalls,
|
||||
graphReads,
|
||||
layoutWrites,
|
||||
normalizeCalls,
|
||||
pendingReadCalls,
|
||||
resumeCalls,
|
||||
archiveCalls,
|
||||
@@ -693,147 +472,6 @@ describe('project resource live canvas integration', () => {
|
||||
};
|
||||
}
|
||||
|
||||
it('enters refine in the central view, keeps the draft on return, and preserves the source after a durable edit', async () => {
|
||||
const { graphReads, layoutWrites } = installTauri();
|
||||
render(<LiveWorkbench />);
|
||||
|
||||
const sourceCard = await screen.findByRole('button', {
|
||||
name: /source-art\.png/,
|
||||
});
|
||||
for (const label of [
|
||||
'生成视频',
|
||||
'生成音效',
|
||||
'生成背景音乐',
|
||||
'新增 UI 设计',
|
||||
]) {
|
||||
expect(screen.queryByRole('button', { name: label })).toBeNull();
|
||||
}
|
||||
fireEvent.click(sourceCard);
|
||||
const sourceDetails = screen.getByRole('dialog', {
|
||||
name: /source-art\.png/,
|
||||
});
|
||||
expect(sourceDetails.closest('.game-workbench-stage')).toBe(
|
||||
screen.getByLabelText('项目主视窗'),
|
||||
);
|
||||
expect(sourceDetails.hasAttribute('aria-modal')).toBe(false);
|
||||
expect(document.querySelector('.game-resource-focus-backdrop')).toBeNull();
|
||||
for (const label of [
|
||||
'生成视频',
|
||||
'生成音效',
|
||||
'生成背景音乐',
|
||||
'新增 UI 设计',
|
||||
]) {
|
||||
expect(screen.queryByRole('button', { name: label })).toBeNull();
|
||||
}
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '按依赖' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(screen.getByRole('button', { name: '按类型' })).not.toBeNull();
|
||||
expect(screen.getByRole('button', { name: '复位资源视图' })).not.toBeNull();
|
||||
expect(screen.getByLabelText('资源依赖视图').hasAttribute('inert')).toBe(
|
||||
false,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
|
||||
expect(
|
||||
(await screen.findByLabelText('测试素材创作画布')).textContent,
|
||||
).toContain('refine:source-art:source-art:art-image');
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: /source-art\.png/ }),
|
||||
).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: '完成测试保存' }),
|
||||
);
|
||||
expect(await screen.findByLabelText('测试素材创作画布')).not.toBeNull();
|
||||
expect(screen.queryByRole('dialog', { name: /canvas-output/ })).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
|
||||
const updatedSource = await screen.findByRole('button', {
|
||||
name: /source-art--1.png/,
|
||||
});
|
||||
fireEvent.click(updatedSource);
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: /source-art--1.png/ }),
|
||||
).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
|
||||
expect(
|
||||
layoutWrites.every(
|
||||
(write) =>
|
||||
!(write.positions as Array<{ resourceId: string }>).some(
|
||||
(position) => position.resourceId === 'asset:canvas-output-1',
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
graphReads.every((read) => {
|
||||
const resourceIds = (
|
||||
read.resources as Array<{ resourceId: string }>
|
||||
).map((resource) => resource.resourceId);
|
||||
return (
|
||||
resourceIds.includes('asset:source-art') &&
|
||||
!resourceIds.includes('asset:canvas-output-1')
|
||||
);
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('reuses a kept refine draft and recovers its generation after a real workbench remount', async () => {
|
||||
installTauri();
|
||||
const firstWorkbench = render(<LiveWorkbench />);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: /source-art\.png/ }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
|
||||
await waitFor(() => {
|
||||
expect(canvasFixture.recoveredDraftIds).toHaveLength(1);
|
||||
});
|
||||
const keptDraftId = canvasFixture.recoveredDraftIds[0];
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
|
||||
|
||||
firstWorkbench.unmount();
|
||||
render(<LiveWorkbench />);
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: /source-art\.png/ }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(canvasFixture.recoveredDraftIds).toEqual([
|
||||
keptDraftId,
|
||||
keptDraftId,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps an existing search while replacing the same final-image resource', async () => {
|
||||
installTauri();
|
||||
render(<LiveWorkbench />);
|
||||
const search = await screen.findByLabelText('搜索项目资源');
|
||||
fireEvent.change(search, { target: { value: 'source-art' } });
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: /source-art\.png/ }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: '完成测试保存' }),
|
||||
);
|
||||
|
||||
expect(await screen.findByLabelText('测试素材创作画布')).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
|
||||
const restoredSearch = (await screen.findByLabelText(
|
||||
'搜索项目资源',
|
||||
)) as HTMLInputElement;
|
||||
expect(restoredSearch.value).toBe('source-art');
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /source-art--1.png/ }),
|
||||
).not.toBeNull();
|
||||
expect(screen.queryByText('新资源已保存,但被当前搜索条件隐藏')).toBeNull();
|
||||
});
|
||||
|
||||
it('derives a document non-destructively and retries with the original operation identity', async () => {
|
||||
const { deriveCalls } = installTauri({ failFirstDerive: true });
|
||||
render(<DerivedWorkbench />);
|
||||
@@ -869,22 +507,6 @@ describe('project resource live canvas integration', () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('normalizes a completed task image before opening the existing refine canvas', async () => {
|
||||
const { normalizeCalls } = installTauri();
|
||||
render(<TaskImageWorkbench />);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: /task-hero\.png/ }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
|
||||
expect(
|
||||
(await screen.findByLabelText('测试素材创作画布')).textContent,
|
||||
).toContain('refine:normalized-task-hero');
|
||||
expect(normalizeCalls).toHaveLength(1);
|
||||
expect(normalizeCalls[0]?.sourcePath).toBe('assets/task-hero.png');
|
||||
expect(normalizeCalls[0]?.producerTaskId).toBe('art-asset-plan');
|
||||
});
|
||||
|
||||
it('lists an unfinished edit and resumes it using only the private-ledger operation id', async () => {
|
||||
const { resumeCalls } = installTauri({ pendingResourceEdit: true });
|
||||
render(<DerivedWorkbench />);
|
||||
|
||||
@@ -29,12 +29,11 @@ function resource(
|
||||
}
|
||||
|
||||
describe('全类型资源编辑能力模型', () => {
|
||||
it('把现役资源稳定分流到图片画布、媒体派生、文本派生和版本分支', () => {
|
||||
it('把现役资源稳定分流到图片派生、媒体派生、文本派生和版本分支', () => {
|
||||
const cases: Array<{
|
||||
resource: ProjectResource;
|
||||
route: 'image-canvas' | 'derive';
|
||||
route: 'derive';
|
||||
editKind?: string;
|
||||
normalizeSource?: boolean;
|
||||
}> = [
|
||||
{
|
||||
resource: resource({
|
||||
@@ -44,8 +43,8 @@ describe('全类型资源编辑能力模型', () => {
|
||||
mediaType: 'image/png',
|
||||
manifestAssetId: 'png',
|
||||
}),
|
||||
route: 'image-canvas',
|
||||
normalizeSource: false,
|
||||
route: 'derive',
|
||||
editKind: 'image-reference',
|
||||
},
|
||||
{
|
||||
resource: resource({
|
||||
@@ -54,8 +53,8 @@ describe('全类型资源编辑能力模型', () => {
|
||||
path: 'hero.webp',
|
||||
mediaType: '美术产物',
|
||||
}),
|
||||
route: 'image-canvas',
|
||||
normalizeSource: true,
|
||||
route: 'derive',
|
||||
editKind: 'image-reference',
|
||||
},
|
||||
{
|
||||
resource: resource({
|
||||
@@ -163,15 +162,7 @@ describe('全类型资源编辑能力模型', () => {
|
||||
expected.resource,
|
||||
);
|
||||
expect(capability.route, expected.resource.id).toBe(expected.route);
|
||||
if (capability.route === 'derive') {
|
||||
expect(capability.editKind, expected.resource.id).toBe(
|
||||
expected.editKind,
|
||||
);
|
||||
} else {
|
||||
expect(capability.normalizeSource, expected.resource.id).toBe(
|
||||
expected.normalizeSource,
|
||||
);
|
||||
}
|
||||
expect(capability.editKind, expected.resource.id).toBe(expected.editKind);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -144,14 +144,13 @@
|
||||
|
||||
```text
|
||||
resource-overview
|
||||
-> asset-canvas.create(当前临时禁用,不向普通用户开放)
|
||||
-> asset-canvas.refine(静态图片“编辑资源”)
|
||||
-> resource-editor.derive(SVG、视频、音频、文档/代码、Agent 回执“编辑资源”)
|
||||
-> resource-editor.derive(静态图片、SVG、视频、音频、文档/代码、Agent 回执“编辑资源”)
|
||||
-> resource-editor.version-branch(项目版本“编辑资源”)
|
||||
-> ui-editor(UI 原型与 UI 资源“编辑资源”)
|
||||
-> run(存在 runnableVersion 且 loopback preview 可启动)
|
||||
|
||||
asset-canvas.create|refine
|
||||
-> resource-overview(取消、保留草稿退出或保存投影完成)
|
||||
resource-editor.* | ui-editor
|
||||
-> resource-overview(取消或保存投影完成)
|
||||
|
||||
run.playing
|
||||
-> run.paused(用户暂停或切片结束)
|
||||
@@ -219,23 +218,23 @@ type ProjectWorkbenchViewState = {
|
||||
centerState:
|
||||
| { kind: 'resource-overview' }
|
||||
| {
|
||||
kind: 'asset-canvas';
|
||||
sessionId: string;
|
||||
draftId: string;
|
||||
intent: 'create' | 'refine';
|
||||
sourceAssetId: string | null;
|
||||
kind: 'resource-editor';
|
||||
flowId: string;
|
||||
operationId: string;
|
||||
editKind: string;
|
||||
}
|
||||
| { kind: 'ui-editor'; resourceId: string }
|
||||
| { kind: 'run' };
|
||||
approvalMode: 'strict' | 'risk' | 'none';
|
||||
expandedAgentGroups: Array<'balance' | 'audio' | 'publishing'>;
|
||||
};
|
||||
```
|
||||
|
||||
旧 `game-creator-workbench-view.v1.mode='resources'` 读取时只映射到 `resource-overview`,`mode='run'` 映射到 `run`;旧状态不能合成 asset-canvas 草稿。`sessionId` 是本次进入流程的短生命周期 UUID,`draftId` 是本地可恢复草稿 UUID。`create` 必须没有源资产,`refine` 必须绑定当前 manifest 中唯一图片资产。`approvalMode` 仍只有 `strict` 可有效写入;其它值只能作为不可用选项展示。
|
||||
旧 `game-creator-workbench-view.v1.mode='resources'` 读取时只映射到 `resource-overview`,`mode='run'` 映射到 `run`。`flowId` 是本次进入编辑流程的短生命周期 UUID,`operationId` 与幂等键只属于当前会话,编辑中状态不持久化。`approvalMode` 仍只有 `strict` 可有效写入;其它值只能作为不可用选项展示。
|
||||
|
||||
### 5.2 资源画布布局(P1)
|
||||
|
||||
实现状态(2026-08-22):dependency / type 双模式通过项目内 CAS sidecar 独立持久化;dependency 模式由 Tauri Rust 只读构建关系拓扑与确定性依赖深度、前端 SVG 派生几何,图结构和线段均不写入布局 sidecar。依赖图加载完成前设布局初始化屏障,避免以临时 `dependencyDepth=0` 生成并持久化错误坐标。非空资源总览支持自动布局、资源卡点击与手动拖动;素材创作无限画布独立支持 viewport 与图层变换,分区高度/倍率与 sidecar 完全分离。
|
||||
实现状态(2026-08-22):dependency / type 双模式通过项目内 CAS sidecar 独立持久化;dependency 模式由 Tauri Rust 只读构建关系拓扑与确定性依赖深度、前端 SVG 派生几何,图结构和线段均不写入布局 sidecar。依赖图加载完成前设布局初始化屏障,避免以临时 `dependencyDepth=0` 生成并持久化错误坐标。非空资源总览支持自动布局、资源卡点击与手动拖动;资源画本按主画布与子画布各自保留 viewport,分区高度/倍率与 sidecar 完全分离。
|
||||
|
||||
```ts
|
||||
type ProjectResourceCanvasLayout = {
|
||||
|
||||
@@ -8196,3 +8196,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
||||
- 历史 manifest 缺少字段时读取按 `kind` 派生分类、`tags` 取空数组;显式合法分类原样保留,未知分类字符串按前向兼容退回 `kind` 派生。新建条目写派生默认值,更新既有条目保留已有 `category` / `tags`,不覆盖用户值。
|
||||
- `kind` 的 canonical 契约本轮不动:`UI`、`font`、`test` 等非 canonical 原始 kind 仍归 `unclassified`,窄化别名留作后续项。
|
||||
- 本轮不做 UI(画布功能画布与筛选、@ 面板标签筛选、标签管理面板)、数据迁移脚本、标签库聚合派生、Agent 检索工具与 `agc_list_registered_assets` 投影扩展。
|
||||
|
||||
## 2026-09-09 AGC 图片编辑改走资源派生,asset-canvas 精修草稿退役(四不写)
|
||||
|
||||
- 决策:V3.0 取消"单个图片的精修草稿"这一形态。理由有三:草稿画布把"编辑中间态"做成了需要持久化、锁、revision CAS、恢复对账和事务 journal 的第二套业务真相,成本与出错面远超它提供的价值;素材不可变(编辑产出新素材)与替换队列已经覆盖同一批用户场景,不需要原地改稿;网站美术编辑器与客户端各维护一份草稿画布,长期必然分叉。因此图片编辑统一走 `derive_local_project_resource` 的 `editKind='image-reference'`,结果固定创建新素材,源素材不变,旧草稿画布按四不写整体删除。
|
||||
- 现权威口径:图片及其它类型的来源核验、请求 DTO、幂等与恢复、资源身份血缘以新增的 `docs/technical/【技术方案】AGC资源派生与非破坏性编辑合同-2026-09-09.md` 为准;资源画布交互、投影与焦点竞态以 `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md` 为准;共享画布包、Host Port 与共享 chrome 以 `docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md` 为准;工作台状态机以 `docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md` 为准。原《客户端素材创作无限画布阶段一合同》整篇退役。
|
||||
- 删除范围:Rust `project/asset_canvas.rs`、`project/asset_canvas/generation.rs`、`project/asset_canvas_tests.rs`、`project.rs` 的模块声明与再导出、`commands.rs` 的 18 个 Tauri 命令、`main.rs` 的 18 行注册;前端 `features/asset-canvas/` 四个文件、`index.tsx` 的草稿路由与提交事件链路、`styles.css` 的 `resources.asset-canvas` 选择器、`tests/assetCanvasSurface.test.tsx` 与相关草稿用例。
|
||||
- 保留项:`canvas.asset_generate` 命令契约(`packages/shared` 与 `shared-contracts` 的正式权限合同,Agent Runtime 仍在使用);`asset-canvas-references` 与 `legacyPrefix=generated-character-drafts` 是 api-server 直传票据白名单契约,服务端白名单本次不动;`packages/image-canvas-core|react` 继续服务网站美术编辑器与资源画本视口;manifest 资产来源 `kind='canvas'`(网页画板项目)与本次退役无关。
|
||||
- 路由口径:`resolveProjectResourceEditCapability` 不再返回 `image-canvas`,PNG/JPEG/WebP 与其他图片类型一样走 `derive + image-reference`,不再要求先正规化为正式 manifest asset。
|
||||
- 过渡项(带删除触发条件):`scripts/check-config.mjs` 的 native-only 白名单新增 `normalize_local_project_raster_resource`,因为原前端调用方随草稿画布一起删除。资源画布重建快速编辑后,该命令重新获得前端调用方,届时删除这条 allowlist 条目。
|
||||
- 存量数据:`.agent/workbench/asset-canvas/` 不写清理代码,不做迁移;只对目录做一次性手工清理,`.agent/workbench/` 下 resource-edits、replacement-queue 等在役目录不得整体删除。
|
||||
|
||||
@@ -189,14 +189,6 @@
|
||||
- 验证:同时挂载至少两个分区和各自同类型关系,断言每条边只存在于对应分区 SVG;只滚动其中一分区,另一分区的逻辑 viewport 与 path 不变。另覆盖缩放后 SVG / 卡片仍在同一 plane、双向离屏继续线、两端离屏隐藏、marker、自环以及 mode / 项目切换清理。
|
||||
- 关联:`apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx`、`apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts`、`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`。
|
||||
|
||||
## 正式保存不能等待 React state 才取得草稿 CAS 的新 revision
|
||||
|
||||
- 现象:用户刚完成编辑就点击“保存到项目”,自动草稿保存已经成功,但正式提交仍携带旧 `expectedDraftRevision`,于是单窗口也得到 draft revision conflict;快速连续保存时还可能使用不同 commitId 重复 staging。
|
||||
- 原因:`setDraft(result.value)` 的 React state 提交晚于当前 Promise 链,正式保存若从闭包或下一次 render 读取 revision,会把 UI 调度时序误当成持久化顺序。相同问题也会出现在选择变化未标脏、父组件每次 render 新建 scope 对象而重复恢复、项目切换后旧 generation 回调继续写 notice。
|
||||
- 处理:草稿保存队列在 CAS 成功后同步更新 `draftRef.current` 并直接返回权威 draft;正式提交继续使用该返回值的 revision。scope 按 project/draft/intent/source 原始字段稳定化,所有导入、保存、生成和事件回调捕获当前 epoch,选择变化属于草稿合同并必须标脏。首次正式保存冻结 commitId/idempotencyKey,未知结果只重放原请求。
|
||||
- 验证:用 deferred Promise 证明草稿 CAS 完成后正式 commit 使用新 revision;相同 scope 值重渲染不重复 recover/load;锁定图层选择进入草稿更新;项目切换后迟到 generation/commit/event 均不改变新会话。
|
||||
- 关联:`apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx`、`apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx`。
|
||||
|
||||
## 共享画布不能用全局 DOM 查询或宿主整包 CSS 作为隐式依赖
|
||||
|
||||
- 现象:页面挂两个画布时,第二个画布点击小地图会移动第一个画布;反复挂载后 wheel 触发多次。Tauri 单独引入网站 `index.css` 时还会带入账号、项目页和历史业务样式,或因仓库外源码解析到第二份 React 而出现 Hook 错误。
|
||||
@@ -210,24 +202,8 @@
|
||||
- 现象:`1280×800` 或更矮窗口中,Supervisor 的消息、Runtime 状态和错误正文共同按内容高度增长,聊天输入被推到栏外;底部 Dock 使用固定宽度卡片时还会在中等宽度造成页面级横向溢出。中央画布的生成卡和状态栏若同时固定高度并隐藏 overflow,进度、失败、重试、保存或取消动作会被裁掉。
|
||||
- 原因:四区工作台没有把“主区内部滚动”和“页面级滚动”分开;Supervisor 的长状态没有独立上限,Dock 卡片不能收缩,宿主又用全局按钮/固定行高样式覆盖共享 chrome。
|
||||
- 处理:桌面工作台使用 `100dvh` 两行网格,第一行 `minmax(0, 1fr)` 承载中央区与 Supervisor,第二行承载 Dock;消息和 Runtime 分别内部滚动,composer 保持最后一行并设置明确层级。Dock 卡片使用可收缩 flex 与文本省略。画布 dialog、进度/失败卡和状态栏设置 `min-height: 0`、受限最大高度与内部滚动;宿主不再覆盖所有按钮,只为主要动作和布局提供 token 化薄样式。
|
||||
- 验证:AppSurface CSS 合同检查 `100dvh`、Supervisor composer、Runtime 内滚动、Dock 常驻/可收缩;素材画布测试检查生成 dialog、operation card、状态栏和窄屏保存动作不会被裁剪。真实入口在 `1280×800` 测量 document/body client 与 scroll 一致;登录门禁不可为视觉测试绕过。
|
||||
- 关联:`apps/ai-game-creator-shell/src/styles.css`、`apps/ai-game-creator-shell/src/features/asset-canvas/assetCanvasSurface.css`、`apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`、`apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx`。
|
||||
|
||||
## 素材保存区不要把机器 subtype 当普通文本框(2026-08-06)
|
||||
|
||||
- 现象:保存区同时显示“画布素材”和裸 `asset` 文本框,普通用户无法判断两者用途;自由修改 kind 会形成无法稳定参与类型布局、Agent 合同和替换兼容性的 subtype。工具栏与保存设置挤在同一行时,主要保存按钮还会被压缩或裁切。
|
||||
- 原因:把 Host Port 的 `name / assetKind / mediaType` DTO 直接映射成同层输入控件,没有区分用户命名、机器分类和编码格式,也没有为中央区域的真实容器宽度保留主操作列。
|
||||
- 处理:名称保留编辑;create kind 使用 Runtime 权威四项目录和中文标签,refine 从源 manifest 继承并锁定,未知历史值只透传;格式继续使用有限枚举。工具动作和保存设置显式上下分行,保存列使用 `max-content + nowrap`,窄容器时按钮独占整行。普通工作区状态只显示项目名称,不把绝对路径作为默认辅助文案。
|
||||
- 验证:Surface 测试断言四项用途、默认值、精修未知 kind 锁定、最终 commit 参数和保存按钮 CSS;AppSurface 断言普通界面找不到绝对路径,内部 Tauri 调用仍使用原完整路径。
|
||||
- 关联:`apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx`、`apps/ai-game-creator-shell/src/features/asset-canvas/assetCanvasSurface.css`、`apps/ai-game-creator-shell/src/features/project-workspace/`。
|
||||
|
||||
## 正式素材提交不能把多文件写入或 Tauri 事件误当成一次原子动作
|
||||
|
||||
- 现象:图片已经落到 `assets/` 但 manifest 没有资产,或 manifest 已追加而 project revision/草稿仍是旧值;进程在 emit 前后退出后,用户重试又得到第二份图片、第二个 asset 或重复选中。
|
||||
- 原因:文件系统只保证单文件原子替换,不能让最终图片、manifest、`.agent/runtime/project-revision.json`、commit ledger 和草稿跨文件物理原子;Tauri event 也没有跨崩溃 exactly-once。若先写副作用再临时生成幂等身份,或只凭目标文件存在推断成功,就无法区分未提交、已提交未回包和部分提交。
|
||||
- 处理:第一次保存前冻结 `commitId + idempotencyKey + eventId + requestFingerprint`,在项目 write lock 内先写 prepared journal 和 before/after 摘要,再按最终图片、manifest/revision 逻辑原子更新、回读、ledger/草稿提交推进,释放锁后最后 emit。恢复只按 journal stage、精确字节摘要和 ledger 前向完成/安全回滚;矛盾状态进入 reconciliation-required。事件采用至少一次,监听方按 eventId 和 project revision 去重。
|
||||
- 验证:分别在 prepared、图片安装、manifest 安装、revision 安装、ledger 提交、emit 和投递标记后强杀;确认只有唯一 `canvas-<commitId>`、revision 最多推进一次、源资产与血缘正确,响应丢失后返回 already-committed,矛盾 fixture 不自动重试。
|
||||
- 关联:`docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md`。
|
||||
- 验证:AppSurface CSS 合同检查 `100dvh`、Supervisor composer、Runtime 内滚动、Dock 常驻/可收缩;资源编辑壳与资源画本的 dialog、状态栏和窄屏保存动作不会被裁剪。真实入口在 `1280×800` 测量 document/body client 与 scroll 一致;登录门禁不可为视觉测试绕过。
|
||||
- 关联:`apps/ai-game-creator-shell/src/styles.css`、`apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`。
|
||||
|
||||
## 素材保存成功不等于迟到结果仍有权抢占当前焦点
|
||||
|
||||
@@ -4720,29 +4696,13 @@
|
||||
- 处理:全部全局 sink 测试共用一把 test-only 串行锁,并由 RAII guard 在 `Drop` 中无条件清空;测试统一使用 `manifest_invalidation_sink_isolation_` 前缀。relay fixture 对 accept 和 payload 分别使用非阻塞轮询与总 deadline,不使用固定 sleep;生产 loopback、token、连接 / 写入超时和 payload 大小校验保持不变。
|
||||
- 验证:用 `--test-threads=2` 重复运行统一 filter,覆盖正常 relay、无事件 accept 超时、不完整 payload 超时、panic 展开清理,以及 GUI owner attach 配置与 guard 清理。
|
||||
|
||||
## 远端图片 completed 不能冒充本地资源创建成功(2026-08-05)
|
||||
|
||||
- 现象:External operation 已返回 completed,但稳定引用缺失、下载失败、正式资产事务中断或 manifest 已提交而 UI 事件丢失时,界面仍可能提前显示“资源创建成功”,重复回调还可能再次下载、写文件或登记资源。
|
||||
- 原因:把远端生成、媒体传输、本地 durability、manifest 投影、布局和选择压成一个 completed 布尔值;同时把 External idempotencyKey、operationId 或 taskId 暴露到公开草稿,导致恢复逻辑从非权威状态重建请求或误绑本地 task graph。
|
||||
- 处理:使用私有 generation ledger 保存原请求、External 身份、稳定远端引用、固定 staging token 与本地 commit 身份;公开面只投影不可逆阶段。启动时先恢复阶段三事务,再恢复原 generation;重复 completed 先检查远端引用、staging 和 committed ledger,只有 `committed | already-committed` 才进入 manifest 投影。用户取消等待只推进 focus generation,不删除账本或伪装远端取消。
|
||||
- 精修补充:`sourceImageSrc` 是可下载的稳定媒体引用,`sourceResourceId` 是资源身份,二者不能因为都可表现为字符串就填同一个 objectKey。本地 `local-asset:*` 只保留在本地 manifest 血缘;没有真实 External resourceId 时省略 `sourceResourceId`。
|
||||
- 验证:覆盖确认前零调用、同 key 连点、accepted 重启 GET-only、重复 completed、取消后迟到、下载后本地事务恢复、事件丢失、切项目/改选择、旧轮询隔离、实时布局与选择、精修血缘及敏感字段零泄漏。
|
||||
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs`、`apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx`、`docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md`。
|
||||
|
||||
## 可恢复生成账本不能持久化 direct-upload ticket(2026-08-05)
|
||||
|
||||
- 现象:为支持参考图上传中断恢复,把完整 upload ticket 放进 generation ledger;账本随之包含 Provider host、formFields、policy、signature 或临时 Authorization,项目目录泄露即可复用临时凭证。
|
||||
- 原因:把“恢复所需的稳定远端身份”和“仅供一次上传的临时授权材料”当成同一种持久状态。原子 sidecar 只能保证写入完整,不能让敏感字段变安全。
|
||||
- 处理:ticket 结构不实现 Serialize/Deserialize,host/formFields 只在本次内存调用中使用。账本在上传前只保存稳定 bucket/objectKey;重启先用这组身份调用 object confirm,确认成功后只保留 objectKey/assetObjectId 并清掉上传中间态。账本测试必须直接序列化完整 ledger,扫描 Provider URL、Authorization、policy、signature、API Key 和 ticket 字段名。
|
||||
- 验证:运行 `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml private_generation_ledger_never_serializes_upload_credentials_or_provider_url`,并继续检查公开草稿、manifest、事件和普通错误不含 prompt、operationId、Key、绝对路径或媒体正文。
|
||||
|
||||
## 客户端内部用途目录不能直接作为 legacyPrefix(2026-08-10)
|
||||
|
||||
- 现象:本地图片精修或视频、音频等全类型资源编辑点击生成后立即提示参考资源上传失败;私有账本的 `uploadBucket/uploadObjectKey/operationId/requestBodyJson` 全为空,服务端也没有 OSS、confirm、生成或扣费记录。
|
||||
- 原因:direct-upload ticket 的 `legacyPrefix` 不是任意业务目录,而是 `platform-oss::LegacyAssetPrefix` 的权威白名单值。把 `asset-canvas-references` 或 `resource-editor-references` 直接放在该字段会被 api-server 在签名之前以 `400` 拒绝;客户端若把票据、OSS 和 confirm 全折叠成一个错误码,还会掩盖真正失败阶段。
|
||||
- 处理:客户端编辑器统一使用合法私有 `legacyPrefix=generated-character-drafts`,把业务用途放入 `pathSegments`:图片画布为 `editor/asset-canvas-references/<projectId>/<draftId>/<generationId>`,全类型资源编辑为 `editor/resource-editor-references/<projectId>/<operationId>`。仍严格执行 ticket → OSS form POST → object confirm,只有 confirm 返回自洽稳定 `objectKey/assetObjectId` 后才允许提交生成;不要为内部目录扩白名单或新建上传接口。图片路径按本地校验、票据、对象上传、对象确认分别使用安全错误码,票据材料继续只驻留内存。
|
||||
- 处理:客户端编辑器统一使用合法私有 `legacyPrefix=generated-character-drafts`,把业务用途放入 `pathSegments`:全类型资源编辑为 `editor/resource-editor-references/<projectId>/<operationId>`。仍严格执行 ticket → OSS form POST → object confirm,只有 confirm 返回自洽稳定 `objectKey/assetObjectId` 后才允许提交生成;不要为内部目录扩白名单或新建上传接口。图片路径按本地校验、票据、对象上传、对象确认分别使用安全错误码,票据材料继续只驻留内存。
|
||||
- 验证:客户端端到端测试必须断言 confirm 早于生成 POST、请求使用精确前缀与 pathSegments、账本只持久化稳定对象身份;票据失败时断言 `operationId/requestBodyJson` 为空且 manifest 只有源资产。api-server 测试应断言生成的 key 位于 `generated-character-drafts/editor/...` 且 access 为 private。
|
||||
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs`、`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`、`server-rs/crates/api-server/src/assets.rs`。
|
||||
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`、`server-rs/crates/api-server/src/assets.rs`。
|
||||
|
||||
## Tauri 有平台登录 Token 不代表应调用主站画布 API(2026-08-10)
|
||||
|
||||
@@ -4750,7 +4710,7 @@
|
||||
- 原因:把“客户端壳有账号登录能力”误当成“客户端画布属于主站网页宿主”。主站和 Tauri 虽复用相同请求 DTO 与后端生成服务,但对外边界不同:主站使用站内认证路由,Tauri 远端媒体能力使用 Developer API Key 和 External v1 路由。
|
||||
- 处理:Tauri 前端不读取、透传或持久化站内 Access Token;Rust 只从发布 AppData 私有 `editorApi.baseUrl/apiKey` 解析 External 凭据。图片、视频、音效、BGM 的项目/素材库、上传、确认、生成、轮询与换签全部留在 `/api/external/v1`,不访问内部 job 查询或账号/profile 接口。账本只绑定 External 配置身份指纹,升级前遗留的站内 endpoint 必须进入待对账状态,不能拿 External Key 自动重放。Key 缺失或无权限只返回安全配置错误,不打印 Key、Authorization、Provider 正文或私有路径。
|
||||
- 验证:前端测试断言 command input 不含 `accessToken/apiKey`;Rust mock 服务器拒绝任何 `/api/editor/*`、`/api/assets/*` 和 `/api/runtime/external-generation/jobs/*` 请求,并覆盖 External `202`、原 operation 轮询、换签、非破坏性本地提交和账本零凭据。主站路由与 OpenAPI 未发生契约变化时不得为了客户端切换修改后端接口。
|
||||
- 关联:`apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs`、`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`。
|
||||
- 关联:`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`。
|
||||
|
||||
## prepared journal 之前同样存在正式事务崩溃窗口(2026-08-05)
|
||||
|
||||
@@ -4993,7 +4953,7 @@
|
||||
- 追加审计回滚陷阱:Direct 恢复中 `asset.register` 是 append-only 审计,返回错误不能证明 append 未持久。若 file / manifest 已落盘且审计成功或结果未知,删文件或回滚 manifest 会创造“审计已存在、资源却消失”的第二种不一致,重试还可能复制审计或重新扣费。应保留 file + manifest + audit 现场并标记 `reconciliation-required`;后续 binding 失败也使用同一语义。
|
||||
- Runner 跨 GUI 陷阱:WebView 的 `authGeneration` 会随 GUI 进程重启从较低值重新开始,但 busy Runner 可能仍持有旧 GUI 的高 generation。只用 generation CAS 会把新账号安装误判为过期;只把 OS owner 锁当作授权,或保留独立 `platform.session.install/clear` 入口,又会让 Runner 在单次 IPC 丢失后继续使用旧账号。Runner 协议 v7 由 owner 锁创建随机 epoch,每次会话变更先推进 durable revision claim,并且只允许与 claim 完全匹配的 `runner.attach_gui_owner` 安装会话。Runner 要持续比对 claim,失配立即清空平台会话并拒绝 Runtime 请求;GUI 同步失败还要隔离或停止旧 Runner,不能只向前端报错。
|
||||
- 验证:用真实平台会话 fixture 覆盖 A→B→重启→A,逐个断言 B 的 URL、请求体和稳定引用中没有 A 的 project/resource/object ID;另测同名 localProjectId 隔离、改名不漂移、源摘要变化、非 refine 参考、视频 committed objectKey、art-spec 派生以及在途 operation 切号零网络。Runner 回归还要覆盖高 generation 旧 epoch 被低 generation 新 epoch 正确替换、迟到旧 epoch/revision attach 失败关闭、claim 改写或同步失败后旧 Runner 零 Runtime 请求。Developer Key fixture 不能替代平台账号隔离证据。
|
||||
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs`、`project/resource_editor.rs`、`project/asset_canvas/generation.rs`、`agent/generation/canvas_generation.rs`、`agent/direct_runtime.rs`。
|
||||
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs`、`project/resource_editor.rs`、`agent/generation/canvas_generation.rs`、`agent/direct_runtime.rs`。
|
||||
|
||||
## Direct 美术工具不能把“包存在”当成“本次已生成”(2026-08-23)
|
||||
|
||||
|
||||
@@ -256,6 +256,48 @@
|
||||
- 后续可继续选择更高内聚的交互 workflow 或持久化边界,不再把生成链路继续拆成浅层 wrapper。
|
||||
- 工程资源持久化、工具切换和历史捕获仍在主视图编排,拆分前需要先确认不会破坏多生成对象同时存在、完成时读取最新占位框、素材拖拽上传位置和角色动画优先传 `objectKey` 的历史保护规则。
|
||||
|
||||
## 共享画布合同(2026-09-09 承接)
|
||||
|
||||
本节原属《客户端素材创作无限画布阶段一合同》第 3、4、15 节。该文档随 AGC 侧"单个图片精修草稿"机制退役后,以下与网页美术编辑器、`@genarrative/image-canvas-core`、`@genarrative/image-canvas-react` 和共享 chrome 相关的条款继续有效,改在此维护。
|
||||
|
||||
### 目标目录与依赖方向
|
||||
|
||||
```text
|
||||
packages/image-canvas-core/src/
|
||||
packages/image-canvas-react/src/
|
||||
src/components/image-editor/host/webImageCanvasHostAdapter.ts
|
||||
```
|
||||
|
||||
- 包名固定为 `@genarrative/image-canvas-core` 和 `@genarrative/image-canvas-react`。
|
||||
- `image-canvas-core` 只含纯 TypeScript 的画布模型、几何、选择、图层命令、历史、序列化、防御校验和状态机;不得依赖 React、DOM、Tauri、HTTP、账号、钱包或浏览器存储。
|
||||
- `image-canvas-react` 只含 React 视图、hooks、交互控制器和通用 UI,依赖 core 和注入的 Host Port;不得直接 import Tauri API、站点请求客户端、账户 store 或钱包 store。
|
||||
- 依赖方向只能是"宿主 adapter -> React/UI -> core"。core/react 不得反向 import 任一宿主。
|
||||
- AGC 侧现役消费方是资源画本视口与 UI 编辑器预览(`resourceCanvasLayoutModel`、`resourceBookViewport`、`view/ui-editor/components/preview/PreviewWorkspace.tsx`)。后续资源画布若需要画布能力,必须新建只负责 Tauri 命令、错误翻译和能力注入的薄 adapter,不得复制 core/react。
|
||||
|
||||
### 禁止复制的验收门
|
||||
|
||||
- 现役 `src/components/image-editor/` 中的通用模型、hooks 和视图应移动或抽取到上述共享包,网站改为 import 共享包;不得先完整复制到 `apps/ai-game-creator-shell` 再各自维护。
|
||||
- 网站与 Tauri 对相同画布命令、序列化 fixture 和交互状态机必须运行同一组共享测试。宿主测试只覆盖 adapter 差异。
|
||||
- 客户端目录不得出现共享包已有文件的镜像副本;允许存在只负责 Tauri 命令、错误翻译和能力注入的薄 adapter。
|
||||
- 共享 React 只依赖注入的 Host Port 语义,不依赖具体传输。端口类型定义在 `packages/image-canvas-core/src/ports.ts`,网站美术编辑器继续实现它;草稿画布专属的 `ImageCanvasHostScope`(`draftId`/`intent`)、草稿与提交结果类型不再有 AGC 实现方。
|
||||
|
||||
### 主站 UI 对齐与共享画布 chrome
|
||||
|
||||
- 第一批通用视觉组件固定由 `@genarrative/image-canvas-react` 暴露:`CanvasChromeButton`、`CanvasToolbar`、`CanvasToolbarGroup`、`CanvasToolbarDivider`。
|
||||
- `CanvasChromeButton` 统一原生 button 的可访问名称、tooltip、`aria-pressed`、`aria-expanded`、禁用态和画布 action 视觉;图标、短文案、业务事件和禁用条件由宿主传入。共享组件不得 import Lucide、平台账号 store、钱包 store 或宿主请求客户端。
|
||||
- `CanvasToolbar` 只负责 `role=toolbar`、可访问名称、plain/floating surface 和横向收纳;`CanvasToolbarGroup`、`CanvasToolbarDivider` 只表达动作分组,不知道具体工具 ID 或业务顺序。
|
||||
- 共享 CSS 继续位于 `packages/image-canvas-react/src/styles.css`,选择器必须以 `.genarrative-image-canvas` 前缀开头。颜色优先消费宿主继承的 `--image-canvas-brand-*`,再回退 `--platform-*`,最后才使用包内安全默认值。
|
||||
- 主站现役 `EditorIconButton` 可以保留为传入 Lucide icon 的薄 adapter,但默认画布按钮必须委托 `CanvasChromeButton`;特殊网站浮层或暗色媒体按钮仍可留在宿主。主站底部画布工具栏必须委托 `CanvasToolbar`、`CanvasToolbarGroup` 和 `CanvasToolbarDivider`。
|
||||
- 主题事实源仍是 `packages/shared/src/theme.css`。AGC 只能导入共享主题、`@genarrative/image-canvas-react/styles.css` 和自身宿主样式,禁止导入网站整包 `src/index.css`。
|
||||
- 共享组件测试必须覆盖名称、默认 `type=button`、pressed/expanded、禁用、带短标签动作、floating toolbar surface 和 ref/class/event 透传;主站测试继续锁定工具列表、选中态和 option tool 的 hover/focus 行为。
|
||||
- 桌面 `>760px` 统一使用 `100dvh` 的两行工作台网格,第一行是内部滚动的主视窗 / Supervisor,第二行是常驻 Dock。正式 `1280×800` 基线下页面级尺寸合同为 window/document/body `1280×800`;无登录会话的真实 Vite 入口按安全合同停在登录页,不绕过门禁。
|
||||
|
||||
### 图片画布能力与后续分期
|
||||
|
||||
- 画布 viewport 平移、缩放、适配内容和复位;PNG/JPEG/WebP 导入;图片图层单选、多选、框选、移动、等比与非等比缩放、层序、显隐、锁定、水平/垂直翻转和分组 / 解组;撤销 / 重做最多保留当前会话 `60` 个历史步骤(历史覆盖图层和 viewport 命令,不重发远端生成请求);裁剪、扩图、画布背景和一键去背景的结果保存为受控媒体引用,不能把 base64 媒体塞入 JSON。
|
||||
- 高级蒙版、毛发级抠图、逐像素笔刷和可编辑边缘通道;图集切片 / 打包、角色动画和 image-sequence 编辑;视频时间线、转码、字幕和音轨编辑;音频剪辑、混音、波形和效果器仍属后续分期,不得阻塞图片正式闭环。
|
||||
- 验收门:构建网站与 Tauri 两个宿主时,两者 import 同一 core/react,且客户端目录不出现共享包镜像副本。
|
||||
|
||||
## 验证计划
|
||||
|
||||
- `npm run test -- src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/ImageCanvasStageControllerModel.test.ts src/components/image-editor/useImageCanvasStageController.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# AGC 资源派生与非破坏性编辑合同
|
||||
|
||||
更新时间:`2026-09-09`
|
||||
|
||||
状态:现役。本文是 AI 游戏创作客户端(AGC)"全类型现有资源非破坏性编辑"的权威合同,约束 `apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`、`commands.rs` 的 `derive_local_project_resource` / `normalize_local_project_raster_resource` 与前端 `ResourceEditSurface` 链路。原文出自《客户端素材创作无限画布阶段一合同》第 16 节与第 5 节;该文档随"单个图片精修草稿"机制退役后,这些条款继续有效并在此维护。
|
||||
|
||||
## 1. 入口与来源核验
|
||||
|
||||
- 资源总览的"新增资源"保留为明确禁用态,入口处理函数也必须拒绝 create,不能只依赖按钮外观阻止进入空白画布。create 专属空画布、草稿 sidecar 与图片精修草稿已整体退役,不再作为任何普通用户入口。
|
||||
- 资源聚焦态的所有现役资源均提供"编辑资源",覆盖 manifest asset、已完成任务产物、已导入附件、Agent 文本回执和项目版本。后端必须按 manifest、任务完成态、上传登记或回执身份重新核验来源;没有唯一来源身份的本地媒体不得仅凭前端路径进入编辑。
|
||||
- 各类型统一使用稳定 `operationId / Idempotency-Key`。普通站内响应的 queued operation 与高级 External v1 的 `202` 都只表示受理,必须轮询原 operation;inline 完成则直接消费同一请求的稳定结果。提交结果未知或登录失效时保留原身份供恢复,不能换键重提。签名 URL 不得写入 manifest,manifest 只保存稳定 objectKey 对应的资源 / 资产身份。
|
||||
- 前端不缓存草稿身份:每次进入编辑都生成新的 `operationId` 与幂等键,编辑中状态只存在于当前会话内存,刷新即失效,不持久化。
|
||||
|
||||
## 2. 图片编辑(derive + image-reference)
|
||||
|
||||
- 静态 PNG / JPEG / WebP 统一走 `derive_local_project_resource` 的 `editKind = 'image-reference'`,提交 `POST /api/editor/images/edits`,并把源资源身份作为必选引用;不再使用 `AssetCanvasSurface + intent=refine`,也不再要求先把任务产物或附件正规化为正式 manifest asset。
|
||||
- 源资源已有 manifest 身份时传 `sourceAssetId`;只有本地路径或任务产物时传 `sourcePath + sourceMediaType + producerTaskId`,由后端按 `source.canonical_resource_id` 建立稳定来源身份。
|
||||
- 图片编辑请求严格遵循 `EditorImageEditRequest`,不得携带 `assetKind`、`sourceImageSrc` 或 `sourceResourceId`。Game Agent 本地 kind 在登记编辑器来源前映射到现役 canonical kind;服务端安全业务错误必须结构化返回,不能把全部 400 折叠为同一个"平台明确拒绝"。
|
||||
- 历史 `game-background`、`art-spritesheet` 等私有 kind 不得直接作为远端来源:客户端从本地正式图片重新取得受控字节,按现役映射登记 canonical 主来源(`game-background -> scene`、`art-spritesheet -> icon-spritesheet` 等),`/api/editor/images/edits` 只提交登记后返回的业务资源 ID,不扩大服务端白名单。canonical 来源缓存属于本地私有恢复数据,不写入 manifest,缓存身份至少绑定平台服务身份、平台 owner、Editor Project ID、源图片 SHA256 与 canonical kind。
|
||||
- 结果固定创建新的本地 asset、文件路径和资源身份,源 asset 与源文件不得删除、覆盖或复用;新 asset 的 `referenceResourceIds` 必须包含源资源规范身份,资源总览同时保留新旧资源。
|
||||
|
||||
## 3. 其它现役类型
|
||||
|
||||
- SVG、UTF-8 文档、代码和 Agent 文本回执使用文本差异派生:把源内容当作不可信数据交给当前客户端 LLM,响应必须是完整、唯一的结构化内容 envelope;JSON、SVG 等可校验格式必须在落盘前重新校验。结果写入新的本地路径和 manifest asset,不能直接写回源文件。Agent 回执原记录不转写、不删除,新 asset 以回执资源身份登记血缘。
|
||||
- 文本、SVG 与 Agent 回执在 Provider 调用前必须先持久化 request-issued;成功响应必须先原子安装到与原 operation、请求指纹和内容摘要绑定的私有 durable handoff,再做 envelope 解析、格式校验和 staging。issued 后缺少可信 handoff 只能对账;handoff 已存在且校验通过时恢复只消费该正文,两种情况都禁止再次调用 Provider。
|
||||
- 普通客户端视频使用 `POST /api/editor/videos/generations`。有稳定远端引用时直接作为 `referenceVideoSrcs`,只有本地文件时先走 `/api/assets/direct-upload-tickets`、OSS 表单上传和 `/api/assets/objects/confirm`,再提交同一逻辑生成;结果必须下载到新的本地文件并登记远端稳定身份。
|
||||
- 普通客户端音效和背景音乐分别使用 `POST /api/editor/audios/sound-effects/generations` 与 `POST /api/editor/audios/background-music/generations`。当前接口没有源音频引用字段,因此产品语义固定为"基于原资源语义的派生重制",界面不得描述为对源波形的裁剪、变声或局部修改;结果仍必须引用源资源身份并保留原音频。
|
||||
- 项目版本编辑固定追加 `parentVersionId` 指向源版本的子版本,继承源版本资源绑定并记录本轮编辑提示;已有版本数组元素不可修改、删除或重排。
|
||||
- 角色动画派生结果的 manifest `kind` 固定写 `character-animation`,`source.generationKind` 同步保留 `character-animation`,媒体仍以预览视频加正式序列帧登记。图片编辑等普通派生继续继承源资源语义 kind;后续如需改写为 canonical kind,只能在读取投影或显式迁移中完成,不重写历史 manifest。
|
||||
- 图片编辑、视频、音效和背景音乐请求统一在 `generationInputs.source` 写入专用消费身份 `game-creator-resource-editor`;普通内部路由和高级 External 路由都必须实际读取并传递同一稳定 `Idempotency-Key`。队列完成态只向该消费身份返回经过裁剪的稳定 `objectKey / resource / asset` 引用和必要媒体元数据,不暴露 provider、worker、队列内部字段或临时签名 URL。
|
||||
|
||||
## 4. 资源身份与血缘
|
||||
|
||||
- `projectPath` 只作为 Tauri command 输入,用既有安全路径能力解析为项目根;不得写入账本或 manifest。权威项目身份是当前项目根 `.agent/manifest.json` 的 `projectId`;权威项目 mutation revision 是 `.agent/runtime/project-revision.json` 的 `game-creator-project-revision.v1.revision`,跨 JSON/Tauri/TypeScript 的 revision 限制在 `0..=9_007_199_254_740_991`。所有写命令必须携带 `expectedProjectId`,并在取得对应系统锁后再次验证。
|
||||
- manifest asset ID 与资源关系 ID 是两个命名空间。`referenceResourceIds` 只保存 `GameCreationAppAssetManifestEntry.source.resourceId`,绝不直接保存裸 manifest asset ID;数组稳定去重,最多 `128` 项,每项 `1..512` 个 Unicode 字符,禁止空白和控制字符。
|
||||
- 本地资产缺少 `source.resourceId` 时,规范资源身份固定为 `local-asset:<manifestAssetId>`;派生资源的 `referenceResourceIds` 至少包含源资产的规范资源身份,并可追加本次直接引用的其它唯一资源身份。
|
||||
- 资源依赖图的精确引用身份同时支持远端 `source.resourceId` 与本地 `local-asset:<manifestAssetId>`。派生资源写成本地 canonical 身份时必须能解析到同一项目的唯一资源;候选去重后才允许建立一条 `asset-reference`,无法唯一解析的引用继续进入 `unresolvedReferenceResourceIds`,不得在前端猜测连线。
|
||||
|
||||
## 5. 私有账本与恢复
|
||||
|
||||
- 全类型资源编辑私有账本必须冻结 `sourceAssetId / sourceMediaType / sourceAssetKind / producerTaskId / sourceVersionId` 源快照;新账本不得依赖 UI 仍停留在原资源,旧账本只允许从当前 manifest、已完成任务产物、版本数组和受控扩展名兼容恢复。恢复结果仍要重新校验唯一来源、项目归属与媒体类型,不能把账本快照当成越权凭据。
|
||||
- 普通模式私有账本绑定固定官方 origin 与 ownerUserId,不绑定可轮换的 Access Token;恢复命令不得让前端重新拼请求或把 Token 写入参数。高级 External v1 账本绑定显式服务 origin,同样不绑定可轮换的 Developer API Key。两种模式都不得因凭据轮换改变原 operation;`401/403/404`、轮询超时或网关暂时失败必须保留原 endpoint、请求字节、幂等键和 operation,并先按账本记录的模式与 owner 做隔离校验。
|
||||
- operation 已受理或首次提交结果未知后遇到 `401`,不得写成 terminal failed 或不可逆 `reconciliation-required`。普通模式先按单飞流程刷新;刷新失败、换号或当前 owner 不匹配时保留原 remote phase、operation、原请求字节和原幂等身份,停止网络。重新登录同一 owner 后,已有 `operationId` 只继续 GET 同一 operation,未受理请求才允许用冻结正文和原幂等键重试 POST。高级 External v1 模式修正 Developer API Key 后遵守相同恢复规则。
|
||||
- pending 恢复扫描的目录上限统计所有实际目录条目,包括未知文件、非 pending 账本和损坏条目;达到上限后停止扫描并返回安全错误,不能通过堆放无关文件绕过边界。已 committed 的账本不得出现在 pending 列表中。
|
||||
- 可恢复账本不得持久化 direct-upload ticket:ticket 结构不实现 Serialize/Deserialize,host/formFields 只在本次内存调用中使用。账本在上传前只保存稳定 bucket/objectKey;重启先用这组身份调用 object confirm,确认成功后只保留 objectKey/assetObjectId 并清掉上传中间态。账本测试必须直接序列化完整 ledger,扫描 Provider URL、Authorization、policy、signature、API Key 和 ticket 字段名。
|
||||
- 派生媒体提交必须以账本冻结的源摘要再次校验源文件;摘要、媒体身份或版本父子关系变化时失败关闭。远端派生视频二次编辑必须复用已提交账本中的稳定 `objectKey`,不能把仅用于本地登记的 `assetObjectId` 当成 generation reference。版本 journal 重放只能返回既有子版本,不得重复追加。
|
||||
|
||||
## 6. 验收
|
||||
|
||||
- 每种现役资源都能从"编辑资源"进入对应派生路径;没有唯一来源身份的本地媒体被拒绝,且失败关闭不产生副作用。
|
||||
- 静态图片走 `derive + image-reference` 并实际到达 `candidate-ready`;结果产生新的本地 asset,源 asset、源文件、Agent 回执与源版本均保持不变,`referenceResourceIds` 包含源资源规范身份。
|
||||
- 历史 `game-background` 等私有类型来源不得直接作为远端来源;首次编辑完成 canonical 登记,后续同源请求复用缓存。
|
||||
- 相同 `operationId / Idempotency-Key` 的重放只产生一份结果,不重复扣费、不重复追加版本;`401` 后按账本身份恢复,不换键重提。
|
||||
- `expectedProjectId` 与 revision 校验在锁内二次验证;路径被重建成另一个项目时零副作用失败。
|
||||
@@ -574,9 +574,8 @@ game-project/
|
||||
- 项目工作台只在资源卡本体中渲染已登记图片与安全 SVG,并在卡内展示视频首个可解码画面;中央资源聚焦以路径、MIME、来源、Rust 权威依赖和版本信息为主,不重复挂载或放大图片、SVG、视频。媒体通过受控 Tauri 命令从项目 `assets/` / `game/` 读取,只允许 manifest 已登记资产或已完成任务产物,并复用 `file.read` auto 权限、图片魔数、文件大小、像素尺寸、普通文件、路径漂移和符号链接校验。Rust data URL 只承担一次 IPC 传输,前端立即按实际解码字节建立 Blob URL 并丢弃 base64 字符串;不向 WebView 暴露任意本机文件协议或绝对路径。
|
||||
- 2026-08-03 阶段四在上述图片链路外新增 `read_local_project_text_preview` 与 `read_local_project_media_preview`。前者只接收当前 manifest 已登记文档或已完成任务中的 Markdown / 文本 / JSON / YAML / TOML,限制 2 MiB 与 UTF-8;Agent 文本回执继续直接消费合法对话投影,不反查本地路径。后者的美术分支接收 GIF、安全 SVG、AVIF、BMP、MP4、WebM、MOV,音频分支只接收 manifest 已登记的 MP3、WAV、OGG / Opus、M4A、AAC、FLAC,二进制媒体限制 32 MiB。两条命令统一执行 `file.read` auto 权限、规范化相对路径、项目边界、敏感路径、普通文件、父目录链接、硬链接、读取漂移和重开身份复核;媒体按文件签名而非只按扩展名或 MIME 建立 data URL,SVG 额外拒绝活动内容与外部引用。
|
||||
- `canvas.export_import` 复用 `/editor/canvas` 已有素材导出 ZIP 格式,读取根 `metadata.json`、复制 `images/` / `media/` / `sequences/` 到本地项目 `assets/canvas-imports/`,再按导出层登记为 `canvas` 来源资产;导出包不保存真实 resourceId 时,使用 `canvas-export:<file>` 作为可追踪 assetObjectId,不伪造后端资源行。
|
||||
- 阶段一图片画布正式闭环只覆盖图片。网站 adapter 保留账户、钱包、服务端 editor project、云端素材库、OSS/asset object 与现有生成 API;Tauri adapter 使用本地项目、受控媒体、`game-creator-asset-canvas-draft.v1` 草稿、manifest、项目 mutation revision 和 External Editor API。2026-08-10 起视频和音频进入统一非破坏性派生壳,但高级抠图、图集、角色动画、视频时间线和音频波形级编辑仍后续分期。
|
||||
- Tauri 正式保存必须通过受控 staging 与 `commit_local_project_asset`,携带 `expectedProjectId + expectedRevision + expectedDraftRevision + commitId + idempotencyKey`。事务固定为 prepared journal、最终图片、manifest/revision 可恢复更新、回读验证、committed ledger/草稿、最后发布 `game-creator-local-asset-committed`;不能继续用先推进 revision 再分别登记资产的旧命令拼装正式闭环。
|
||||
- refine 默认保留源文件和源 manifest asset,新建 `canvas-<commitId>` 资产。源资产没有外部 `source.resourceId` 时在同一 manifest 事务中补齐 `local-asset:<manifestAssetId>`,新资产通过 `referenceResourceIds` 引用该规范身份;禁止把裸 manifest asset ID 冒充 External Editor resource ID。
|
||||
- 阶段一图片画布正式闭环只覆盖图片。网站 adapter 保留账户、钱包、服务端 editor project、云端素材库、OSS/asset object 与现有生成 API;Tauri adapter 使用本地项目、受控媒体、manifest、项目 mutation revision 和 External Editor API。2026-08-10 起视频和音频进入统一非破坏性派生壳,但高级抠图、图集、角色动画、视频时间线和音频波形级编辑仍后续分期。
|
||||
- 派生编辑默认保留源文件和源 manifest asset,固定新建新的本地 asset 或子版本。源资产没有外部 `source.resourceId` 时在同一 manifest 事务中补齐 `local-asset:<manifestAssetId>`,新资产通过 `referenceResourceIds` 引用该规范身份;禁止把裸 manifest asset ID 冒充 External Editor resource ID。
|
||||
|
||||
## GameAgent V1.0 项目开发工作台首版界面
|
||||
|
||||
@@ -606,7 +605,7 @@ game-project/
|
||||
- 每个分区使用固定标题行与 `minmax(0, 1fr)` 内部 viewport;分区本体仍是 `.game-resource-canvas-content` 中的普通 grid item。逻辑 plane 继续承载未缩放的卡片坐标和 `180×128` 本体尺寸,外层 frame 按倍率设置真实滚动 extent,plane 仅使用 `transform-origin: 0 0` 的视觉 scale。超出当前高度或缩放范围时由分区 viewport 内部滚动,外层 `.game-resource-canvas` 只负责分区之间滚动。高度、倍率和内部滚动均不调用布局 CAS,也不改变媒体 React key;详情、项目或 mode 切换前捕获对应 scope,返回时分别恢复外层与分区滚动及原卡片焦点,未保存过的 scope 从 `0` 开始。
|
||||
- 固定四区分别在自己的 `.game-resource-plane` 内挂载 `ResourceDependencyOverlay`。每个分区最多创建一个 `ResizeObserver`,并把本区 scroll / resize / observer 回调合并到本区单一在途 `requestAnimationFrame`;SVG 和卡片直接消费同一逻辑坐标与父 plane scale,不通过异步屏幕测量重绑端点。分区 viewport 原生裁剪本区 SVG;一端离屏时只保留对应方向、带同语义 marker 的边界继续线,两端离屏时隐藏。项目 / mode 切换、搜索隐藏、资源删除和卸载继续丢弃旧 viewport / path 并清理 observer 与监听。
|
||||
- 阶段三的聚类已落在 `reconcileResourceCanvasLayout` 的 dependency 自动坐标派生步骤。它先按资源分类过滤 reference edge,并把 task-flow 按固定分类切成仅在同类 source / target 同时存在时有效的聚合超边;每个 section 再用精确边与 flow 临时节点建无向邻接表,以迭代遍历生成弱连通组。task-flow 只以“流节点 -> 成员”的线性成员关联参与布局,绝不展开 source × target 资源组合,不绘制 SVG,也不把搜索后的 visible set 用作输入。`dependencyDepth` 仍唯一决定横向业务层级;相关簇按 `minDependencyDepth + minStableResourceId` 排序,孤立集合置于所有相关簇之后,簇间使用单一布局常量留白。每个相关簇内先按稳定资源 ID 建同层初始序,再做固定两轮左至右 / 右至左的中位数扫描:精确引用读取相邻层的上下游 rank,task-flow 读取另一端成员 rank 的中位数,平局按稳定资源 ID 收口。dependency 自动位置使用 `48px` 列间走线区和 `40px` 行间走线区;每个相关簇以最大层行数确定高度,资源较少的层增加确定性半差偏移而在簇内居中,菱形 / 分叉两侧因此保持均衡。type 模式仍使用原 `16px` 行列间距。跨分类 read model 关系不进入前端聚类、边界偏置或拓扑签名,但 Rust 深度与原始图真相不改。显示坐标必须遵守前后端共享的 `0..=1_000_000` 上限;超深依赖在最后合法列确定性饱和,保留原始 `dependencyDepth`,同列资源继续按稳定顺序纵向避让。若任一自动 `x / y` 无法在合法域内落槽,协调必须在 IPC 前失败关闭,不持续提交必然被 Rust 拒绝的坐标。算法保持 `O(V + E)` 图遍历,加固定轮数的层内稳定排序和现有有界占用索引;4096 资源不允许全量配对。旧的 `manuallyPlaced=true` 坐标先占位并原样保留,聚类只派生自动坐标;同类型拓扑身份签名只记录有界的资源 ID 端点 / 成员,以便深度未变但邻接变化时触发重派生。图边、cluster ID 和签名都不写 sidecar。
|
||||
- 中间主视窗提供 `resource-overview / asset-canvas / resource-editor / run` 四种状态。2026-08-10 起普通用户“新增资源”显示为禁用态且处理函数拒绝 create;所有现役资源从聚焦态“编辑资源”进入非破坏性派生。静态图片继续进入 refine 素材创作无限画布,SVG、视频、音频、文档/代码、Agent 回执和项目版本进入统一资源编辑壳并按能力分流;底层 create 合同仅保留兼容。编辑面板只替换中央区域,不覆盖右侧 Supervisor 或底部 Agent。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled` 或 `aria-disabled`;完成后才允许进入运行表现层。切回资源总览只修改前端展示态,不伪造后端预览暂停结果。
|
||||
- 中间主视窗提供 `resource-overview / resource-editor / ui-editor / run` 四种状态。2026-08-10 起普通用户“新增资源”显示为禁用态且处理函数拒绝 create;所有现役资源从聚焦态“编辑资源”进入非破坏性派生:静态图片走 `derive + editKind='image-reference'`,SVG、视频、音频、文档/代码、Agent 回执和项目版本进入统一资源编辑壳并按能力分流。编辑面板只替换中央区域,不覆盖右侧 Supervisor 或底部 Agent。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled` 或 `aria-disabled`;完成后才允许进入运行表现层。切回资源总览只修改前端展示态,不伪造后端预览暂停结果。
|
||||
- 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执、已导入附件和已完成任务明确登记的产物派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,未完成任务或未在 `artifacts` 中登记的任意本地音频也不冒充正式资源。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
|
||||
- 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar;2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源总览卡片拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供通用工具栏、工具侧边栏或可拖动标题栏。阶段四已补齐安全本地文档、扩展美术媒体与音频聚焦,正文独立滚动,视频 / 音频使用内置媒体控件,失败显示空态。2026-08-30 视觉验收修正:资源总览所有栏目初次适配与复位最多以 `1.5` 倍缩放卡片,避免单个低尺寸卡片被插值放大成糊图;用户主动缩放仍沿用通用画布倍率,并按“排序模式 + 栏目”保留当前会话内的平移和缩放。美术资源聚焦态改为视口级大预览,保留原始资源读取与元数据,不生成第二份缩略图,图片 / 视频预览按弹窗可用高度展示并允许正文滚动。该资源总览边界不限制后续素材创作无限画布内的图片图层移动/缩放、生成和正式回写。
|
||||
- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,并保留素材信息和数值微调面板;两个面板保持原有 `156px` 最小高度,没有真实数据时只让正文为空,不渲染预设字段、默认数值、未载入控件或自然语言功能占位,也不随空内容收缩。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;参数调整首版仍只保留本地 UI 草稿,不修改代码或 manifest。preview server 对 UTF-8 HTML 响应注入固定同源尺寸桥脚本;注入点通过真实 HTML tokenizer 边界定位,保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,并支持省略 `</body>` / `</html>`。桥以 `ResizeObserver` 观察 `documentElement / body` 根布局,结合页面 load、窗口 resize 与字体就绪重新测量;页面可见时另以 `500ms` 低频兜底探测至多 `512` 个元素的实际边界,探测截断时保留 body / scroll 上界,并按连续测量排除随 viewport 同步变化的 `100vh / 100% / bottom / right` 自反馈。相同尺寸元组去重后才以固定版本 `postMessage` 上报,不订阅整页 `MutationObserver`。宿主同时校验消息 origin 和 `event.source`,以实际内容宽高与当前容器宽高计算不超过 `1` 的等比缩放;宿主单独记录最近一次合法上报的 iframe viewport,首次收到由自身 fit 切换产生的新 viewport 测量时只确认该 viewport、不反向改写内容尺寸,待 viewport 稳定后仍接受真实内容宽高变化,从而阻断 `100vh` / 百分比布局在两个适配尺寸之间回灌振荡。重复内容尺寸不更新 React 状态,陈旧 viewport 消息继续忽略。容器 resize 期间保留内容尺寸与已观察 viewport,只按新容器尺寸连续重算缩放,避免拖动窗口时在原生尺寸和 fit 之间闪烁;preview URL 变化时才清空状态并重新测量。放得下时保持 `1:1`,超出时完整缩小并居中,iframe 禁止横纵滚动条,不能以 `overflow: hidden` 直接裁掉超出内容。非 UTF-8 HTML 原样返回,不因适配桥破坏已有预览。
|
||||
@@ -1155,74 +1154,27 @@ game-project/
|
||||
- `.agent/agent.db` 有界尾部读取报告截断时,审计 producer 映射失败关闭,不生成基于不完整审计的 producer、task flow 或对应任务环。前端收到截断 DTO 时只剔除 `producerAssignments`、`taskFlows` 与对应 `cyclicTaskIds`;Rust 根据当前 manifest、精确资源引用和仍可信任务深度下限返回的 `dependencyDepths` 继续保留,前端只校验资源仍存在且深度为非负安全整数,不得自行重算或压平权威深度。精确引用边、reference connection index、`cyclicResourceIds` 与 unresolved references 同样继续保留。
|
||||
-- 资源依赖 SVG 继续作为不可交互装饰层隐藏,但 dependency 画布通过 `aria-describedby` 提供当前可见精确引用和任务流的文本等价列表。中央资源聚焦按稳定 `resourceId` 驱动焦点状态:仅 `null -> id` 或 `idA -> idB` 聚焦详情 region,同一 ID 的 manifest 重投影不得抢走音频、视频、链接或关闭按钮焦点;显式收起和 Escape 恢复画布滚动并优先聚焦原触发卡片。聚焦资源被删除时清理 stale focused / selected ID,关闭详情并把焦点落到资源搜索框;项目切换或运行视图切换清除旧恢复意图,不得恢复旧项目卡片。橙色引用线及箭头使用对 `#fffdfa` 画布达到至少 `3:1` 的颜色。
|
||||
|
||||
## 2026-08-05 客户端素材创作无限画布阶段一合同
|
||||
## 2026-08-05 客户端素材创作共享画布源码与资源编辑闭环
|
||||
|
||||
- 资源总览画布与素材创作无限画布是项目工作台中央主视窗的两个状态。资源总览继续消费 manifest、dependency/type 布局与 Rust 依赖图;素材画布以 `sessionId + draftId + create/refine` 建立独立生命周期。“新增资源”和“精修资源”只切换中央区域,右侧 Supervisor 与底部 Agent 状态栏常驻。
|
||||
- 网站与 Tauri 共用 `packages/image-canvas-core` 和 `packages/image-canvas-react`;网站保留账户、钱包、服务端项目和云端素材库 adapter,Tauri 保留本地项目、受控文件、manifest、项目 revision,以及普通登录态内部编辑器/高级 External v1 双模式 adapter。现役网站画布是抽取来源,不允许整体复制到客户端。
|
||||
- 本地草稿固定保存到 `.agent/workbench/asset-canvas/`,schema 为 `game-creator-asset-canvas-draft.v1`,以 `expectedProjectId + expectedDraftRevision` 做 OS 锁内 CAS;单 JSON `2 MiB`、最多 `4096` 层,媒体正文只进入受控 media/staging 文件。损坏、未知 schema、身份错配、超限和恢复摘要不匹配全部失败关闭。
|
||||
- 正式资产命令固定为 `commit_local_project_asset`,同时绑定 `expectedProjectId`、项目 `expectedRevision`、`expectedDraftRevision`、`commitId`、`idempotencyKey` 和 staging 摘要。事务在项目锁内按 prepared、最终图片、manifest/revision 逻辑原子更新、回读、ledger/草稿提交推进,释放锁后最后发布 `game-creator-local-asset-committed`;事件至少一次并按固定 eventId 去重。
|
||||
- refine 不覆盖源文件或复用源 asset ID。源缺少外部 resourceId 时补齐 `local-asset:<manifestAssetId>`,新 `canvas-<commitId>` 资产用 `referenceResourceIds` 登记源和其它直接引用,避免混淆 manifest asset ID 与 External Editor resource ID。
|
||||
- 提交返回完整最新 manifest;当前项目仍匹配时立即更新项目上下文、资源投影、依赖图和两种布局,布局 ready 后才按焦点守卫决定选中。用户已切项目、切状态、开始新 session、选择其它资源或改变搜索/筛选时,迟到结果不得抢焦点;新资源被隐藏时保留条件并提供显式清除/定位动作。
|
||||
- 图片首版包含平移、缩放、图层选择/移动/缩放、撤销重做、导入、基础编辑、生成、导出和本地回写。2026-08-10 起视频使用源引用派生、音频使用语义重制;高级抠图、图集、角色动画、视频时间线与音频波形级编辑后续分期;本阶段不修改 SpacetimeDB schema。
|
||||
|
||||
## 2026-08-05 客户端素材创作无限画布阶段二共享源码抽取
|
||||
|
||||
- 阶段二只完成网站与 Tauri 可共同解析的唯一画布源码,不进入 Tauri 素材创作业务闭环,也不修改 `project-development` 资源总览页面。`apps/ai-game-creator-shell` 只增加共享包解析、React 去重、作用域 CSS 引入和 `features/asset-canvas/sharedImageCanvasFoundation.ts` 编译入口;草稿 sidecar、Tauri adapter、中央状态接线、生成、正式提交和即时投影仍按阶段一合同留待后续阶段。
|
||||
- `packages/image-canvas-core/src/` 是纯 TypeScript 边界:`types/model/interaction/selection/layerCommands/layerTransform/history/overlays/stageController/stageInteractions/exportModel/ports` 分别承接通用类型与常量、坐标/平移/缩放/适配内容/小地图、单选/多选/框选、图层命令和缩放变换、历史恢复、overlay 定位、stage controller、基础导出模型,以及 project/asset/generation/completion 四组 Host Port。该包不依赖 React、DOM、网站客户端或 Tauri API。
|
||||
- `packages/image-canvas-react/src/` 承接 `CanvasViewport`、`CanvasWorld`、`LayerRenderer`、`SelectionOverlay`、`ZoomControls`、`Minimap`、`CanvasPortal` 和现役 history/viewport/stage hooks。网站 `ImageCanvasStageView`、`ImageCanvasWorldView`、`ImageCanvasPanelDockView` 与 `ImageCanvasEditorPortal` 已直接消费这些组件;旧模型和 hook 文件只保留无业务逻辑 re-export,网站与后续客户端不会再维护第二份核心实现。
|
||||
- 网站继续保留 `ImageCanvasEditorView` 的账号门禁、钱包/计费展示、路由、`editorProjectClient`、云端项目持久化、素材库、上传、生成、媒体换签与业务面板。`src/components/image-editor/host/webImageCanvasHostAdapter.ts` 只负责把这些网站事实组合到共享四组 ports;共享包不读取账号 store、钱包 store、HTTP client 或网站别名。现役视频、音频、序列帧播放器和 Agent 面板仍属于网站 renderer 插槽边界,本阶段没有误下沉到图片首版 core。
|
||||
- 共享样式固定为 `packages/image-canvas-react/src/styles.css`,全部以 `.genarrative-image-canvas` 作用域开头;网站可以继续用 `src/index.css` 提供站点皮肤,Tauri 只导入共享样式和自身 `styles.css`。两套 Vite 都把共享源码显式 alias 到仓库 `packages/`,Tauri dev server 保留 `fs.allow=[repoRoot]`,并通过 `resolve.dedupe=['react','react-dom']` 保证单 React 实例。
|
||||
- portal 默认进入当前 Provider 指定的 root,未指定时才回退 `document.body`;事件监听、`ResizeObserver`、animation frame 和小地图查询均按组件实例清理或限定在当前 viewport。文档中只有一个小地图的旧调用形态允许唯一元素回退,页面存在多个小地图时不做全局猜测。
|
||||
- 阶段二定向证据为共享 core/React 7 项与现役 `src/components/image-editor` 74 个文件、892 项测试通过;主站与 AI 游戏创作壳 typecheck 通过。构建、编码和 diff 门禁以本次任务最终验证记录为准。
|
||||
|
||||
## 2026-08-05 客户端素材创作无限画布阶段三 Tauri 独立闭环
|
||||
|
||||
- 阶段三在 `apps/ai-game-creator-shell/src/features/asset-canvas/` 增加独立 `AssetCanvasSurface` 与薄 Tauri Host Adapter,直接消费阶段二的 core/React/ports。页面实现 `canvas.creating / editing / saving / recovering / failed`,支持 PNG/JPEG/WebP 本地导入、选择、多选、移动、缩放、删除、60 步撤销重做、viewport 平移缩放、小地图、复位和浏览器合成导出;generation port 明确返回 mock unsupported,不发送真实 AI 请求。
|
||||
- 草稿正式保存到 `.agent/workbench/asset-canvas/`,由 Tauri Rust 使用持久 OS 文件锁和 `expectedDraftRevision` CAS 串行化;主草稿、固定 recovery JSON 与实际 recovery 字节的裸 SHA-256 一起安装。主文件损坏时只有 schema、项目/草稿身份、摘要及全部媒体签名、摘要、尺寸和普通文件身份都可信才恢复,否则进入 `reconciliation-required`。单文件限制 `64 MiB`,单草稿媒体总量限制 `512 MiB`。
|
||||
- 正式提交先把合成结果安全写入受控 staging,再进入既有项目 mutation 写锁。锁内重读并校验 manifest、项目身份、项目 revision、草稿 revision 和提交指纹,写 prepared journal/ledger 与 before/after 快照,安全安装唯一最终图片,原子更新 manifest 和 project revision,回读图片/manifest/revision 后提交 ledger 与草稿;释放锁后才发布 `game-creator-local-asset-committed`。同 `commitId/idempotencyKey` 同请求返回原权威结果,同键不同请求失败关闭,同 expected revision 竞争最多一个成功。
|
||||
- 崩溃恢复只按 journal、ledger、before/after 摘要和最终文件三态推进:图片与 manifest/revision 都在 before 时,只清理摘要和尺寸可证明属于本事务的新文件;最终路径不存在可直接回滚;最终路径存在但身份不匹配必须保留文件并进入对账。manifest 已安装而 revision 未安装时前向补 revision;manifest/revision 都是 after 时补齐 ledger、草稿和事件。业务事实完成后 emit 失败不回滚,后续恢复继续按固定 eventId 发布。
|
||||
- refine 只允许从当前 manifest 唯一登记的 PNG/JPEG/WebP 普通文件建立草稿,并复核签名、完整解码、大小、符号链接和硬链接。正式保存保留源 asset 与源文件,为缺失 resourceId 的源补 `local-asset:<sourceAssetId>`,新建 `canvas-<commitId>` 并以 `referenceResourceIds` 引用源资源。
|
||||
- 页面以 project/draft/session epoch 丢弃项目切换、会话切换和卸载后的旧 Promise/事件,并以 eventId 去重 command 返回与至少一次事件。相同 scope 字段的父级重渲染不会重复打开草稿,选择变化也进入草稿 CAS;正式保存直接消费 CAS 返回的新 draft revision,不等待 React state 提交时序。
|
||||
- 本阶段只提供独立 Surface/fixture 与 Tauri commands,不接 `project-development` 资源总览按钮、不做保存后的资源总览自动选中,也不接真实 External Editor API。定向证据为阶段三 Surface `8/8`、共享 core/React `7/7`、既有 AppSurface `351/351`、Rust 素材画布 `11/11` 与客户端 typecheck 通过;AppSurface 保留仓库既有 React `act(...)` 警告,不影响测试结果。
|
||||
|
||||
## 2026-08-05 客户端素材创作无限画布阶段四资源总览闭环
|
||||
|
||||
- `project-development` 已在资源总览提供“新增资源”,在 PNG/JPEG/WebP 聚焦态提供“精修资源”;两者只替换中央主视窗。取消先以草稿 CAS 标记 `cancelled` 再恢复原 dependency/type、搜索、滚动、选择与聚焦上下文。generation 仍为明确 mock,不请求真实 AI。
|
||||
- command 响应与 `game-creator-local-asset-committed` 统一携带 `projectId + draftId + commitId + assetId + projectRevision + committedProjectRevision + eventId`。启动器以 `projectPath + projectId + revision` 合并 manifest,并以 `eventId`、`projectId + commitId + revision` 记录重复身份;响应先到、事件先到、旧轮询和迟到重复均只改变一次当前权威快照。
|
||||
- dependency graph 不再依赖当前可见 mode 才读取;scope 包含 `projectPath + projectId +` 完整资源输入签名(含精确引用)。dependency 图未 ready 时不启动该 mode 布局读写;graph、dependency layout、type layout 都以签名化 epoch 丢弃旧请求,新 scope 不等待旧请求槽位。两份布局独立补齐新 ID,并保留全部仍有效历史坐标;图结构和布局继续不写 manifest。
|
||||
- 保存开始冻结 `saveAttemptId + sessionId + draftId + commitId + focusGeneration`。迟到提交始终可以更新当前项目的权威 manifest,但只有 flow、项目、保存尝试和 generation 仍匹配的最新意图可以继续定位。定位依次等待资源投影、dependency/type 两份布局 settled 且均有位置、当前筛选可见和 `data-resource-id` DOM 卡片存在;完成后按 commitId 只执行一次。搜索隐藏时保留条件并提供“清除搜索并定位”,该显式动作建立新的 focus generation。
|
||||
- 锁职责复核保持阶段三实现:正式提交/恢复固定为“项目 mutation write lock -> asset-canvas draft/transaction lock -> manifest store lock”;资源布局只取得独立 `.layout.lock`,不取得项目 mutation 或 asset-canvas 锁,布局协调只发生在提交释放锁并返回/发事件之后,不形成反向锁顺序。
|
||||
- 阶段四新增纯状态机与中央主视窗集成测试,覆盖响应/事件双顺序、旧 manifest、旧 graph/layout、切项目、改选择、连续保存、搜索隐藏、布局未完成、DOM 一次聚焦、refine 真实依赖以及无 reload/重开项目。最终通过项以本次任务验证记录为准。
|
||||
|
||||
## 2026-08-05 客户端素材创作无限画布阶段五真实图片生成
|
||||
|
||||
- 阶段五只接图片生成与图片精修,通过阶段二共享 `ImageCanvasGenerationPort` 调用客户端 Tauri adapter,并复用现有编辑器项目、统一生成队列、资产上传/换签、稳定引用解析和受控下载能力;不复制网站 `useImageCanvasGenerationWorkflow`,不新增角色动画、视频、音频、图集或平行生成系统。
|
||||
- 生成面板和确认摘要只操作本地状态;用户点击独立“确认生成”后才通过 `canvas.asset_generate` confirm 权限边界。本地生成意图建立时即分配稳定 `intentId/generationId/idempotencyKey`,取消确认则直接丢弃且零 External 调用;一旦提交,重复点击、重启和未知结果保持原身份,不能自动确认或换键重扣。
|
||||
- 每个网络动作在发出前冻结 `ownerUserId + authGeneration`,并在响应解析、账本推进、媒体安装和正式提交前复核当前账号。旧 generation 的迟到响应或清除不得覆盖新账号。普通模式首次 `401` 时,每个 generation 只允许一个共享 refresh;其它请求等待它,但保留各自 deadline/取消。refresh 成功后以更高 generation CAS 安装新 Token,并仅用原 endpoint、原始请求字节和原 `Idempotency-Key` 重试一次;refresh 失败、账号切换或再次 `401` 时停止网络并保留账本,`403` 不触发 refresh。
|
||||
- 调用模式、精确相对 endpoint、精确请求、prompt、idempotency key、operationId 和稳定产物引用只进入 `.agent/workbench/asset-canvas/generations/` 私有账本。本地参考图 direct-upload ticket 的 host/formFields/policy/signature/临时 Authorization 只存在内存;恢复账本只允许保存稳定 bucket/objectKey。普通账本身份固定为 `official-platform-v1 + 官方 origin + ownerUserId`,高级账本身份固定为 External service origin;均不绑定或保存 Token/Key。换号后旧 owner 账本零网络、零安装,只有重新登录同一 owner 后才能恢复。`accepted/running` 只 GET 原 operation,`prepared` 只精确重放冻结 endpoint、原始请求字节和原幂等键。草稿/manifest/event/日志只投影安全阶段与本地身份。
|
||||
- 固定状态顺序为 `generation-accepted -> generation-running -> remote-completed -> media-downloaded -> asset-durable-committed -> manifest-projected -> layout-ready -> selected`。远端 completed 后必须先验证稳定引用,再经 `/assets/read-url` 下载并写入阶段三 staging;随后用账本中的固定本地 commit 身份进入既有 journal/ledger 事务。正式提交失败只恢复本地事务,不重新生成或下载。
|
||||
- 应用重启时 `accepted/running` 只恢复 GET,`prepared` 只以私有账本原请求字节和原键恢复同一 POST。用户停止等待只失效当前 focus generation;不可取消的远端操作继续 reconcile,迟到 completed 可以更新原项目但不能抢焦点。配置缺失、结果引用不稳定、下载/提交失败和未知结果都不得显示资源创建成功。
|
||||
- 最终审计后 Tauri 撤销/重做直接使用与网站相同的共享 `useCanvasHistory`,共享 history 同时恢复 viewport、选择、图层位置和缩放边界;Tauri 自身只保留 Pointer 事件接线,平移、缩放、选择、图层变换和 renderer 算法继续来自共享 core/react。
|
||||
- 正式事务把首个快照、全部快照和 journal 写入后但 ledger 写入前纳入故障矩阵。恢复发现无 ledger 的 transaction 时,只有目录内容全部属于受控快照/临时文件、正式目标文件不存在、manifest 与 project revision 仍精确等于 before,才清理目录并返回 rolled-back;任何未知文件或权威状态变化都失败关闭。同一 commit/idempotency 身份在清理后可重放,最终仍只登记一份资产。
|
||||
|
||||
## 2026-08-06 Game Agent 画布 UI 对齐阶段三至五
|
||||
|
||||
- 中央 `AssetCanvasSurface` 沿用现有 create/refine、生成、保存、取消和恢复接线,直接消费 `@genarrative/image-canvas-react` 的 `CanvasChromeButton / CanvasToolbar / CanvasToolbarGroup / CanvasToolbarDivider`。宿主不再维护全局按钮皮肤,只保留字段、面板定位、主要动作和平台 token 映射;主站完整 `src/index.css` 没有进入 Tauri。
|
||||
- 顶部动作与保存设置分层,工具组保持中文单行并允许工具栏受控横向收纳;生成设置仍是独立 dialog。进度/失败卡、dialog 和底部状态栏使用内部滚动,不再由固定高度或父级 overflow 裁剪,保存/确认使用平台 primary 层级。
|
||||
- 工作台仍是左导航、中央主视窗、右 Project Supervisor、底部 Agent Dock 四区。中央区使用主面板层级;Supervisor 的消息/Runtime 独立滚动且 composer 常驻;Dock 改为较轻的持续状态 surface 与可收缩卡片。桌面网格按 `100dvh` 将 Dock 固定在第二行,`≤760px` 只保留开发兼容堆叠。
|
||||
- AppSurface/CSS 合同锁定 `1280×800` 下的工作台无页面级溢出、Supervisor composer、主要保存动作和 Dock 可见;共享/Tauri 定向用例锁定按钮名称、工具组/分隔符、pressed/expanded/disabled 和生成/失败/保存/取消非裁剪。真实 Vite 页面测得 window/document/body client 与 scroll 均为 `1280×800`;无登录会话时按既有安全合同停在登录门禁,不增加或使用测试绕过。
|
||||
- 本次只调整 UI 和测试/文档,未改变图片生成请求、登录态、泥点计费、钱包刷新、Host Port、Tauri command/event、草稿 CAS、manifest/revision、幂等恢复、Runtime、审批、资源投影或保存事务。
|
||||
- 2026-08-06 视觉复核进一步锁定保存区语义:素材名称保留用户编辑;create 的资源用途改为四项中文固定选择并默认普通游戏美术;refine 从源 manifest 继承并锁定 kind,避免普通用户直接编辑内部 `assetKind` slug。顶部工具与保存设置使用明确的纵向两层布局,保存设置在宽主视窗中以 `620px` 为最大宽度向中部延伸,在较窄中央区占满可用宽度;保存按钮不可压缩,窄容器独占整行,任一设置都不得越过中央主视窗右边界。保存字段标题必须完整显示,输入值与下拉选项使用和左侧画布动作接近的紧凑字号,字段列保留足够最小宽度,不能依靠省略标题换取空间;Supervisor 和普通聊天工作区状态把绝对路径收口为项目名称。
|
||||
- 网站与 Tauri 共用 `packages/image-canvas-core` 和 `packages/image-canvas-react`;网站保留账户、钱包、服务端项目和云端素材库 adapter,Tauri 保留本地项目、受控文件、manifest、项目 revision,以及普通登录态内部编辑器 / 高级 External v1 双模式 adapter。现役网站画布是抽取来源,不允许整体复制到客户端。
|
||||
- `packages/image-canvas-core/src/` 是纯 TypeScript 边界:`types/model/interaction/selection/layerCommands/layerTransform/history/overlays/stageController/stageInteractions/exportModel/ports` 分别承接通用类型与常量、坐标 / 平移 / 缩放 / 适配内容 / 小地图、单选 / 多选 / 框选、图层命令和缩放变换、历史恢复、overlay 定位、stage controller、基础导出模型,以及 project/asset/generation/completion 四组 Host Port。该包不依赖 React、DOM、网站客户端或 Tauri API。
|
||||
- `packages/image-canvas-react/src/` 承接 `CanvasViewport`、`CanvasWorld`、`LayerRenderer`、`SelectionOverlay`、`ZoomControls`、`Minimap`、`CanvasPortal` 和现役 history/viewport/stage hooks。网站 `ImageCanvasStageView`、`ImageCanvasWorldView`、`ImageCanvasPanelDockView` 与 `ImageCanvasEditorPortal` 已直接消费这些组件;旧模型和 hook 文件只保留无业务逻辑 re-export,网站与客户端不再维护第二份核心实现。
|
||||
- 共享样式固定为 `packages/image-canvas-react/src/styles.css`,全部以 `.genarrative-image-canvas` 作用域开头;网站可以继续用 `src/index.css` 提供站点皮肤,Tauri 只导入共享主题、共享样式和自身 `styles.css`。两套 Vite 都把共享源码显式 alias 到仓库 `packages/`,Tauri dev server 保留 `fs.allow=[repoRoot]`,并通过 `resolve.dedupe=['react','react-dom']` 保证单 React 实例。
|
||||
- 图片能力首版包含平移、缩放、图层选择 / 移动 / 缩放、撤销重做、导入、基础编辑、生成、导出和本地回写;2026-08-10 起视频使用源引用派生、音频使用语义重制。高级抠图、图集、角色动画、视频时间线与音频波形级编辑后续分期;本阶段不修改 SpacetimeDB schema。
|
||||
- 资源总览的“新增资源”保持禁用;所有现役资源从聚焦态“编辑资源”进入非破坏性派生(图片为 `derive + editKind='image-reference'`),成功路径都追加新文件、新 manifest asset 或新版本,源资源、源文件和原版本不覆盖、不删除、不重排。
|
||||
- dependency graph 不依赖当前可见 mode 才读取;scope 包含 `projectPath + projectId +` 完整资源输入签名(含精确引用)。graph、dependency layout、type layout 都以签名化 epoch 丢弃旧请求;两份布局独立补齐新 ID 并保留全部仍有效历史坐标,图结构和布局不写 manifest。
|
||||
- 保存或派生开始冻结 `saveAttemptId + flowId + focusGeneration`。迟到结果始终可以更新当前项目的权威 manifest,但只有身份仍匹配的最新意图可以继续定位;定位依次等待资源投影、两份布局 settled 且均有位置、当前筛选可见和 DOM 卡片存在,完成后按提交身份只执行一次。搜索隐藏时保留条件并提供“清除搜索并定位”。
|
||||
- `canvas.asset_generate` 保持 confirm 权限边界;本地生成意图建立时即分配稳定 `intentId/generationId/idempotencyKey`,一旦提交,重复点击、重启和未知结果保持原身份,不自动确认或换键重扣。每个网络动作在发出前冻结 `ownerUserId + authGeneration`,并在响应解析和正式提交前复核当前账号;首次 `401` 只允许一个共享 refresh,refresh 失败、账号切换或再次 `401` 时停止网络并保留账本,`403` 不触发 refresh。
|
||||
- 工作台仍是左导航、中央主视窗、右 Project Supervisor、底部 Agent Dock 四区;中央区使用主面板层级,Supervisor 消息 / Runtime 独立滚动且 composer 常驻,Dock 使用较轻 surface 与可收缩卡片。桌面网格按 `100dvh` 把 Dock 固定在第二行,`≤760px` 只保留开发兼容堆叠。中央画布动作直接消费 `@genarrative/image-canvas-react` 的 `CanvasChromeButton / CanvasToolbar / CanvasToolbarGroup / CanvasToolbarDivider`,宿主只保留字段、面板定位、主要动作和平台 token 映射。
|
||||
- AppSurface/CSS 合同锁定 `1280×800` 下的工作台无页面级溢出、Supervisor composer、主要保存动作和 Dock 可见;真实 Vite 页面测得 window/document/body client 与 scroll 均为 `1280×800`;无登录会话时按既有安全合同停在登录门禁,不增加或使用测试绕过。
|
||||
|
||||
## 2026-08-11 Tauri 无限画布恢复与一致性收口
|
||||
|
||||
- 当前产品只从现有资源进入非破坏性编辑,“新增资源”保持禁用。图片进入 refine 画布,其他现役类型进入统一派生编辑壳;所有成功路径都追加新文件、新 manifest asset 或新版本,源资源、源文件和原版本不覆盖、不删除、不重排。
|
||||
- 当前产品只从现有资源进入非破坏性编辑,“新增资源”保持禁用。图片走 `derive + editKind='image-reference'`,其他现役类型进入统一派生编辑壳;所有成功路径都追加新文件、新 manifest asset 或新版本,源资源、源文件和原版本不覆盖、不删除、不重排。
|
||||
- 资源编辑账本把远端明确失败固化为 `remote-failed`,只保存稳定失败分类和终态时间;该状态不得再 POST 或轮询,只能由用户显式置为 `archived` 后移出活动恢复队列。归档不删账本、不伪装 `committed`;`result-unknown` 和 `reconciliation-required` 不允许归档或换键重试。
|
||||
- 派生资产提交使用 `game-creator-resource-edit-asset-transaction.v1` durable journal,绑定 operation/project/source、最终路径和媒体摘要、manifest before/after 及 base/target project revision,阶段固定为 `prepared -> media-installed -> manifest-written -> revision-written -> committed`。重启后只对可证明组合前向补齐;manifest 已 after 但 revision 仍为 base 时只补目标 revision,身份、摘要、重复 asset 或 revision 任一冲突则进入 `reconciliation-required`。ledger 只能在文件、manifest、revision 与 journal 全部回读一致后进入 `committed`。`committed` 后若仍有 staging,只有在 staging 与 journal 摘要一致、正式媒体摘要一致,且 manifest 按 asset ID 或路径只能找到唯一一条并与 journal asset 精确相等时才尽力清理;删除 staging 的 I/O 失败不改变 durable committed 结果,任何身份或媒体漂移都保留 staging,并把 journal 与 ledger 转入对账。
|
||||
- 派生子版本 journal 同时冻结 project revision before/after 的完整身份与目标 after 记录。manifest 已存在目标子版本但 journal 缺失时不得据此补造提交证明;升级前 journal 缺少 revision 身份时,只有当前 revision 仍精确处于 base 才能补齐 before/after 身份,已经推进且无法证明由本事务写入时必须失败关闭并进入 `reconciliation-required`。
|
||||
- 工作台顶部恢复入口只打开独立 modal,不在当前面板下追加列表。面板展示后端权威队列的全部 operation:可继续项可独立恢复,`remote-failed` 只能移出活动队列,`reconciliation-required` 只读展示对账。读取失败保留错误并允许重试,任一操作后重读权威队列;项目切换后的迟到结果必须丢弃。通用 `ThemedModal` 使用焦点陷阱:打开时移入 dialog,Tab / Shift+Tab 不得逸出;Escape 或背景关闭后归还原触发元素,无可聚焦子项时聚焦 dialog 本身。窄屏操作按钮改为纵向全宽布局。
|
||||
- 草稿应用统一经过单调 revision 门禁:generation progress、保存队列、生成/提交回包与延迟 `loadDraft` 只能推进当前 scope 的最高可信 revision;同 revision 只允许完整相等的幂等回包,低 revision 和旧 scope 一律丢弃。Tauri 指针与键盘 Shift 选择统一调用共享 `resolveLayerPointerSelection`;移动、缩放和背景平移只在首次真实变化时 capture 一次 history,零位移不增加 undo、documentVersion 或草稿保存。
|
||||
- `canvas.failed` 必须带 `generation / draft-save / asset-commit / recovery / cancellation` 五类稳定 operation。只有 `generation` 失败允许“返回修改/重新确认”;草稿保存或 CAS 冲突只提供重试保存/重新加载,资产提交和恢复只提供安全恢复/对账,取消故障只允许保留草稿继续编辑。初始恢复读取失败也归 `recovery`,不得显示生成重试动作。
|
||||
|
||||
## 2026-08-11 通用 Goal Contract 与动态 Acceptance Graph
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
资源管理在空项目中展示现有按类型式分区展览。任一栏目出现资源后,“按依赖”和“按类型”共用同一套栏目分页画布:固定五个栏目各自拥有一个铺满资源管理区域的独立画布,空栏目也保留可打开的空画布;栏目大纲悬浮在画布左侧中间,只显示图标和栏目名称,不占用画布布局宽度;顶部显示当前栏目标题,底部显示下一页标题。普通滚轮平移当前视图,按住 Ctrl/Meta 的滚轮以指针为锚点缩放当前画布。两种模式唯一差异是“按依赖”在当前栏目内绘制引导线。资源详情是叠加在资源画板之上的非模态卡片;打开、切换和关闭详情都不得卸载背景工具栏、资源卡、依赖连线或重置 viewport、搜索和排序模式。
|
||||
|
||||
图片资源从“编辑资源”进入持续精修草稿后,点击任意图片直接在图片下方显示快速编辑卡。每次快速编辑创建新的候选图,不覆盖来源图层;生成任务立即在画布中创建占位并进入任务侧栏。用户显式选择候选图“设为最终图”后,保持原资产 ID 不变,以事务方式切换 manifest 指向的正式 PNG;精修草稿和其它候选图继续保留。
|
||||
图片资源从“编辑资源”进入资源派生编辑(`derive + editKind='image-reference'`),结果固定创建新的本地 asset,源资源与源文件保持不变;具体来源核验、请求 DTO 与幂等合同见《【技术方案】AGC资源派生与非破坏性编辑合同-2026-09-09》。
|
||||
|
||||
资源总览中的美术图片卡按真实纵横比计算展示矩形,并使用统一边界限制极端长图或宽图。非图片资源继续使用固定卡片尺寸。
|
||||
|
||||
@@ -45,71 +45,6 @@
|
||||
- 固定栏目页始终为 `设计文档 -> 美术资源 -> 音乐音效 -> 游戏代码 -> 项目版本`。若某栏目没有对应投影,Dock 仍显示该栏目并允许打开空画布;空画布只表达“当前没有已登记资源”,不得由 UI 补假数据。
|
||||
- Direct Codex 的游戏生成链路以 `game/index.html`、`game/style.css`、`game/game.js` 作为代码资产登记;是否额外产出设计文档由当前任务和 Agent 决定,资源页只消费已登记结果。
|
||||
|
||||
### 持续精修草稿
|
||||
|
||||
- 一个图片 `sourceAssetId` 唯一发现一个活动精修 `draftId`;重新进入时恢复原草稿,不依赖进程内状态或创建平行画布。
|
||||
- 草稿 JSON 位于 `.agent/workbench/asset-canvas/drafts/<draftId>.json`,只保存图层、viewport、选择、generation 投影和提交记录,不内嵌图片 Base64。
|
||||
- 入口图片图层使用 `project-asset` 引用稳定 `sourceAssetId`。AI 生成、导入和中间图片使用不可变 `draft-media`,二进制位于 `.agent/workbench/asset-canvas/media/<draftId>/<mediaId>.png`,引用同时冻结 SHA256、字节数和像素宽高。
|
||||
- 候选媒体只属于精修草稿,不写入 manifest、不出现在资源总览,也不成为正式依赖。删除图层只删除草稿引用;物理媒体在确认不被草稿、generation ledger、staging、transaction journal 或恢复记录引用后延迟清理。
|
||||
- generation 占位与稳定 `generationId` 绑定。确认后 UI 立即显示内存占位;宿主创建私有 ledger 并接受请求后,才把落点、目标尺寸、阶段和结果图层身份投影到公开草稿。接受前失败或异常不得留下公开 `generation-running` 记录;成功候选必须使用同一落点,页面重启后从草稿和私有 ledger 恢复。
|
||||
|
||||
### 最终图事务
|
||||
|
||||
- “最终图”权威定义为 manifest 中 `sourceAssetId` 当前指向的正式文件,不使用前端布尔值作为业务真相。
|
||||
- 前端只提交 `projectId + draftId + sourceAssetId + sourceLayerId + expectedProjectRevision + expectedDraftRevision + commitId + idempotencyKey`。后端重新读取权威草稿,验证目标图层属于当前 refine 草稿并引用完整 PNG `draft-media`。
|
||||
- 每次提交将候选图安装到新的 `assets/canvas/<name>--<commitId>.png`,保持原 `assetId` 不变,只更新该 manifest 条目的 `mediaType/localPath`,随后推进 project revision。已登记 refine 入口必须保留原 `source.resourceId`(例如 `editor-resource-*`);只有 create 或缺少登记来源的本地资产才补为 `local-asset:<assetId>`。事务回读校验按同一来源解析规则验证,不能无条件要求 `local-asset:*`。
|
||||
- 文件、manifest、revision、ledger、draft 和事件继续使用现有 transaction journal、before/after snapshot、回读验证、幂等返回、回滚与 reconciliation 合同;任何 revision 冲突都不得静默覆盖其它窗口的更新。
|
||||
- 提交成功后草稿回到可编辑状态,并记录 `sourceLayerId + mediaSha256 + assetId + commitId + committedProjectRevision` 的最后提交投影。不得清空候选图或永久终结草稿;再次选择其它候选图可产生下一次最终图事务。
|
||||
- 删除已提交候选图层只影响草稿;正式项目资源继续指向已验证的 PNG。当前最终图显示必须由 manifest/revision 与最后提交摘要共同投影。
|
||||
|
||||
### 精修来源规范化
|
||||
|
||||
- refine 主来源不得仅凭 manifest 中已有的 `source.resourceId` 判断其可直接用于 `sourceReferenceId`。历史 Game Agent 资源可能以 `game-background`、`art-spritesheet` 等私有 kind 登记,虽然 ID 形态是正式 Editor Resource,仍不满足 External v1 快速编辑的 canonical 类型合同。
|
||||
- 客户端必须从本地正式图片重新取得受控字节,按现役映射登记 canonical 主来源,例如 `game-background -> scene`、`art-spritesheet -> icon-spritesheet`;`/api/external/v1/editor/images/edits` 仍只提交登记后返回的业务资源 ID,不扩大服务端白名单,也不在 edit DTO 中补发 `assetKind`。
|
||||
- canonical 来源缓存属于本地私有恢复数据,不写入 manifest。缓存身份至少绑定平台服务身份、平台 owner、Editor Project ID、源图片 SHA256 与 canonical kind;任一身份或图片内容变化都必须重新登记。
|
||||
- 缓存命中时复用已登记资源,避免每次快速编辑重复上传。平台明确返回来源不存在或类型不支持且请求尚未入队时,可以失效缓存并重建一次;不确定受理、已受理和对账状态不得换幂等键重提。
|
||||
|
||||
### 最终图事务恢复对账问题与修复
|
||||
|
||||
现象:用户连续两次“设为最终图”后重新打开画布,恢复阶段返回 `reconciliation-required`,前端提示“安全恢复原任务需要对账 / 素材画布存在无法自动证明的未完成事务”。
|
||||
|
||||
根因:
|
||||
|
||||
- 提交成功后 transaction 目录不会归档,`recover_asset_canvas_transactions_at` 会重放全部历史事务。
|
||||
- `recover_asset_canvas_transaction_locked` 的 `ledger=Committed` 分支要求当前 manifest 中该 asset 仍指向本事务的 `finalImageRelativePath`;后续提交已把同一 asset 指向新的 `assets/canvas/<name>--<commitId>.png`,`asset_present=false` 后直接 `mark_asset_canvas_reconciliation_locked`,没有先执行 `find_superseding_asset_canvas_commit`(该检查目前只在 unresolved 分支生效)。
|
||||
- 前端 `recover()` 只要存在任一 `reconciliation-required` outcome 就把整次恢复判失败,因此一个已被后续提交取代的旧事务会阻塞整个画布打开。
|
||||
- 现有测试此前只覆盖“第一个事务被强制标记 unresolved 后由后续已提交事务取代”,缺少“两个事务都成功提交后恢复”的用例,因此未暴露。
|
||||
- 单步 supersede 修复只接受旧事务的 after 快照与当前事务的 before 快照直接衔接。连续提交三次及以上时,最早事务必须跨过一个或多个已提交中间事务才能到达当前 manifest/revision;把“不是直接前驱”当作无法证明仍会误报对账。
|
||||
|
||||
修复合同:
|
||||
|
||||
1. `ledger=Committed` 分支在进入 reconciliation 前先执行同一套 supersede 证明;命中则把旧 journal/ledger 标记为 `Superseded`,返回 `Superseded`,不得阻塞画布打开。
|
||||
2. supersede 证明继续要求每个后续事务已完整提交、正式文件哈希匹配,且相邻事务的 before 快照精确衔接前一事务的 after 快照;允许沿一条逐笔验证的提交链到达当前 manifest/revision,不得跳过中间事务或仅凭时间戳、revision 数值、路径变化猜测。
|
||||
3. 重复恢复幂等:已 `Superseded` 的事务直接返回既有状态,不再改成对账。
|
||||
4. 回归测试覆盖“两次连续成功提交”和“三次连续提交且最早事务已进入对账态”后恢复:所有旧事务必须收敛为 `Superseded`,当前事务为 `AlreadyCommitted`,整轮及重复恢复均不得出现 `ReconciliationRequired`。
|
||||
5. 恢复失败可观测性仍保留后续增强方向:失败结果应携带 outcomes 明细(commitId、status、stage、证据摘要),前端展示具体事务并支持重试。
|
||||
|
||||
验收:
|
||||
|
||||
- 连续两次“设为最终图”后关闭再打开画布不再出现对账提示,且最终 manifest 指向最新提交。
|
||||
- 现有 fault-injected 恢复用例(`RevisionInstalled`、`LedgerCommitted` 等)全部保持通过,未出现新的 `ReconciliationRequired` 回归。
|
||||
- 重复恢复幂等,已 `Superseded` 事务不再重放为对账。
|
||||
|
||||
### 本地图片批量导入
|
||||
|
||||
- Tauri 正式链路使用原生多选文件对话框,不以隐藏 DOM `input[type=file]` 作为唯一入口。取消选择返回受控 `cancelled`,不修改草稿。
|
||||
- 一批导入在 Rust 中完成安全读取、PNG/JPEG/WebP 签名与尺寸校验、总量校验、媒体安装、图层追加和一次 draft revision 推进。全部身份、路径、层序和新 revision 必须在首个文件安装前完成验证;任一文件失败时不得留下公开部分成功,回滚删除失败必须返回明确对账错误。草稿写入结果无法回读确认时保留本批媒体并进入显式对账错误,禁止冒险删除可能已被新草稿引用的文件。
|
||||
- 导入图层保存真实原始宽高,显示矩形保持比例并受当前 viewport 可见边界限制;首张落在当前视口中心附近,多张确定性错位并避让现有图层。成功后返回权威 draft 与 `importedLayerIds`,前端 hydrate 后选中并定位。
|
||||
- UI 必须区分正在选择、正在导入、已导入数量和安全失败原因。不得把文件读取异常或 host failure 留成未处理 Promise,也不得依赖稍后的前端 autosave 才把已写媒体绑定到图层。
|
||||
|
||||
### 失败生成任务归档与任务侧栏
|
||||
|
||||
- 用户界面的“删除失败任务”语义是归档,不物理销毁私有 generation ledger。只有平台明确失败的 `failed` 任务可归档;`reconciliation-required`、已受理、运行中和结果未知任务不得移出恢复队列。
|
||||
- 已受理 operation 在重试时若请求快照发生变化,客户端可先对原 operation 执行一次只读状态查询;仅当平台明确返回 `failed` 时才自动收口旧账本并允许新提交,排队、运行中、完成或未知状态继续保持原幂等身份并阻断替代请求。
|
||||
- 归档命令校验 project、draft、generation 与 expected draft revision,先把私有 ledger 写入可重放的 `archiving/archivedAt`,再从 `draft.generations` 移除公开投影并推进一次 revision;草稿删除成功并回读后才发布 `archived`。`archiving` 以及历史上已写 `archived` 但仍残留公开记录的状态都必须在恢复阶段幂等收敛,且不依赖图片生成服务凭证。
|
||||
- 失败占位和右上角任务项复用同一个归档动作,成功后两处同时消失,其它任务、图层和候选不受影响。
|
||||
- 任务侧栏折叠只属于当前会话 UI 状态,不写入 draft 或 manifest。视觉和交互复用现役美术画布:右上角独立“任务列表”图标按钮、20rem 白色模糊卡、总数徽标、`排队/生成中` 与 `已完成` 双 Tab、状态圆形图标、阶段进度和时间信息;折叠后只保留图标按钮,不显示摘要卡。用户显式新建 generation 时自动展开并切回活动 Tab,普通进度更新不得推翻用户已有折叠选择。Game Agent 的失败归档作为任务行扩展保留。
|
||||
|
||||
## 操作边界
|
||||
|
||||
- 栏目顺序固定为 `设计文档 -> 美术资源 -> 音乐音效 -> 游戏代码 -> 项目版本`。完全空项目显示全部栏目的分区展览;任一栏目出现资源后,分页大纲以左侧垂直居中的悬浮 Dock 展示全部栏目。常态缩小、降低不透明度并移除容器与选中项背景,只露出栏目文字;悬停或键盘聚焦时平滑恢复完整尺寸,显示栏目图标、Dock 背景和选中态视觉强调。默认停留在该顺序中的第一个非空栏目,空栏目仍可打开空画布。
|
||||
@@ -125,12 +60,31 @@
|
||||
- 完全空项目继续使用可纵向滚动的五分区展览,不挂载分页画布或依赖画布的 `overflow: hidden` 交互壳;低高度窗口在两种排序模式下都能滚动到“项目版本”。
|
||||
- 在“按依赖 / 按类型”之间切换或离开资源管理进入运行视图后返回时,恢复对应“排序模式 + 栏目”的现有 viewport,不自动触发“复位资源画布”,也不得用另一排序模式的 viewport 覆盖用户已经完成的平移和缩放。首次 fit 与用户显式复位仍使用同一套真实资源包围盒算法。
|
||||
- 资源详情卡定位相对 `.game-workbench-stage`,桌面端在中间主视窗居中并受主视窗宽高边界约束;禁止使用相对整个窗口的右上角 `position: fixed` 定位。
|
||||
- 图片选中后的浮动工具栏整体退役。普通点击图片直接打开唯一的图片下方快速编辑卡;拖动不误触发,点击另一张图片切换卡片,点击画布空白关闭卡片。
|
||||
- 快速编辑卡底部统一承载“删除”“设为最终图”“修改”。“删除”只删除当前绑定图层并进入画布 history;“设为最终图”只对完整候选媒体可用;“修改”创建新 generation,不覆盖来源图层。
|
||||
- 页面级返回、导入、撤销和重做不属于图片浮动工具条,本次不以复制另一套图片操作条替代已退役工具条。
|
||||
- 资源自由画板顶部不展示“生成视频”“生成音效”“生成背景音乐”和“新增 UI 设计”等手动新建入口;顶部只保留播放、未完成编辑恢复、排序与复位等画布级动作。无源资源生成由 Agent 语义工具进入既有生成合同,已有资源的编辑、图片“生成动画”和精修图片“修改”入口不受影响。
|
||||
- refine `/api/external/v1/editor/images/edits` 严格遵循 `EditorImageEditRequest`,不得携带 `assetKind`、`sourceImageSrc` 或 `sourceResourceId`。Game Agent 本地 kind 在登记编辑器来源前映射到现役 canonical kind;服务端安全业务错误必须结构化返回,不能把全部 400 折叠为同一个“平台明确拒绝”。
|
||||
- 生成提交后立即创建内存画布占位和任务侧栏记录,宿主接受后再持久化公开 generation 投影;排队、运行、远端完成、下载、候选就绪、失败和对账是任务级状态,不得用全屏 `canvas.generating/canvas.failed` 遮挡或终结整张画布。候选持久化必须先写入并回读候选媒体、图层和公开记录,再发布私有 `candidate-ready` 终态;生成返回时前端只合并候选与 generation 权威事实,并在保存队列屏障后把生成期间的最新本地画布状态写回,禁止全量 hydrate 覆盖并发编辑。重新进入时,关键提交事务由 `host.recover()` 在 hydrate 前收敛;草稿 hydrate 完成后立即进入 `canvas.editing`,耗时的 `recoverImages()` 转为后台任务投影恢复,只更新任务、候选和 notice,不能继续锁住画布或因后台恢复失败切换为全屏失败。真正阻断草稿/提交/恢复的错误使用挂到 `document.body` 的全窗口 portal,必须覆盖整个 Tauri WebView,而不是只覆盖左侧画布区域。
|
||||
- 图片浮动工具条与图片精修草稿画布均已退役。图片的“编辑资源”入口走资源派生编辑,在资源编辑壳内提交,不再打开独立画布或图片下方快速编辑卡。
|
||||
- 资源自由画板顶部不展示“生成视频”“生成音效”“生成背景音乐”和“新增 UI 设计”等手动新建入口;顶部只保留播放、未完成编辑恢复、排序与复位等画布级动作。无源资源生成由 Agent 语义工具进入既有生成合同,已有资源的编辑和图片“生成动画”入口不受影响。
|
||||
- 图片派生编辑使用 `/api/editor/images/edits`,严格遵循 `EditorImageEditRequest`,不得携带 `assetKind`、`sourceImageSrc` 或 `sourceResourceId`。Game Agent 本地 kind 在登记编辑器来源前映射到现役 canonical kind;服务端安全业务错误必须结构化返回,不能把全部 400 折叠为同一个“平台明确拒绝”。
|
||||
|
||||
## 编辑结果投影与焦点竞态
|
||||
|
||||
资源派生编辑或资产命令成功返回后,当前项目仍匹配时必须执行:
|
||||
|
||||
```text
|
||||
用返回的完整 manifest 更新项目上下文
|
||||
→ 重建资源投影
|
||||
→ 以新投影更新依赖图输入
|
||||
→ 分别协调 dependency/type 布局中的新资源
|
||||
→ 当前模式布局 ready 后再决定是否选择和定位新资源
|
||||
```
|
||||
|
||||
- 不刷新页面,不关闭/重开项目,不额外重新拉一份旧 manifest。
|
||||
- 新资源卡身份固定为 `asset:<asset.id>`。布局使用现役确定性默认位置与 CAS/FIFO 合同;编辑事务本身不直接写资源布局 sidecar。
|
||||
- manifest 投影可以在布局协调完成前显示加载占位,但不得用临时坐标持久化错误布局。
|
||||
- 投影与焦点的迟到结果守卫按当前 ref/store 中的完整身份复核,不依赖 React 闭包中的旧布尔值:
|
||||
- 用户已切项目:结果只能更新原项目 key 下的后台缓存,不得写当前项目 manifest、切换中央状态或抢焦点。
|
||||
- 用户已切到运行视图、资源总览或另一编辑流程:可以按精确项目身份更新缓存/投影,但不得把中央主视窗切回本次流程。
|
||||
- 用户在等待期间选择了其它资源:保持用户当前选择;新资源仍进入投影和布局,但不得自动选中。
|
||||
- 搜索或筛选 epoch 已变化且新资源被隐藏:保持条件和当前选择,显示“新资源已保存,但被当前搜索条件隐藏”,并提供显式“清除搜索并定位”动作;不得自动清空条件。
|
||||
- 只有项目身份、编辑流程身份、selection epoch 和 query epoch 全部仍匹配,且新资源在当前条件下可见时,才在布局 ready 后自动选择、滚动并聚焦新资源。
|
||||
|
||||
## 美术预览几何
|
||||
|
||||
@@ -147,37 +101,20 @@
|
||||
3. 在当前栏目内拖拽空白可以无限平移画布,拖拽资源卡可以移动卡片并同步依赖线,Ctrl/Meta + 滚轮以指针位置为锚点缩放,复位按钮可以适配当前栏目内容;普通平移不会因资源 extent、图片测量或窗口 resize 被拉回,连续缩放仍停在共享画布的最小/最大比例范围内。
|
||||
4. 按依赖模式显示当前栏目内同类型两端资源的合法精确引用,并具有与当前可见连线一致的无障碍关系说明;按类型模式不显示引导线;搜索隐藏任一端点后连线和说明同时消失。
|
||||
5. 点击任意资源后“按依赖 / 按类型 / 复位”等画布级动作仍保持挂载和原状态;顶部不出现手动新建入口。详情为非模态独立卡片,背景画板 viewport、搜索、排序、卡片和连线不卸载、不重置。
|
||||
6. 图片资源点击“编辑资源”后恢复唯一活动 refine 草稿,入口图进入默认参考;点击任意图片直接在其下方显示快速编辑卡,图片上方不再出现浮动工具栏。
|
||||
7. 快速编辑卡底部显示适用的删除、设为最终图和修改动作。拖动图片不误触发卡片;删除只影响草稿图层;候选设为最终图后保持原 asset ID、更新正式 PNG,草稿及其它候选仍可继续编辑。
|
||||
8. 点击修改后立即创建避让现有内容的生成占位和任务侧栏记录;成功图在同一落点出现,失败只影响该占位和任务,重启后恢复原 generation/operation,不重新扣费提交。
|
||||
9. refine 请求通过严格 DTO 合同且不携带非法 `assetKind`;参数、来源类型、余额、鉴权和对账错误显示不同的安全错误码。
|
||||
10. 1:1、16:9、9:16、2:3、3:2 和极端横竖图片按真实比例显示并受边界限制;卡片不重叠,当前栏目 extent、复位视口和依赖连线端点正确。非图片资源尺寸不变。
|
||||
11. GIF、AVIF、BMP、SVG 等不满足栅格快速编辑能力的资源继续使用能力模型中的降级编辑路径,不伪装成可提交 PNG 候选。
|
||||
12. 历史 `game-background` 等私有类型来源不得直接作为 refine 主来源;首次快速编辑完成 canonical 登记,后续同源请求复用缓存,并实际到达 `candidate-ready`。
|
||||
13. 点击导入可以原生多选图片;成功批次只推进一次草稿 revision、立即出现在当前视口并被选中,取消和失败不改变草稿且不遗留部分导入。
|
||||
14. 明确失败任务可从占位或任务侧栏归档,重启后不恢复;`reconciliation-required` 不显示删除能力。任务列表具有与现役美术画布一致的独立按钮、双 Tab、状态图标与时间/进度信息;折叠后只保留按钮且不丢任务数据,新建任务自动展开。
|
||||
15. 画布带运行中 generation 重新进入时,在远端恢复完成前已经可选图、平移和继续编辑;后台恢复继续推进原 operation,不重复 POST、不重新扣费。阻断性提交/恢复失败的遮罩覆盖整个 Tauri 窗口。
|
||||
16. 已登记 refine 资产的候选设为最终图后保持原 `assetId`、原 `source.resourceId` 与新正式 PNG 路径;`revision-installed` 中断后重新加载可前向收敛为 committed。
|
||||
|
||||
## PR 176 事务与宿主合同补充
|
||||
|
||||
- 生成失败时,公开 generation 记录必须在同一草稿 revision 链路中落为 `failed` 或 `reconciliation-required`,不得留下持久化的 `generation-running` 幽灵任务。
|
||||
- 客户端处理生成失败时必须调用宿主的权威失败结算接口并携带 `expectedDraftRevision`;结算返回 revision 冲突或失败时保持失败/对账阻断态,不得把画布静默恢复为 clean editing。无私有账本的早期失败只允许由后端原子移除公开占位并返回 `not-started`,已进入候选或正式提交终态的结果返回 `already-terminal`,等待重新打开对账。
|
||||
- 权威失败结算与生成流程内部错误统一先将私有 ledger 写为 `failure-settlement-pending` 或 `reconciliation-settlement-pending`,再幂等更新公开草稿与 staging revision,最后发布 `failed` 或 `reconciliation-required`,任何路径都不得先发布终态 ledger。任一持久化边界中断后,启动恢复必须在解析生成服务凭据前完成 pending 结算;公开草稿或 staging revision 已写入时不得重复推进。
|
||||
- 候选成功回包不得整体 hydrate 覆盖生成期间的本地未 autosave 状态;必须以当前 runtime 的图层、viewport、背景、selection 和 history 为基线,只合并权威候选图层、预览地址与 generation 记录,并用一次带 CAS 的草稿保存确认候选。
|
||||
- 候选生成必须先幂等落盘候选媒体、候选图层和公开记录,回读成功后才能推进私有 ledger 的 `candidate-ready` 终态;两次写入之间中断时,恢复必须可重放。
|
||||
- 失败归档使用 `archive-pending` 中间态:先记录归档意图,再删除草稿公开记录,最后发布 `archived`;任一边界中断都必须在重启后收敛且保持幂等。
|
||||
- `ImageCanvasAssetPort.importLocalImages` 与 `ImageCanvasGenerationPort.archiveFailedGeneration` 是冻结合同中的必选方法;不支持的宿主返回结构化 `unsupported-capability`。
|
||||
- 快速编辑卡按自身测量宽高选择上下方位置,并在 viewport 四边保留安全留白;窄屏、滚动、缩放和 resize 后都重新计算。
|
||||
6. 图片派生请求通过严格 DTO 合同且不携带非法 `assetKind`;参数、来源类型、余额、鉴权和对账错误显示不同的安全错误码。
|
||||
7. 1:1、16:9、9:16、2:3、3:2 和极端横竖图片按真实比例显示并受边界限制;卡片不重叠,当前栏目 extent、复位视口和依赖连线端点正确。非图片资源尺寸不变。
|
||||
8. GIF、AVIF、BMP、SVG 等不满足栅格图片派生能力的资源继续使用能力模型中的降级编辑路径,不伪装成可提交的 PNG 候选。
|
||||
9. 历史 `game-background` 等私有类型来源不得直接作为远端来源;首次编辑完成 canonical 登记,后续同源请求复用缓存,并实际到达 `candidate-ready`。
|
||||
10. 资源派生编辑或资产命令返回后,manifest、资源卡、依赖图输入和当前模式布局不刷新即完成投影;搜索隐藏新资源时保持搜索条件并提供显式“清除搜索并定位”。
|
||||
|
||||
## 验证命令
|
||||
|
||||
```powershell
|
||||
npm run ai-game-creator-shell:typecheck
|
||||
node node_modules/vitest/vitest.mjs run apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts apps/ai-game-creator-shell/tests/resourceCanvasLayoutContract.test.ts
|
||||
node node_modules/vitest/vitest.mjs run apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx apps/ai-game-creator-shell/tests/resourceEditModel.test.ts apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts apps/ai-game-creator-shell/tests/resourceCanvasLayoutContract.test.ts
|
||||
node node_modules/vitest/vitest.mjs run apps/ai-game-creator-shell/tests/appSurface.test.ts
|
||||
cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check
|
||||
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml project::asset_canvas -- --test-threads=1
|
||||
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml project::resource_editor -- --test-threads=1
|
||||
npm run check:encoding
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user