修复Tauri无限画布恢复与交互缺陷
完善资源编辑终态分类、单飞锁与资产和版本可恢复事务。 统一服务身份迁移与旧操作显式确认,保留非破坏性编辑。 修复草稿revision乱序、共享选择与零位移历史污染。 新增独立恢复面板、分类型失败交互和并发焦点隔离。 补充定向回归、权威文档与临时闭环计划。
This commit is contained in:
@@ -114,8 +114,10 @@ const rustSharedContractSource = fs.readFileSync(
|
||||
'utf8',
|
||||
);
|
||||
const allowedUncalledTauriCommands = [
|
||||
'archive_failed_local_project_resource_edit',
|
||||
'chat_with_game_creator_agent',
|
||||
'commit_local_project_asset',
|
||||
'confirm_local_project_asset_canvas_generation_service_identity',
|
||||
'create_local_project_asset_canvas_draft',
|
||||
'discard_local_project_asset_canvas_draft',
|
||||
'generate_local_project_asset_canvas_image',
|
||||
|
||||
@@ -29,7 +29,12 @@ pub(in crate::agent) use canvas_generation::{
|
||||
validate_platform_art_png_bytes_with_limits,
|
||||
};
|
||||
pub(in crate::agent) use draft_validation::validate_closed_game_script_blocks;
|
||||
pub(crate) use external_generation_state::platform_art_generation_external_service_fingerprint;
|
||||
pub(crate) use external_generation_state::{
|
||||
classify_platform_art_generation_service_identity,
|
||||
platform_art_generation_external_service_fingerprint,
|
||||
platform_art_generation_external_service_origin, PlatformArtGenerationServiceIdentityMatch,
|
||||
PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME,
|
||||
};
|
||||
pub(in crate::agent) use external_generation_state::{
|
||||
game_creator_agent_runtime_external_generation_exists,
|
||||
platform_art_generation_runtime_context_from_pending,
|
||||
@@ -40,6 +45,7 @@ pub(in crate::agent) use external_generation_state::{
|
||||
#[cfg(test)]
|
||||
pub(crate) use external_generation_state::{
|
||||
platform_art_generation_external_configuration_fingerprint,
|
||||
platform_art_generation_legacy_external_configuration_fingerprint,
|
||||
setup_platform_art_generation_runtime_accepted_for_recovery_test,
|
||||
write_platform_art_generation_runtime_accepted_for_test,
|
||||
write_platform_art_generation_runtime_prepared_for_test,
|
||||
|
||||
+120
-7
@@ -2,6 +2,7 @@ use super::*;
|
||||
|
||||
pub(in crate::agent) const PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION: &str =
|
||||
"agent-runtime-canvas-generation-request.v2";
|
||||
pub(crate) const PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME: &str = "service-origin-v1";
|
||||
const PLATFORM_ART_GENERATION_RUNTIME_MAX_BYTES: usize = 256 * 1024;
|
||||
const PLATFORM_ART_GENERATION_STATUS_PREPARED: &str = "prepared";
|
||||
const PLATFORM_ART_GENERATION_STATUS_ACCEPTED: &str = "accepted";
|
||||
@@ -67,6 +68,15 @@ pub(in crate::agent) enum PlatformArtGenerationRuntimeRecovery {
|
||||
ResumeLegacyCompleted,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum PlatformArtGenerationServiceIdentityMatch {
|
||||
Unbound,
|
||||
Current,
|
||||
LegacyVerified,
|
||||
LegacyUnverified,
|
||||
Changed,
|
||||
}
|
||||
|
||||
fn platform_art_generation_runtime_relative_path(agent_id: &str, run_id: &str) -> String {
|
||||
format!(
|
||||
".agent/runtime/canvas-generation-requests/{}/{}.json",
|
||||
@@ -127,7 +137,26 @@ pub(crate) fn platform_art_generation_external_service_fingerprint(api_base_url:
|
||||
format!("{:x}", Sha256::digest(normalized_base_url.as_bytes()))
|
||||
}
|
||||
|
||||
fn platform_art_generation_legacy_external_configuration_fingerprint(
|
||||
pub(crate) fn platform_art_generation_external_service_origin(
|
||||
api_base_url: &str,
|
||||
) -> Result<String, String> {
|
||||
let parsed = reqwest::Url::parse(api_base_url.trim())
|
||||
.map_err(|_| "External Editor 服务地址无效".to_string())?;
|
||||
if !matches!(parsed.scheme(), "http" | "https")
|
||||
|| parsed.host_str().is_none()
|
||||
|| !parsed.username().is_empty()
|
||||
|| parsed.password().is_some()
|
||||
{
|
||||
return Err("External Editor 服务地址无效".to_string());
|
||||
}
|
||||
let origin = parsed.origin().ascii_serialization();
|
||||
if origin == "null" {
|
||||
return Err("External Editor 服务 origin 无效".to_string());
|
||||
}
|
||||
Ok(origin)
|
||||
}
|
||||
|
||||
pub(crate) fn platform_art_generation_legacy_external_configuration_fingerprint(
|
||||
api_base_url: &str,
|
||||
api_key: &str,
|
||||
) -> String {
|
||||
@@ -139,6 +168,42 @@ fn platform_art_generation_legacy_external_configuration_fingerprint(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn classify_platform_art_generation_service_identity(
|
||||
scheme: Option<&str>,
|
||||
fingerprint: Option<&str>,
|
||||
api_base_url: &str,
|
||||
api_key: &str,
|
||||
) -> PlatformArtGenerationServiceIdentityMatch {
|
||||
let Some(fingerprint) = fingerprint else {
|
||||
return if scheme.is_none() {
|
||||
PlatformArtGenerationServiceIdentityMatch::Unbound
|
||||
} else {
|
||||
PlatformArtGenerationServiceIdentityMatch::Changed
|
||||
};
|
||||
};
|
||||
let current = platform_art_generation_external_service_fingerprint(api_base_url);
|
||||
match scheme {
|
||||
Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME) => {
|
||||
if fingerprint == current {
|
||||
PlatformArtGenerationServiceIdentityMatch::Current
|
||||
} else {
|
||||
PlatformArtGenerationServiceIdentityMatch::Changed
|
||||
}
|
||||
}
|
||||
Some(_) => PlatformArtGenerationServiceIdentityMatch::Changed,
|
||||
None if fingerprint == current => PlatformArtGenerationServiceIdentityMatch::Current,
|
||||
None if fingerprint
|
||||
== platform_art_generation_legacy_external_configuration_fingerprint(
|
||||
api_base_url,
|
||||
api_key,
|
||||
) =>
|
||||
{
|
||||
PlatformArtGenerationServiceIdentityMatch::LegacyVerified
|
||||
}
|
||||
None => PlatformArtGenerationServiceIdentityMatch::LegacyUnverified,
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the historical helper for persisted-agent call sites while changing its
|
||||
// identity semantics: credentials authorize an operation but do not own it.
|
||||
#[cfg(test)]
|
||||
@@ -154,12 +219,16 @@ pub(super) fn validate_platform_art_generation_external_configuration(
|
||||
api_base_url: &str,
|
||||
api_key: &str,
|
||||
) -> Result<(), String> {
|
||||
let current = platform_art_generation_external_service_fingerprint(api_base_url);
|
||||
let legacy =
|
||||
platform_art_generation_legacy_external_configuration_fingerprint(api_base_url, api_key);
|
||||
if state.external_configuration_fingerprint != current
|
||||
&& state.external_configuration_fingerprint != legacy
|
||||
{
|
||||
if !matches!(
|
||||
classify_platform_art_generation_service_identity(
|
||||
None,
|
||||
Some(&state.external_configuration_fingerprint),
|
||||
api_base_url,
|
||||
api_key,
|
||||
),
|
||||
PlatformArtGenerationServiceIdentityMatch::Current
|
||||
| PlatformArtGenerationServiceIdentityMatch::LegacyVerified
|
||||
) {
|
||||
return Err("External Editor 生成账本与当前服务地址身份不一致".to_string());
|
||||
}
|
||||
Ok(())
|
||||
@@ -837,6 +906,50 @@ mod external_generation_state_tests {
|
||||
assert_ne!(original, different_service);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versioned_service_identity_distinguishes_verified_and_unverified_legacy_ledgers() {
|
||||
let base_url = "https://editor.example.test/";
|
||||
let current = platform_art_generation_external_service_fingerprint(base_url);
|
||||
let legacy = platform_art_generation_legacy_external_configuration_fingerprint(
|
||||
base_url,
|
||||
"original-key",
|
||||
);
|
||||
assert_eq!(
|
||||
classify_platform_art_generation_service_identity(
|
||||
Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME),
|
||||
Some(¤t),
|
||||
base_url,
|
||||
"rotated-key",
|
||||
),
|
||||
PlatformArtGenerationServiceIdentityMatch::Current
|
||||
);
|
||||
assert_eq!(
|
||||
classify_platform_art_generation_service_identity(
|
||||
None,
|
||||
Some(&legacy),
|
||||
base_url,
|
||||
"original-key",
|
||||
),
|
||||
PlatformArtGenerationServiceIdentityMatch::LegacyVerified
|
||||
);
|
||||
assert_eq!(
|
||||
classify_platform_art_generation_service_identity(
|
||||
None,
|
||||
Some(&legacy),
|
||||
base_url,
|
||||
"rotated-key",
|
||||
),
|
||||
PlatformArtGenerationServiceIdentityMatch::LegacyUnverified
|
||||
);
|
||||
assert_eq!(
|
||||
platform_art_generation_external_service_origin(
|
||||
"https://editor.example.test/private/path"
|
||||
)
|
||||
.expect("derive public origin"),
|
||||
"https://editor.example.test"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_external_configuration_fingerprint_migrates_before_key_rotation() {
|
||||
let temporary = crate::tests::canonical_test_tempdir("legacy-fingerprint-");
|
||||
|
||||
@@ -341,6 +341,14 @@ pub(crate) async fn recover_local_project_asset_canvas_generations(
|
||||
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,
|
||||
@@ -1263,6 +1271,15 @@ pub(crate) async fn resume_local_project_resource_edit(
|
||||
resume_local_project_resource_edit_at(input).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn archive_failed_local_project_resource_edit(
|
||||
input: ArchiveFailedLocalProjectResourceEditInput,
|
||||
) -> Result<ArchiveFailedLocalProjectResourceEditResult, String> {
|
||||
let root = Path::new(input.project_path.trim());
|
||||
enforce_project_permission_policy(root, "asset.register")?;
|
||||
archive_failed_local_project_resource_edit_at(input).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn normalize_local_project_raster_resource(
|
||||
input: NormalizeLocalProjectRasterResourceInput,
|
||||
|
||||
@@ -2212,6 +2212,7 @@ fn main() {
|
||||
derive_local_project_resource,
|
||||
list_pending_local_project_resource_edits,
|
||||
resume_local_project_resource_edit,
|
||||
archive_failed_local_project_resource_edit,
|
||||
normalize_local_project_raster_resource,
|
||||
import_canvas_asset,
|
||||
import_canvas_export,
|
||||
@@ -2267,6 +2268,7 @@ fn main() {
|
||||
stage_local_project_asset_canvas_image,
|
||||
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,
|
||||
|
||||
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
+40
-9
@@ -4,6 +4,7 @@ import type {
|
||||
ImageCanvasGenerationPort,
|
||||
ImageCanvasGenerationProgress,
|
||||
ImageCanvasGenerationRecord,
|
||||
ImageCanvasGenerationServiceIdentityConfirmation,
|
||||
ImageCanvasHostPort,
|
||||
ImageCanvasHostResult,
|
||||
ImageCanvasHostScope,
|
||||
@@ -71,6 +72,14 @@ type GenerationProgressEvent = ImageCanvasGenerationProgress & {
|
||||
occurredAt: number;
|
||||
};
|
||||
|
||||
type ServiceIdentityConfirmationResult = {
|
||||
generationId: string;
|
||||
operationId: string | null;
|
||||
operationState: string;
|
||||
serviceOrigin: string;
|
||||
identityScheme: 'service-origin-v1';
|
||||
};
|
||||
|
||||
export type LocalAssetCommitResult =
|
||||
| {
|
||||
status: 'committed' | 'already-committed';
|
||||
@@ -130,6 +139,10 @@ export type TauriImageCanvasHostAdapter = ImageCanvasHostPort & {
|
||||
manifest: GameCreationAppManifest;
|
||||
}>
|
||||
>;
|
||||
confirmGenerationServiceIdentity(input: {
|
||||
scope: ImageCanvasHostScope;
|
||||
confirmation: ImageCanvasGenerationServiceIdentityConfirmation;
|
||||
}): Promise<ImageCanvasHostResult<ServiceIdentityConfirmationResult>>;
|
||||
subscribeCommitted(
|
||||
callback: (event: LocalAssetCommittedEvent) => void,
|
||||
): Promise<UnlistenFn>;
|
||||
@@ -159,13 +172,12 @@ function failure(error: unknown): ImageCanvasHostResult<never> {
|
||||
const normalized = message.toLowerCase();
|
||||
return {
|
||||
status: 'failed',
|
||||
code:
|
||||
message.includes('reconciliation-required')
|
||||
? 'reconciliation-required'
|
||||
: message.includes('project-identity-conflict')
|
||||
? 'project-identity-conflict'
|
||||
: normalized.includes('external editor api')
|
||||
? 'platform-service-configuration'
|
||||
code: message.includes('reconciliation-required')
|
||||
? 'reconciliation-required'
|
||||
: message.includes('project-identity-conflict')
|
||||
? 'project-identity-conflict'
|
||||
: normalized.includes('external editor api')
|
||||
? 'platform-service-configuration'
|
||||
: normalized.includes('authentication-required') ||
|
||||
message.includes('登录已失效')
|
||||
? 'authentication-required'
|
||||
@@ -210,7 +222,7 @@ export function createMockImageCanvasGenerationPort(): ImageCanvasGenerationPort
|
||||
generateImage: unsupported,
|
||||
recoverImages: async () => ({
|
||||
status: 'ok' as const,
|
||||
value: { resumedGenerationIds: [] },
|
||||
value: { resumedGenerationIds: [], serviceIdentityConfirmations: [] },
|
||||
}),
|
||||
removeBackground: unsupported,
|
||||
};
|
||||
@@ -314,7 +326,10 @@ export function createTauriImageCanvasHostAdapter(input: {
|
||||
null,
|
||||
recoverInput.onProgress,
|
||||
);
|
||||
const result = await invokeInput<{ resumedGenerationIds: string[] }>(
|
||||
const result = await invokeInput<{
|
||||
resumedGenerationIds: string[];
|
||||
serviceIdentityConfirmations: ImageCanvasGenerationServiceIdentityConfirmation[];
|
||||
}>(
|
||||
'recover_local_project_asset_canvas_generations',
|
||||
baseScope(recoverInput.scope),
|
||||
);
|
||||
@@ -478,6 +493,22 @@ export function createTauriImageCanvasHostAdapter(input: {
|
||||
},
|
||||
},
|
||||
generation,
|
||||
async confirmGenerationServiceIdentity({ scope, confirmation }) {
|
||||
try {
|
||||
const result = await invokeInput<ServiceIdentityConfirmationResult>(
|
||||
'confirm_local_project_asset_canvas_generation_service_identity',
|
||||
{
|
||||
...baseScope(scope),
|
||||
generationId: confirmation.generationId,
|
||||
operationId: confirmation.operationId,
|
||||
challenge: confirmation.challenge,
|
||||
},
|
||||
);
|
||||
return { status: 'ok', value: result };
|
||||
} catch (error) {
|
||||
return failure(error);
|
||||
}
|
||||
},
|
||||
completion: {
|
||||
async commitImage(commitInput) {
|
||||
try {
|
||||
|
||||
@@ -5489,6 +5489,95 @@ iframe.preview-frame {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.game-resource-recovery-dialog {
|
||||
width: min(620px, 100%);
|
||||
max-height: min(720px, calc(100dvh - 40px));
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.game-resource-recovery-list {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.game-resource-recovery-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 12px;
|
||||
border: 1px solid #ead8cf;
|
||||
border-radius: 13px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.game-resource-recovery-item > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.game-resource-recovery-item strong,
|
||||
.game-resource-recovery-item small,
|
||||
.game-resource-recovery-item span {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.game-resource-recovery-item small {
|
||||
color: #9a7d70;
|
||||
}
|
||||
|
||||
.game-resource-recovery-item > button,
|
||||
.game-resource-recovery-error > button {
|
||||
min-height: 34px;
|
||||
padding: 0 13px;
|
||||
border: 1px solid #ddb9a8;
|
||||
border-radius: 999px;
|
||||
background: #fff7f2;
|
||||
color: #87513b;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.game-resource-recovery-item > button:disabled,
|
||||
.game-resource-recovery-error > button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.56;
|
||||
}
|
||||
|
||||
.game-resource-recovery-readonly {
|
||||
flex: 0 0 auto;
|
||||
padding: 7px 10px;
|
||||
border-radius: 999px;
|
||||
background: #f5efec;
|
||||
color: #846f66;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.game-resource-recovery-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 11px;
|
||||
background: #fff0ea;
|
||||
color: #9a452d;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.game-resource-recovery-item,
|
||||
.game-resource-recovery-error {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.game-resource-recovery-item > button,
|
||||
.game-resource-recovery-error > button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.game-approval-dialog > header button {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
|
||||
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
@@ -498,17 +498,24 @@ type ProjectAgentMudPointAttribution = {
|
||||
|
||||
### 7.6 素材创作无限画布阶段一至五最终验收
|
||||
|
||||
实现状态(2026-08-06):资源总览新增/图片精修入口、中央素材画布、取消恢复、正式 manifest/revision 实时合并、依赖图重建、dependency/type 双布局协调和三阶段自动定位已经接通。command/event 任意顺序按项目、commit、event 与 revision 去重;低 revision、旧 graph/layout 和失效 focus generation 均不能倒灌。普通客户 Tauri 图片新增与精修通过共享 generation port 使用平台登录态接入现有编辑器生成队列、泥点计费、稳定引用下载、阶段三正式事务和重启恢复;第三方开发者 External API 保持独立。客户无需且不能在画布填写 Base URL/API Key,成功不依赖刷新、重开项目、随机等待或手工修复。
|
||||
实现状态(2026-08-11):当前产品切片禁用“新增资源”,只从现有资源进入非破坏性编辑。图片进入中央 refine 画布,其他现役类型进入统一派生编辑壳;取消恢复、正式 manifest/revision 实时合并、依赖图重建、dependency/type 双布局协调和三阶段自动定位已经接通。command/event 任意顺序按项目、commit、event 与 revision 去重;低 revision、旧 graph/layout 和失效 focus generation 均不能倒灌。Tauri 远端媒体编辑固定使用 AppData 私有 `editorApi.baseUrl/apiKey` 访问 `/api/external/v1/*`,不把 Key 传入 WebView、项目事实、账本、日志或普通错误;画布 UI 不提供 Base URL/API Key 输入。重启恢复只继续原 operation,不以新请求、新幂等键或新 operationId 替代结果未知的旧任务。
|
||||
|
||||
1. 网站与 Tauri 实际 import 同一份 `@genarrative/image-canvas-core` 和 `@genarrative/image-canvas-react`,客户端没有复制的主站画布目录;viewport、selection、变换、renderer 与 history 算法位于共享层,宿主只保留事件接线与 adapter 副作用。
|
||||
2. “新增资源”和“精修资源”分别进入 create/refine 素材画布;精修保留原资产、创建新资产,并用规范 `referenceResourceIds` 登记直接血缘。
|
||||
2. “新增资源”在当前产品切片中保持禁用;“编辑资源”只接受现有资源。图片 refine 保留原资产与原文件、创建新资产,并用规范 `referenceResourceIds` 登记直接血缘;其他类型同样只追加派生文件/asset 或子版本,不覆盖、删除或重排源记录。
|
||||
3. 草稿 schema、revision、容量、项目身份、OS 锁、CAS、恢复副本和媒体引用符合权威专题;损坏、未知 schema、身份错配和超限均失败关闭。
|
||||
4. 正式提交携带 `expectedProjectId + expectedRevision + expectedDraftRevision + commitId + idempotencyKey`,按事务快照/journal/ledger、文件、manifest/revision、回读、ledger/draft、事件顺序完成;首个快照、全部快照、journal 写入、文件、manifest、revision、验证和 ledger 各崩溃阶段均有确定结果,未发布快照残留只在证明正式文件、manifest 与 revision 均未变化时清理。
|
||||
5. 保存成功后不刷新、不重开项目即可进入 manifest 投影、依赖图、dependency/type 布局和允许时的选中定位;切项目、切中央状态、改选择或改搜索后的迟到结果不得抢焦点。
|
||||
6. 搜索/筛选隐藏新资源时保留条件,明确提示“新资源已保存,当前筛选条件下不可见”,只通过显式动作清除条件并定位。
|
||||
7. 新增、精修、生成、保存、取消、失败和恢复必须覆盖权威专题 §13 的完整验收矩阵;只完成画布 UI 或只完成本地写文件都不能算正式闭环。
|
||||
7. 现有资源编辑、生成、保存、取消、失败和恢复必须覆盖权威专题 §13 中与当前非破坏性编辑切片对应的验收矩阵;只完成画布 UI 或只完成本地写文件都不能算正式闭环。
|
||||
8. 自动定位必须分别证明资源已投影、dependency/type 两份布局都 settled 且存在目标位置、目标卡 DOM 已提交;搜索隐藏走显式清除/定位,任何 commit 最多自动聚焦一次。
|
||||
9. 客户生成只使用当前登录身份;后端按 owner 预扣/退款泥点并返回可轮询 operation。登录失效、余额不足、平台生成配置故障和远端失败必须在画布内可见,保留参数并提供重试或返回修改;生成状态不得因固定高度或 `overflow` 裁剪而消失,终态后刷新钱包余额。
|
||||
9. Tauri 远端媒体编辑只使用发布 AppData 私有 External v1 配置;后端按 owner 预扣/退款泥点并返回可轮询 operation。凭据失效、余额不足、平台生成配置故障和远端失败必须在画布内可见;生成状态不得因固定高度或 `overflow` 裁剪而消失,终态后刷新钱包余额。
|
||||
10. 素材画布生成账本的服务身份固定为 `service-origin-v1`,绑定规范化 External base URL 的服务指纹,不绑定 Developer API Key;确认面板只展示去除路径与凭据的服务 origin。无法用当前 Key 验证的升级前 Key-bound 账本在任何网络动作前显示当前服务 origin 并要求用户确认;确认挑战过期、账本变化或 base URL 变化均失败关闭。确认后 `accepted/running` 只恢复原 GET,`prepared` 只可精确重放冻结的原 POST。全类型资源编辑复用同一身份分类,旧 Key-bound 指纹无法验证时保留原 operation 进入对账,不自动接管或重放。
|
||||
11. 资源编辑恢复面板必须为独立 modal,展示后端权威队列的全部 operation。用户可继续任意可恢复项;`remote-failed` 只允许显式移出活动队列,并保留私有账本审计;`reconciliation-required` 只读展示对账。读取失败必须提供重试,不得伪装空队列;操作后必须重读后端。
|
||||
12. `remote-failed` 已是远端明确终态,重启后不再 POST、不再轮询、不再扣费;`archived` 仅表示用户已将它移出活动恢复队列,不等于 `committed`。`result-unknown`、鉴权临时失败和 `reconciliation-required` 均不允许归档或重新生成。
|
||||
13. 派生资产提交必须通过 durable asset transaction journal 串起最终文件、manifest 和 project revision。任一崩溃阶段恢复后只有一份派生文件、一条 manifest asset,revision 精确推进一次;manifest 已写而 revision 未写时只前向补 revision,无法证明的组合进入人工对账。durable committed 后遗留的 staging 只有在 staging/正式媒体摘要一致,且 manifest 中按 asset ID 或路径唯一命中并与 journal asset 精确相等时才尽力删除;删除失败不降级已提交结果,身份或媒体漂移则保留 staging 并进入对账。
|
||||
14. generation progress、草稿保存队列、生成/提交回包与延迟 `loadDraft` 必须共用单调 revision 门禁,低 revision 不得覆盖已落地的新草稿。Shift 指针与键盘选择必须与共享 core 一致;单选自身 Shift 不能清空选择,已选多图层普通指针拖动应保持并同步移动选择集。零位移不得产生 undo、documentVersion 或草稿保存。
|
||||
15. 失败 UI 必须按 `generation / draft-save / asset-commit / recovery / cancellation` 五类 operation 显示可访问名称与安全动作。只有生成失败可以保留参数并“返回修改/重新确认”;草稿、CAS、提交、恢复和取消故障不得出现会发起新生成的按钮。
|
||||
16. 派生子版本 journal 必须冻结 project revision before/after 身份和目标 after 记录。manifest 已有目标子版本但 journal 缺失时失败关闭;旧 journal 缺少 revision 身份且当前 revision 已推进、无法证明是同一事务写入时进入人工对账,不得把现状猜测为已提交。
|
||||
|
||||
### 7.7 主站 UI 对齐验收
|
||||
|
||||
@@ -521,6 +528,7 @@ type ProjectAgentMudPointAttribution = {
|
||||
|
||||
## 8. 非目标
|
||||
|
||||
- 当前不开放“新增资源”产品入口,不把本次现有资源非破坏性编辑收口扩展成新资源创建流。
|
||||
- 资源总览当前不实现资源卡手动拖动,也不实现通用聚焦工具栏/工具侧边栏、资源直接替换、运行版本切换、版本回滚、运行模块扩展、测试切片、运行态消费版本、数值参数或泥点归因。正式版本记录已经成为 manifest 业务真相,“编辑资源”仅追加子版本。
|
||||
- 素材创作阶段一不实现高级蒙版/毛发级抠图、图集、角色动画、视频时间线编辑或音频波形级编辑;图片画布继续提供现役编辑闭环,其他类型使用统一非破坏性派生面板。
|
||||
- 资源总览不持久化资源聚焦状态、搜索条件、筛选条件或当前 dependency/type mode;其会话上下文不能塞入 `game-creator-resource-layout.v1`。素材创作 viewport 和图层状态按独立 `game-creator-asset-canvas-draft.v1` 保存,不能混用资源总览 sidecar。
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
# Tauri 无限画布审查缺陷闭环计划
|
||||
|
||||
日期:`2026-08-11`
|
||||
|
||||
状态:`最终差异审查重新打开 S2、S4、S5;正在并行修复 1 个 P1、8 个 P2 和 1 个合同一致性 P3,计划必须继续保留`
|
||||
|
||||
适用分支:`codex/infinite-canvas`
|
||||
|
||||
当前本地基线:`HEAD fa2d8be29`,最新 `origin/master 3c457feb4`,两者 merge-base 为 `aa5c85706`;当前分支相对 `origin/master` 为 `ahead 65 / behind 1`,远端 `origin/codex/infinite-canvas` 仍为 `9ff16393e0`。本轮工程改动尚未提交,用户本地 `.env`、`.env.local`、`output/manual-validation/` 继续排除;提交本轮改动后必须融合这 1 个最新 master 提交并重新验证,不能用本段基线代替最终 head 证据。
|
||||
|
||||
关联权威文档:
|
||||
|
||||
- [`AI 游戏创作智能体 App 实施计划`](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)
|
||||
- [`AI 游戏创作项目开发工作台 PRD`](../../prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md)
|
||||
- [`项目决策日志`](../shared-memory/decision-log.md)
|
||||
- [`项目踩坑记录`](../shared-memory/pitfalls.md)
|
||||
|
||||
> 本文是本轮缺陷修复的临时唯一执行清单。后续 Agent 开始相关工作前必须先读本文,逐项更新“实施状态”和验证证据,不得因为上下文切换跳过未完成项。只要还有未完成、未验证、未融合、未通过远端 CI 或需人工确认的事项,本文就必须继续保留在仓库中。
|
||||
|
||||
## 一、目标
|
||||
|
||||
1. 修复资源编辑恢复账本、manifest / project revision 崩溃窗口和恢复队列阻塞问题。
|
||||
2. 修复无限画布草稿乱序回包、失败态误导、Shift 选择分叉和零位移撤销污染问题。
|
||||
3. 为升级前包含 Developer API Key 的图片生成账本提供显式、安全、可验证的服务身份迁移路径。
|
||||
4. 融合最新 `master`,补齐回归测试、权威文档、提交规范和远端 required CI 证据。
|
||||
5. 保持现有非破坏性编辑语义:任何编辑都创建新资源,原资源、原文件和原版本不得被覆盖或删除。
|
||||
|
||||
## 二、绝对边界
|
||||
|
||||
- 不修改、暂存或提交 `.env`、`.env.local`、`output/manual-validation/` 及任何用户本地配置、凭据、日志或构建产物。
|
||||
- 不通过重发新生成请求来“恢复”已经受理、可能已扣费或结果未知的 operation;恢复只能使用私有账本冻结的 endpoint、原始请求字节、幂等键和 operationId。
|
||||
- 不把 API Key、Authorization、上传 ticket、签名 URL、Provider 正文或绝对路径写入账本、前端 DTO、日志、测试 fixture 或文档。
|
||||
- 不在 Tauri 宿主复制共享画布 core 已有的选择、变换、renderer 或 history 规则;优先复用 `packages/image-canvas-core` 和 `packages/image-canvas-react`。
|
||||
- 不修改 `/api/external/v1` 路由、HTTP 方法或公开 DTO;如果实施中确实需要改公开契约,必须暂停,补齐 OpenAPI 与契约测试后再继续。
|
||||
- 不修改 SpacetimeDB schema。本轮若发现必须修改 schema,立即暂停并先确认迁移计划。
|
||||
- 不为已退役入口增加兼容代码、测试、注释或文档。
|
||||
- 不执行真实付费生成;生成与恢复只用确定性单测或 loopback mock 验证。
|
||||
|
||||
## 三、当前问题与目标设计
|
||||
|
||||
### R0:远端分支与最新 master 尚未形成可信基线
|
||||
|
||||
现状:本地分支比本地 `origin/master` 少 2 个提交;其中 `4c0fdf180` 涉及画布改造恢复链路。截图中的远端 head 与当前本地 head 也不一致。
|
||||
|
||||
目标:
|
||||
|
||||
- 先只读 fetch,核对 `origin/master`、远端 `codex/infinite-canvas`、PR head 和本地 HEAD 的祖先关系。
|
||||
- 先融合最新 master,再在融合后的源码上落修复;按合同解决 `Cargo.lock`、工作台 PRD 和 AI 游戏创作技术方案冲突,不能直接选择一侧覆盖。
|
||||
- 融合后重新定位下列函数与测试,禁止沿用截图旧行号。
|
||||
|
||||
### R1:远端明确 `failed` 后账本仍永久占用 pending 队列
|
||||
|
||||
现状:`wait_for_resource_edit_remote` 对 `status=failed` 只返回普通错误;`ResourceEditLedgerPhase` 没有远端终态失败或用户处置状态,`list_pending_local_project_resource_edits_at` 会继续列出该 operation。
|
||||
|
||||
目标设计:
|
||||
|
||||
- 为私有账本增加可识别的远端终态失败,持久化稳定失败分类和终态时间,不持久化远端错误正文。
|
||||
- 区分三类状态:可安全继续、需要人工对账、远端明确失败。远端明确失败不得再次 POST、不得继续轮询、不得触发重复生成或扣费。
|
||||
- 增加“移出恢复队列”的显式处置。处置只把终态失败标记为已跳过/已归档,保留账本审计,不删除远端事实,不伪装 committed。
|
||||
- 不允许对结果未知、鉴权临时失败或 reconciliation-required operation 直接执行跳过;这些状态继续失败关闭。
|
||||
|
||||
验收:远端 `failed` 只轮询到一次终态,重启后仍为终态失败;恢复按钮不再被它永久占用;跳过后不再出现在活动恢复队列,但账本仍可审计,HTTP POST/GET、manifest、revision 和扣费相关调用均不增加。
|
||||
|
||||
### R2:资产 manifest、project revision 与账本提交之间存在崩溃窗口
|
||||
|
||||
现状:`commit_resource_edit_asset` 先写 manifest,再推进 project revision,最后才把 ledger 标记 committed。若进程在这些步骤之间退出,重启看到 manifest 已有 asset 时会直接把 ledger 标记 committed,却可能永远漏掉 revision。普通错误回滚不能覆盖进程崩溃。
|
||||
|
||||
目标设计:
|
||||
|
||||
- 复用版本编辑现有 journal 思路,为资产提交增加 durable transaction journal;不要只在错误返回路径做内存回滚。
|
||||
- journal 至少绑定 operation/project/source、稳定 asset ID、最终相对路径、媒体摘要、base/target project revision、manifest before/after 身份和阶段。
|
||||
- 阶段覆盖:`prepared -> media-installed -> manifest-written -> revision-written -> committed`。每次推进都原子写入并回读;ledger 只能在 journal 证明 manifest、revision、文件与结果身份全部一致后进入 committed。
|
||||
- 恢复矩阵:
|
||||
- manifest/revision 都是 before:安全继续或只清理能证明属于本事务的新文件;
|
||||
- manifest 是 after、revision 是 base:只前向补目标 revision;
|
||||
- manifest/revision 都是 after:补齐 journal、ledger 和结果投影;
|
||||
- asset 重复、摘要冲突、revision 越过目标或 before/after 身份不一致:进入 reconciliation-required,禁止猜测回滚或再次生成。
|
||||
- 审计 `resource_editor.rs` 内其它“写 manifest 后推进 revision”的同类路径;能复用同一事务 helper 的一并收口,不能留下等价崩溃窗口。
|
||||
|
||||
验收:在每个阶段构造 crash fixture,重启恢复后始终只有一份派生文件和一条 manifest asset,revision 精确推进一次;“manifest 已写、revision 未写”的回归必须证明恢复补齐 revision,而不是直接 committed。
|
||||
|
||||
### R3:草稿 loadDraft 乱序完成会把新 revision 覆盖成旧 revision
|
||||
|
||||
现状:`applyGenerationProgressRevision` 只把 load 回包与各自捕获的 progress revision 比较。r5 请求后发、r6 请求先回时,r5 仍可能覆盖已经落到 `draftRef.current` 的 r6。
|
||||
|
||||
目标设计:
|
||||
|
||||
- load 回包落地前同时校验 scope epoch、回包 revision、触发该请求的最低 revision,以及“回包当下”的 `draftRef.current.revision`。
|
||||
- 只允许 revision 单调前进;相等回包只有完整身份与当前状态一致时才可幂等应用,任何较旧回包都丢弃。
|
||||
- 生成 progress、草稿保存队列和初始 hydration 使用同一单调规则,避免一处修复、另一处仍回退。
|
||||
|
||||
验收:用 deferred Promise 分别覆盖 r5/r6 正序和逆序完成、保存与 progress 交错、scope 切换和卸载;最终 draft 始终停在最高可信 revision,下一次保存不产生伪 revision conflict。
|
||||
|
||||
### R4:恢复 UI 只处理排序后的第一条 operation
|
||||
|
||||
现状:工作台按钮固定读取 `pendingResourceEdits[0]`。一条不可恢复旧 operation 会永久挡住后续已受理或已下载、可能已扣费的 operation。
|
||||
|
||||
目标设计:
|
||||
|
||||
- 顶部按钮只负责打开独立恢复面板,不在当前工具栏下方追加说明或列表。
|
||||
- 面板逐条显示安全名称、编辑类型、创建时间、当前阶段和允许动作;用户可以选择任意 operation 恢复。
|
||||
- 可继续项提供“继续”,reconciliation-required 提供只读对账提示,远端终态失败只提供“移出恢复队列”。
|
||||
- 单项执行期间只锁该 operation;其它行保持可见但避免重复提交。完成或处置后重新读取后端权威队列,不在前端猜测删除。
|
||||
- 列表读取失败必须显示可重试错误,不能静默伪装为空队列。
|
||||
|
||||
验收:至少构造三条乱序记录(首条终态失败、第二条可恢复、第三条需对账),证明第二条可以独立恢复、首条可以显式处置、第三条不会被误重放;项目切换后的迟到结果不能污染新项目面板。
|
||||
|
||||
### R5:升级前 Key-bound 指纹在旧 Key 已失效后没有恢复路径
|
||||
|
||||
现状:新代码只计算服务地址指纹,但旧账本可能保存 `H(baseUrl, H(oldKey))`。旧 Key 已轮换时,客户端既不能验证旧哈希,也不能确认当前地址就是原服务,结果只能永久 `configuration-changed`。
|
||||
|
||||
目标设计:
|
||||
|
||||
- 为 generation ledger 的服务身份增加显式版本/方案标记,当前方案固定为规范 base URL 的 service fingerprint;新账本不再混入凭据。
|
||||
- 旧 Key 仍可用且旧指纹精确匹配时,可在任何远端动作前自动原子迁移为 service fingerprint。
|
||||
- 旧 Key 已失效、旧指纹无法验证时,不得自动接管。提供显式用户确认面板,显示脱敏的当前服务 origin 与旧 operation 状态;用户确认“当前地址就是创建该 operation 的原服务”后,Tauri 在项目锁/账本锁内重读配置与 ledger,再把身份原子迁移为 service fingerprint。
|
||||
- 确认命令只接收项目和 generation/operation 身份及防止陈旧确认的挑战值,不接收 Key、请求正文或任意 base URL;配置或账本在面板打开后变化时确认失败。
|
||||
- accepted/running 的旧账本迁移后只恢复原 GET;prepared/未知提交结果只有在确认后才允许用原 endpoint、原始正文和原幂等键重放原 POST。任何新 operationId、新正文或新键都禁止。
|
||||
- 该路径同时审计资源编辑与素材画布的服务身份实现,提取共享纯函数,避免再次形成两套指纹语义。
|
||||
|
||||
验收:必须补“旧 Key 已失效、升级后配置新 Key、用户确认当前服务、恢复旧 accepted operation”的回归;断言恢复只有 GET、operationId 不变、没有新 POST。另覆盖确认前零网络、确认挑战过期、服务地址改变、prepared 原请求精确重放和账本零凭据。
|
||||
|
||||
### R6:统一 `canvas.failed` 会误导用户执行生成重试
|
||||
|
||||
现状:草稿保存、revision 冲突、资源提交、取消/恢复和真实生成错误都进入同一失败 UI;可访问名称固定为“图片生成失败”,并统一提供返回修改/重新确认,可能让保存故障再次生成。
|
||||
|
||||
目标设计:
|
||||
|
||||
- 失败状态携带稳定操作类别,例如 `generation / draft-save / asset-commit / recovery / cancellation`,错误码继续保持脱敏。
|
||||
- 使用纯映射函数按操作类别给出标题、`aria-label`、说明和允许动作。
|
||||
- 只有明确 generation 失败允许“返回修改/重新确认”;草稿保存或 CAS 故障提供保留草稿、重新加载或返回总览;资产 commit/result-unknown 只提供恢复/对账,不触发新生成;取消故障保留草稿处置入口。
|
||||
- 保留用户已填写的生成参数,不把失败分类修复误做成清空表单。
|
||||
|
||||
验收:逐类断言可访问名称和按钮集合;保存/revision/CAS/取消故障中不存在生成重试按钮,生成故障仍能返回原参数。
|
||||
|
||||
### R7:Tauri Shift 选择复制了共享 core,但语义已经分叉
|
||||
|
||||
现状:Tauri `onPointerDown` 自行实现 toggle;仅选中一个图层时 Shift 点击自身会删除最后一项。共享 `resolvePointerSelection` 已明确保留最后一项。
|
||||
|
||||
目标设计:
|
||||
|
||||
- Tauri pointer 与键盘选择统一调用共享 `resolveLayerPointerSelection`/`resolvePointerSelection`,删除宿主内重复的数组增删算法。
|
||||
- 同一次 pointerdown -> pointerup -> click 序列只能切换一次;普通点击已选图层保持当前多选集合以支持拖拽,Shift 单选自身仍保留该项。
|
||||
- Web 与 Tauri 用同一组 core contract 测试锁定语义。
|
||||
|
||||
验收:覆盖单选自身 Shift、两项多选移除其中一项、向现有选择追加、已选多图层拖拽、键盘 Enter/Space 和 pointer 完整序列;两宿主结果一致。
|
||||
|
||||
### R8:pointerdown 立即写 history,零位移也触发撤销和保存
|
||||
|
||||
现状:图层 move/resize 和背景 pan 在 pointerdown 立即 capture,pointerup 无条件 mark dirty;单击已选图层或零位移序列会产生空撤销项并触发草稿 CAS。
|
||||
|
||||
目标设计:
|
||||
|
||||
- pointerdown 只冻结初始快照和待定 history action,不立即压入 history。
|
||||
- 第一次产生有效 world delta、viewport delta 或 resize bounds 变化时只 capture 一次;pointerup 只有最终快照确实变化才 mark dirty。
|
||||
- 零位移、pointercancel 前无变化、锁定图层和仅保持原选择不得产生 history、documentVersion 或草稿保存。
|
||||
- 选择本身真实变化仍按草稿合同持久化,但与 move history 分开,不消费用户第一次撤销。
|
||||
- 优先复用共享 React stage hook 已有的 pending drag history 思路;若宿主事件形状不同,只保留最薄的 Tauri adapter 状态。
|
||||
|
||||
验收:单击已选图层后第一次撤销仍撤销上一个真实操作;零位移不调用 updateDraft;首次有效移动只产生一个 history entry 和一次 dirty;多次 pointermove 不重复 capture;取消/锁定/生成中均无副作用。
|
||||
|
||||
### R9:提交历史与远端审查闭环
|
||||
|
||||
现状:审查指出历史中存在英文 merge 标题和空正文;当前分支又是已发布的长分支,直接重写会影响协作者。
|
||||
|
||||
目标:
|
||||
|
||||
- 本轮新提交全部使用中文标题,正文逐行说明一项修改;合并提交也遵守同一格式。
|
||||
- 修复完成后重新检查 Gitea 对“提交规范”的实际校验范围。若 required check 仍检查本分支全部历史,新增提交不能修复旧记录。
|
||||
- 未经用户明确批准,不 force-push、不改写共享分支历史。推荐方案是从最新 `origin/master` 新建替代分支,把最终差异整理为少量中文标题+逐行正文的提交并新建/替换 PR;若用户明确要求保留原 PR,再制定可回滚的历史重写方案并单独确认。
|
||||
- 精确 head 的四个 required CI 全绿且审查意见重新确认后,才算远端闭环。
|
||||
|
||||
## 四、实施顺序与状态
|
||||
|
||||
- [x] S0:fetch 并核对本地、远端分支、PR head、最新 master 和 dirty worktree;记录新的基线,保护用户本地文件。
|
||||
- [x] S1:融合最新 master,按权威合同解决冲突;运行冲突扫描、定向 typecheck/test 和 `git diff --check`。
|
||||
- [x] S2:实现 R1 + R2 后端账本状态与资产事务 journal,先写故障矩阵测试,再接恢复命令。最终审查发现的旧 committed version journal、committed 正式媒体异常、HTTP 400 终态分类,以及 202 非法 JSON / 缺 operationId 边界均已补齐并回归。
|
||||
- [x] S3:实现 R5 版本化服务身份与显式确认迁移,补旧 Key 已失效的恢复测试。
|
||||
- [x] S4:实现 R3、R7、R8 的共享交互与单调草稿规则,补 core/React/Tauri 回归。最终审查发现的 progress 读取失败重试和拖离后回到原点均已补齐并回归。
|
||||
- [x] S5:实现 R4、R6 的独立恢复面板与按操作分类失败 UI,完成键盘、焦点和移动端/桌面布局回归。最终两轮审查发现的后置权威重读隔离、失败重读、逐 operation 锁、modal focus trap、并发失败提示、动态禁用焦点和同项目重开 modal 竞态均已补齐并回归。
|
||||
- [x] S6:同步 AI 游戏创作技术方案、工作台 PRD、decision log 和 pitfalls;删除当前文档中已被新实现推翻的过度承诺。
|
||||
- [x] S7:完成改动范围内的定向验证与必要门禁,复核无敏感信息、无用户文件、无墓碑兼容和无公开契约漂移;定向结果未暴露需要升级完整 native-shell 门禁的跨模块风险。
|
||||
- [ ] S8:按 R9 处理提交历史与 PR,确认精确 head 的远端 required CI 和审查结论。
|
||||
- [ ] S9:全部完成后,将稳定结论保留在权威文档/共享记忆中,并在最终代码提交中删除本临时计划文件。
|
||||
|
||||
执行纪律:一次只推进一个步骤;任何步骤发现合同歧义、公开 API 变化、schema 变化、历史重写或真实付费请求需求时立即暂停,不跨过确认门。每次交接必须在本节勾选真实完成项,并在“实施记录”写入提交、测试和剩余风险;不得预先勾选。
|
||||
|
||||
## 五、预计文件范围
|
||||
|
||||
核心实现:
|
||||
|
||||
- `apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`
|
||||
- `apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs`
|
||||
- `apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs`(仅提取共享服务身份纯逻辑时)
|
||||
- `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/view/project-development/index.tsx`
|
||||
- `apps/ai-game-creator-shell/src/styles.css`(仅恢复面板确有布局需要时)
|
||||
- `packages/image-canvas-core/src/selection.ts`
|
||||
- `packages/image-canvas-core/src/stageInteractions.ts`
|
||||
- `packages/image-canvas-react/src/useImageCanvasStageInteractions.ts`(优先复用,不随意改行为)
|
||||
|
||||
测试:
|
||||
|
||||
- `apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx`
|
||||
- `apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx`
|
||||
- `packages/image-canvas-core/src/sharedCanvasCore.test.ts`
|
||||
- `packages/image-canvas-react/src/sharedCanvasReact.test.tsx`
|
||||
- `resource_editor.rs` 与 `asset_canvas/generation.rs` 内 Rust 定向测试
|
||||
|
||||
文档:
|
||||
|
||||
- `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
|
||||
- `docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`
|
||||
- `docs/project-memory/shared-memory/decision-log.md`
|
||||
- `docs/project-memory/shared-memory/pitfalls.md`
|
||||
- 本计划文件(仅在全部完成时删除)
|
||||
|
||||
实际实施可以减少文件,但增加范围前必须说明必要性;禁止借机重构无关 Runtime、主站编辑器、后端生成队列或 SpacetimeDB。
|
||||
|
||||
## 六、验证矩阵
|
||||
|
||||
### 6.1 Rust 定向验证
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml resource_editor -- --test-threads=1
|
||||
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml asset_canvas -- --test-threads=1
|
||||
cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --tests
|
||||
```
|
||||
|
||||
定向 filter 必须真实匹配新增用例;`0 tests` 不算通过。必要时按新增测试统一前缀再拆分运行,避免把无关长测当成唯一证据。
|
||||
|
||||
### 6.2 前端与共享画布验证
|
||||
|
||||
```bash
|
||||
npm run test -- apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx packages/image-canvas-core/src/sharedCanvasCore.test.ts packages/image-canvas-react/src/sharedCanvasReact.test.tsx
|
||||
npm run ai-game-creator-shell:typecheck
|
||||
```
|
||||
|
||||
需要真实 DOM 行为的恢复面板补充键盘焦点、Escape、按钮可访问名称和窄容器测试;不通过默认说明文案填充 UI。
|
||||
|
||||
### 6.3 仓库门禁
|
||||
|
||||
```bash
|
||||
npm run check:encoding
|
||||
git diff --check
|
||||
```
|
||||
|
||||
本轮按用户授权以改动范围内的定向测试为主,不把完整 `npm run check:native-shells` 作为每阶段或最终强制项。若定向 Rust、前端测试、typecheck 或差异复核暴露跨模块风险,再升级运行相关完整门禁;sandbox loopback `EPERM`、外部依赖失败和源码测试失败分别报告,不能互相冒充。
|
||||
|
||||
### 6.4 安全与差异复核
|
||||
|
||||
- `git status --short` 中用户原有 `.env`、`.env.local`、`output/manual-validation/` 保持未暂存、未修改归属。
|
||||
- 私有 ledger 序列化测试扫描 Key、Authorization、ticket、签名 URL、Provider 正文、绝对路径和敏感字段名,命中数必须为 0。
|
||||
- `git diff --check`、中文编码检查和冲突 marker 扫描通过。
|
||||
- 若没有修改 External v1 公开契约,OpenAPI 必须保持零 diff;若出现 diff,任务自动回到契约评审门。
|
||||
|
||||
### 6.5 远端验收
|
||||
|
||||
- Gitea 精确 head 的四个 required job 全部成功。
|
||||
- 逐条回复 R1-R9 对应审查意见,提供代码与测试证据。
|
||||
- PR 不落后最新 base,且最终提交历史满足仓库中文标题和逐行正文规范。
|
||||
|
||||
## 七、完成定义与计划删除条件
|
||||
|
||||
只有以下条件全部同时满足,才允许删除本文:
|
||||
|
||||
- R0-R9 全部实现或按明确批准的替代方案闭环,没有保留“后续再修”的 P1/P2。
|
||||
- 故障矩阵、乱序回包、Key 已失效迁移、可选恢复队列、失败分类、共享 Shift 选择和零位移 history 回归全部通过。
|
||||
- 最新 master 已融合,冲突按权威合同解决,分支不落后 base。
|
||||
- AI 游戏创作技术方案、工作台 PRD、decision log 和 pitfalls 已更新为当前真实实现,不保留过期承诺。
|
||||
- 定向 Rust、前端、typecheck、encoding 和 diff 门禁有可信结果;若定向结果要求升级,则对应完整 native-shell 门禁也有可信结果;远端四项 required CI 在精确最终 head 上全部成功。
|
||||
- 提交历史和 PR 审查闭环;任何 force-push/替代分支选择均已取得用户授权并完成。
|
||||
- `.env`、`.env.local`、`output/manual-validation/` 和其它用户本地文件未进入任何提交。
|
||||
|
||||
删除方式:在最后一个工程提交中删除本文,不留下“计划曾存在/已删除”的墓碑注释或墓碑文档;长期有效的结论只保留在权威技术文档、decision log 和 pitfalls 中。若任一条件未满足,本文继续保留并更新状态,不得为了让 diff 看起来完成而提前删除。
|
||||
|
||||
## 八、实施记录
|
||||
|
||||
- `2026-08-11`:S7 完成。最终组合验证为前端四文件 `65/65`、`resource_editor 31/31`、此前未受后续改动影响的 `asset_canvas 27/27`;客户端 typecheck、`cargo check --tests`、`cargo fmt --check`、Prettier 改动文件检查、`npm run check:encoding`(`5383` 个文件)和 `git diff --check` 均通过。新增行扫描未命中私钥、长 Bearer/API token、签名 URL、个人绝对路径或冲突 marker;本任务对 External v1 OpenAPI 仍为零 diff;`.env`、`.env.local`、`output/manual-validation/` 保持未暂存并明确排除。S8/S9 仍等待本轮提交、最新 `origin/master 3c457feb4` 融合、精确最终 head 远端 required CI 以及提交历史方案确认,因此计划继续保留。
|
||||
- `2026-08-11`:第二轮恢复面板复核发现 3 个 P2 并已闭环:并发 operation 的原始动作错误使用逐 operation 状态保存,不再因其它 operation 推进全局读取 epoch 而丢失;焦点陷阱把动态禁用后已退出当前可聚焦集合的 active element 视为边界;modal 打开/关闭 epoch 阻止同项目旧 action 关闭后来重开的新面板。新增 3 条真实异步交互回归后资源实时集成测试为 `17/17`,四文件前端组合定向为 `65/65`(Surface `40`、资源实时集成 `17`、core `5`、React `3`),客户端 typecheck 与 Prettier 改动文件检查通过。
|
||||
- `2026-08-11`:Rust 最终缺口回归补齐:旧 `Committed` version journal 即使项目 revision 仍停在 base 也必须进入对账;External `202` 非法 JSON 或缺 operationId 均原子转为 `reconciliation-required`,后续 resume 在网络前失败关闭。精确非 loopback 用例 `1/1` 通过;202 mock 在沙箱内因 `bind EPERM` 失败,允许 loopback 后 `1/1` 通过;最终 `resource_editor 31/31`、`cargo check --tests` 和 `cargo fmt --check` 通过。仓库编码检查继续为 `5383` 个文件通过。
|
||||
- `2026-08-11`:首次 S7 定向门禁全绿后继续执行三路独立只读审查,没有提前提交。审查未发现前端 P1 或锁顺序/网络持锁问题,但发现 1 个 Rust P1:旧 `Committed` version journal 缺 revision 证明字段时仍可绕过门禁;另发现 8 个 P2:committed 正式媒体为 symlink/目录/读取异常时未转对账、External v1 明确 HTTP 400 仍允许重放 POST、progress 首次读取失败后同 revision 无法重试、pan/move/resize 拖离再回原点未恢复初始快照、恢复/归档后的权威重读迟到污染、项目切换存在 render 到 effect 的守卫窗口、恢复失败未重读权威队列、全局锁阻塞其它 operation。modal 缺少 focus trap 的合同偏差一并修复。S2、S4、S5 已据实重新打开,三个子代理按 Rust、画布、恢复面板互斥文件范围并行补实现与回归。
|
||||
- `2026-08-11`:S6 完成。AI 游戏创作技术方案、项目开发工作台 PRD、decision log 和 pitfalls 已同步为当前实现:补充 committed 后 staging 只在正式媒体摘要和 manifest 唯一精确 asset 均可证明时清理,清理 I/O 失败不逆转 durable committed;正式身份、媒体或 manifest 漂移时保留现场并进入 `reconciliation-required`。version journal 现在冻结 project revision before/after 身份与 after 快照;manifest 已有子版本但缺 journal、旧 journal 无法证明 revision 推进或 revision 已越界时均失败关闭,不再猜测 committed。
|
||||
- `2026-08-11`:S7 本地验证已运行,等待最终独立差异审查后再勾选。Rust `resource_editor 27/27`、`asset_canvas 27/27` 通过;两组 loopback 测试在沙箱分别有 `3` 与 `5` 个 `bind EPERM`,放宽 loopback 后全部通过。`cargo check --tests`、`cargo fmt --check`、四文件前端定向测试 `54/54`(Surface `36`、资源实时集成 `10`、core `5`、React `3`)、客户端 typecheck、`npm run check:encoding`(`5383` 个文件)、`git diff --check`、冲突标记扫描和 External v1 OpenAPI 零 diff 均通过。安全差异扫描未在新增行发现个人绝对路径、私钥或长 Bearer 形态;`.env`、`.env.local`、`output/manual-validation/` 仍未进入任务差异范围。
|
||||
- `2026-08-11`:S5 完成。工作台顶部恢复按钮改为打开独立面板,面板显示后端权威队列中每个 operation 的安全名称、编辑类型、创建时间和阶段;可恢复项可任意选择,`remote-failed` 只能显式移出活动队列,`reconciliation-required` 始终只读。列表读取失败保留错误并提供重试,不再伪装成空队列;操作后重读后端队列,项目切换后的迟到恢复结果由 flow/path 双重守卫丢弃。面板支持 Escape、初始关闭按钮聚焦、关闭后聚焦归还,桌面最大高度内滚动且小于 620px 时行动作改为竖排全宽。画布失败态增加 `generation / draft-save / asset-commit / recovery / cancellation` 稳定操作分类与纯展示映射,只有 generation 失败显示“返回修改/重新确认”,保存、CAS、提交、恢复和取消故障均不会引导新生成。定向验证:Surface `33/33`、资源实时集成 `8/8`、TypeScript typecheck 和 `git diff --check` 通过。
|
||||
- `2026-08-11`:S4 完成。generation progress 不再用浅拷贝先行抬高草稿 revision,而是记录已观测的最高 revision,并在 `loadDraft` 回包当下同时核对 scope 身份、最低期望 revision 和当前草稿 revision;草稿保存队列与生成/提交回包共用同一单调落地门禁。Tauri 的指针与键盘选择统一调用 core `resolveLayerPointerSelection`,Shift 单选自身不再清空,普通点击已选图层保留多选拖动集合。图层移动、缩放与背景平移改为首次真实变化时只 capture 一次冻结 history,pointerup 仅在几何或选择确实变化时 mark dirty,零位移不生成撤销、documentVersion 或草稿保存。定向验证:core `5/5`、React lifecycle `3/3`、Tauri Surface `31/31`、TypeScript typecheck 和 `git diff --check` 通过。
|
||||
- `2026-08-11`:S3 完成。新生成账本固定写入 `service-origin-v1`,资源编辑与素材画布复用同一服务身份分类;可用当前 Key 验证的旧账本自动迁移,无法验证的旧远程事实在确认前零网络并使用 10 分钟、绑定原 operation 冻结事实的挑战值。确认命令不接收 Key、base URL 或请求正文,在 generation singleflight lock 与 project write lock 内重读配置和账本;accepted/running 只 GET 原 operation,prepared 只使用冻结 endpoint、字节和幂等键重放一次原 POST。前端新增独立身份确认对话框。最终定向验证:`assetCanvasSurface.test.tsx` `27/27`、TypeScript typecheck 通过、共享身份分类 `1/1`、服务身份 loopback 回归 `4/4`;后者在沙箱因 `bind EPERM` 受阻,允许 loopback 后全部通过。账本和前端 DTO 的回归确认不含凭据。
|
||||
|
||||
- `2026-08-11`:完成只读基线核对和源码定位,冻结 R0-R9 计划。尚未融合 master、尚未修改生产代码、尚未运行修复后测试;用户本地 `.env`、`.env.local`、`output/manual-validation/` 保持原状。
|
||||
- `2026-08-11`:用户授权开始按计划修复,并明确允许以改动代码范围的定向测试为主;已据此调整 S7 和验证矩阵,完整 `check:native-shells` 仅在定向结果提示跨模块风险时升级执行。
|
||||
- `2026-08-11`:S0 完成。远端审查分支确认为 `9ff16393e0ccc65f3e14244a86cf8516723c3e37`,本地先从 `d6abd2a1fc606e35d2dbb0420c712d6d5a4bcf70` 安全快进;随后两次 fetch 确认最新 `origin/master` 为 `aa5c857060af4fe2644b21e0679a727490995935`。用户本地 `.env`、`.env.local`、`output/manual-validation/` 未修改归属、未暂存。
|
||||
- `2026-08-11`:S1 完成。通过 `f389716149f04feae9ac1e771fa5cd7e347af4eb` 与 `fa2d8be29a5c6098008797d16360af42fcecdf4a` 融合最新主线;按双方合同合并 `Cargo.lock`、工作台 PRD 与技术方案,并修复自动合并暴露的 Godot `projectRevision` 缺失和 Vite `resolve` 重复。`npm run ai-game-creator-shell:typecheck`、客户端 HTTP/错误边界 `5` 项定向测试、`cargo metadata --locked --no-deps`、`npm run check:encoding`、冲突扫描和 `git diff --check` 通过。
|
||||
- `2026-08-11`:S2 完成。资源编辑账本新增 `remote-failed / archived` 持久终态、稳定失败分类和显式归档命令;远端明确失败后恢复入口在任何网络动作前失败关闭,只有该终态可移出活动队列,`result-unknown / reconciliation-required` 不可归档。资产提交改为 `prepared -> media-installed -> manifest-written -> revision-written -> committed` durable journal,冻结文件、manifest before/after 与 project revision before/after 身份;5 个崩溃断点均可前向恢复,漂移进入对账。审计发现的任务图片正规化同类 manifest/revision 窗口也复用资产事务日志,并覆盖旧实现“manifest 已写、revision 未写”的恢复。沙箱内 3 个 loopback fixture 因 `bind EPERM` 失败,提升权限重跑后资源编辑 `19/19` 通过;新增正规化恢复 `2/2` 通过。
|
||||
@@ -1,11 +1,21 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-08-11 Tauri 无限画布以可证明事务和分类恢复收口
|
||||
|
||||
- 终态与队列:资源编辑账本正式区分可继续阶段、`reconciliation-required`、`remote-failed` 和 `archived`。远端明确失败只保存稳定分类与终态时间,不得再 POST、轮询或重新扣费;只有该终态能由用户显式归档并移出活动恢复队列,归档保留账本且不伪装 `committed`。结果未知和需对账项继续失败关闭。
|
||||
- 本地提交:派生 asset 提交新增 `game-creator-resource-edit-asset-transaction.v1` durable journal,冻结 operation/project/source、最终路径/摘要、manifest before/after 和 project revision before/after,以 `prepared -> media-installed -> manifest-written -> revision-written -> committed` 推进。只有文件、manifest、revision 与 journal 全部回读相等才提交 ledger;manifest after/revision before 只前向补 revision,无法证明的组合进入对账,不做猜测回滚。durable committed 后遗留 staging 只有在 staging 与正式媒体摘要一致、manifest 按 asset ID 或路径唯一命中且精确等于 journal asset 时才尽力清理;删除 I/O 失败不改变 committed,身份或媒体漂移则保留 staging,并把 journal 与 ledger 转入对账。
|
||||
- 版本提交:派生子版本 journal 冻结 project revision before/after 摘要和目标 after 记录。manifest 已有目标子版本但 journal 缺失时失败关闭;旧 journal 缺少 revision 身份时,只有当前 revision 仍为 base 才允许补齐身份,revision 已推进且无法证明由同一事务写入时必须进入 `reconciliation-required`;缺少上述 journal 证明时,不得仅以子版本存在或 revision 数值已到达推断 committed。
|
||||
- 服务身份:新生成账本统一写 `service-origin-v1`,绑定规范化 External base URL 的服务指纹,Developer API Key 只负责授权而不拥有 operation;确认面板只展示去除路径与凭据的服务 origin。升级前 Key-bound 指纹可被当前 Key 精确验证时自动迁移;对素材画布生成账本,无法验证时在任何网络动作前显示脱敏服务 origin 并要求用户确认带有期限且绑定账本快照的挑战值,确认后已受理任务只恢复原 GET,prepared 任务只精确重放冻结 POST。全类型资源编辑复用同一身份分类,旧指纹无法验证时保留原 operation 进入对账,不自动接管或重放。
|
||||
- 前端一致性:generation progress、保存队列、生成/提交回包和延迟草稿读取共用单调 revision 门禁;当前 scope 低 revision 不得回退已落地草稿,同 revision 只接受完整相等的幂等回包。Tauri 指针与键盘 Shift 选择共用 `resolveLayerPointerSelection`;移动、缩放和平移只在首次真实变化时 capture history,零位移不生成 undo、documentVersion 或保存。
|
||||
- 交互恢复:工作台用独立 modal 展示全部后端权威 operation,允许选择任意可恢复项;`remote-failed` 只提供归档,`reconciliation-required` 只读展示。读取失败显式重试,操作后重读后端,项目切换后丢弃迟到结果。`canvas.failed` 统一携带 `generation / draft-save / asset-commit / recovery / cancellation` 五类 operation,只有生成失败显示“返回修改/重新确认”。
|
||||
- 产品语义:当前仍禁用“新增资源”,只对现有资源做非破坏性派生编辑;新结果追加为新文件、asset 或子版本,源资源、源文件和原版本保留不变。
|
||||
|
||||
## 2026-08-10 Tauri 客户端远端资源编辑固定使用 External v1
|
||||
|
||||
- 产品边界:主站网页画布继续使用登录态 `/api/editor/*`、`/api/assets/*` 与 `/api/runtime/external-generation/jobs/*`;AI 游戏创作 Tauri 客户端没有网页画布宿主,图片、图片引用、视频、音效和背景音乐等远端媒体编辑固定使用 `/api/external/v1/*`。两条入口继续复用相同请求 DTO、owner 归属、统一生成队列和正式资产结果,不新建平行生成服务。
|
||||
- 凭据边界:客户端沿用发布 AppData 私有运行时配置中的 `editorApi.baseUrl/apiKey`,不实现登录后自动签发 Developer API Key,不把站内 Access Token 传给 Tauri 生成命令,也不把 API Key 打包进仓库、传入 WebView、写入项目、账本、日志或普通错误。Key 缺失投影安全配置错误,`401/403` 投影 External 凭据无效或权限不足,不再描述为站内登录失效。
|
||||
- 路由边界:图片编辑、视频、音效和 BGM 分别使用 `/api/external/v1/editor/images/edits`、`/api/external/v1/editor/videos/generations`、`/api/external/v1/editor/audios/sound-effects/generations` 与 `/api/external/v1/editor/audios/background-music/generations`;上传、确认、轮询和换签固定使用 External v1 对应端点。SVG、UTF-8 文档、代码、Agent 回执与项目版本仍是本地派生,不制造无意义的远端请求。
|
||||
- 可靠性边界:External POST 固定携带原稳定 `Idempotency-Key` 并只接受 `202 + operationId`;`prepared` 只重放原正文和原键,`accepted/running` 只查询 `/api/external/v1/generations/{operationId}`。账本绑定不含明文 Key 的 External 配置身份指纹;升级前已保存的站内 endpoint 只保留为待对账状态,禁止拿 External Key 自动重放。本地参考媒体继续严格执行 ticket → OSS form → confirm,结果只消费稳定 `objectKey/resource/asset`,旧资源保留且派生资源追加。
|
||||
- 可靠性边界:External POST 固定携带原稳定 `Idempotency-Key` 并只接受 `202 + operationId`;`prepared` 只重放原正文和原键,`accepted/running` 只查询 `/api/external/v1/generations/{operationId}`。账本使用 `service-origin-v1` 绑定规范化 External base URL 的服务指纹,不绑定或保存明文 Key;升级前 Key-bound 素材画布生成账本无法用当前 Key 验证时,必须由用户显式确认面板中展示的当前 origin 才能恢复冻结请求;全类型资源编辑的旧指纹无法验证时保留原 operation 对账。升级前已保存的站内 endpoint 只保留为待对账状态,禁止拿 External Key 自动重放。本地参考媒体继续严格执行 ticket → OSS form → confirm,结果只消费稳定 `objectKey/resource/asset`,旧资源保留且派生资源追加。
|
||||
- 关联:`apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts`、`apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.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`。
|
||||
|
||||
## 2026-08-10 客户端参考媒体直传复用已授权私有前缀
|
||||
@@ -22,15 +32,15 @@
|
||||
- 非破坏性边界:图片、SVG、视频、音频、文档和代码生成新的本地文件与 manifest asset;Agent 原回执保持不变,派生文档引用回执身份;版本只追加继承资源绑定的子版本。任何路径都不得覆盖、删除或重排源记录。
|
||||
- 能力分流:SVG/文本/代码走结构化 LLM 内容派生与格式复核;视频使用源视频稳定引用;音效/BGM 因现役接口无源音频字段,固定定义为基于源语义的派生重制,不能宣称波形级编辑;项目版本追加子版本。
|
||||
- 信任边界:前端能力提示不是授权事实,Tauri 在提交前按 manifest、已完成任务或上传登记重新核验来源并复核项目 revision / 源内容摘要。远端生成继续使用稳定幂等身份、`202` 轮询与稳定 object/resource/asset 身份,签名 URL 不落 manifest。
|
||||
- 队列与结果边界:登录态媒体派生统一使用 `game-creator-resource-editor` 专用消费身份;图片 refine、视频、音效和 BGM 复用同一逻辑请求的稳定 `Idempotency-Key`,路由不得丢弃。完成结果只返回裁剪后的稳定 object/resource/asset 引用与必要媒体元数据,不暴露 provider、worker、队列内部字段或临时签名 URL。
|
||||
- 队列与结果边界:Tauri 媒体派生统一经 External v1 进入现役生成队列;图片 refine、视频、音效和 BGM 复用同一逻辑请求的稳定 `Idempotency-Key`,路由不得丢弃。完成结果只返回裁剪后的稳定 object/resource/asset 引用与必要媒体元数据,不暴露 provider、worker、队列内部字段或临时签名 URL。
|
||||
- 验证边界:资源投影回归必须把已完成任务在 `artifacts` 中明确登记的音频归入音乐音效资源,同时继续排除未登记音频和未知二进制;画布工具栏数量断言必须与全部现役工具清单同步;文本预览回归必须覆盖 HTML 与现役代码扩展名;委派回执测试应在显式进入等待态前完成同 action 幂等断言,避免后台 parent-wake 与断言竞争。
|
||||
- 关联:`docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md`、`apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts`、`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`。
|
||||
|
||||
## 2026-08-10 客户端素材画布临时收敛为现有图片非破坏性编辑
|
||||
## 2026-08-10 客户端素材画布收敛为现有图片非破坏性编辑
|
||||
|
||||
- 产品决策:资源总览“新增资源”暂时禁用,普通用户只从唯一图片资源进入“编辑资源”。底层 create 草稿和兼容测试继续保留,不删除既有合同,后续恢复入口时不需要重建数据层。
|
||||
- 编辑语义:refine 自动绑定源图片并调用现有 `/api/editor/images/edits`;结果创建独立 asset 和文件,源 asset/文件保持不变,新资源以 `referenceResourceIds` 登记源资源血缘。
|
||||
- 可靠性决策:公开生成状态每次落盘推进草稿 revision,并同步私有账本、进度事件、staging 和 commit;已受理或结果未知的 operation 遇到鉴权失效进入可恢复对账态,刷新登录后继续原 operation,不创建替代任务。
|
||||
- 产品决策:资源总览“新增资源”禁用,普通用户只从现有图片资源进入“编辑资源”。
|
||||
- 编辑语义:refine 自动绑定源图片,Tauri 通过现役 `/api/external/v1/editor/images/edits` 派生新结果;结果创建独立 asset 和文件,源 asset/文件保持不变,新资源以 `referenceResourceIds` 登记源资源血缘。
|
||||
- 可靠性决策:公开生成状态每次落盘推进草稿 revision,并同步私有账本、进度事件、staging 和 commit;已受理或结果未知的 operation 遇到鉴权失效时保留原 operation。Developer API Key 轮换不创建替代任务;升级前 Key-bound 指纹无法直接验证时通过显式服务 origin 确认后继续原 operation。
|
||||
- 交互决策:dirty 返回必须通过独立确认面板选择保留或放弃,默认保留并先 flush;图层选择和缩放同时提供指针与键盘路径,禁止嵌套交互元素。
|
||||
- 关联:`docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx`、`apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs`。
|
||||
|
||||
@@ -6952,7 +6962,7 @@
|
||||
## 2026-08-10 客户端全类型资源编辑恢复身份与交互门禁
|
||||
|
||||
- 资源编辑 operation 以私有账本中的原 endpoint、请求字节、幂等键和 `operationId` 为唯一恢复身份;账本同时冻结源 asset、媒体类型、资源 kind、生产任务和源版本快照。旧账本只从当前 manifest、已完成任务产物、版本数组及受控扩展名兼容恢复,不能信任 UI 路径或前端重新构造的来源。
|
||||
- Developer API Key 允许轮换,配置身份只绑定 External 服务地址。升级前包含 Key 的 Agent 生成指纹只在当前 Key 可验证原服务时迁移为仅服务地址指纹,迁移后 Key 轮换继续沿用原 operation;无法区分旧 Key 轮换与服务漂移时失败关闭。operation 已建立后遇到 401/403/404、超时或临时网关错误都保留原 operation;恢复命令仅接收项目与 operation 身份,不接收 Key 或新请求正文。
|
||||
- Developer API Key 允许轮换,当前 `service-origin-v1` 配置身份绑定规范化 External base URL 的服务指纹,确认 UI 只展示服务 origin。升级前包含 Key 的生成指纹在当前 Key 可精确验证原服务时自动迁移;无法验证时保留原 operation 进入对账,不自动接管或重放。素材画布生成恢复另提供受挑战值保护的显式 origin 确认。operation 已建立后遇到 401/403/404、超时或临时网关错误都保留原 operation;恢复命令仅接收项目与 operation 身份,不接收 Key 或新请求正文。
|
||||
- pending 扫描上限覆盖目录全部实际条目。媒体提交前重新校验源摘要;视频后续派生使用 committed ledger 的稳定 `objectKey`,不把 `assetObjectId` 当远端输入;版本 journal 重放返回原子版本,禁止重复追加。
|
||||
- 画布生成中冻结 wheel、指针、缩放和方向键状态改变并聚焦“停止等待”;普通 wheel 二维平移,Shift 纵向滚轮横移,Ctrl/Command wheel 缩放。Shift 指针序列只执行一次选择切换,草稿保存失败后退出仍必须提供保留/放弃确认。
|
||||
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`、`apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx`、`packages/image-canvas-core/src/interaction.ts`。
|
||||
|
||||
@@ -4639,9 +4639,18 @@
|
||||
- 处理:任何要断言模拟 HTTP Provider 请求的配置都必须显式写 `agentMode=provider`。测试 helper 可以统一补齐,但直接写隔离 AppData 的 fixture 仍须在自身 JSON 中声明,不能依赖仓库 `.env`、用户 AppData 或历史迁移。
|
||||
- 验证:先单跑失败用例确认请求命中 mock server,再执行完整 `npm run check:native-shells`;日志中不得出现该用例启动 Codex CLI/app-server,所有 Provider/MCP 请求数量和顺序按 fixture 闭合。
|
||||
|
||||
## 资源编辑恢复不能依赖当前 UI 快照或旧 API Key 指纹(2026-08-10)
|
||||
## Tauri 生成与资源编辑恢复不能依赖 UI 快照、旧 Key 指纹或队列首项(2026-08-11)
|
||||
|
||||
- 现象:应用重启后,任务视频或项目版本的编辑 operation 无法恢复;轮换 Developer API Key 后已有 operation 被误判为配置变化;目录中放入大量无关文件还能绕过 pending 扫描上限。派生视频再次编辑时若把 `assetObjectId` 当远端引用,生成会失败或指向错误身份。
|
||||
- 原因:早期账本只保存显示层资源 ID,恢复时又依赖当前页面资源对象;配置指纹混入 Key;扫描计数只在识别出 pending JSON 后递增;本地登记 ID 与 External generation 接受的稳定 `objectKey` 没有分层。
|
||||
- 处理:新账本冻结完整源快照,旧账本从权威 manifest、完成任务和版本记录有界恢复;服务身份只哈希规范 endpoint。扫描在读取每个目录条目时先计数,任何文件都消耗预算。提交前复验源摘要,远端请求只使用账本已确认的稳定 `objectKey`,恢复始终复用原 operation 和请求字节。
|
||||
- 验证:覆盖任务视频/版本旧账本恢复、所有目录条目上限、Key 轮换、401/403/404 保留 operation、源摘要漂移拒绝、committed 视频二次派生以及版本 journal exactly-once。
|
||||
- 现象:应用重启后,任务视频或项目版本的编辑 operation 无法恢复;轮换 Developer API Key 后已有 operation 被误判为配置变化;目录中放入大量无关文件还能绕过 pending 扫描上限。一条远端已明确失败的老 operation 会持续占据队列首项,挡住后续已受理或已下载任务;manifest 已写而 project revision 未写时,又可能被误标为 committed。durable committed 后遗留 staging 可能因一次删除失败而被误报为提交失败,也可能在正式媒体或 manifest 身份已经漂移时被直接删除;manifest 已有派生子版本而 journal 缺失,或旧 journal 没有 revision 身份时,也可能被猜成已经提交。派生视频再次编辑时若把 `assetObjectId` 当远端引用,生成会失败或指向错误身份。
|
||||
- 原因:早期账本只保存显示层资源 ID,恢复时又依赖当前页面资源对象;配置指纹混入 Key;扫描计数只在识别出 pending JSON 后递增;本地登记 ID 与 External generation 接受的稳定 `objectKey` 没有分层。恢复 UI 只选排序后第一项,而账本又没有远端终态失败/归档阶段;资产提交没有一份同时证明文件、manifest 和 revision 的 durable journal,提交后的清理也没有区分可证明的 staging 与需要保留的对账证据;旧 version journal 只保存 base/target 数值,不能证明完整 project revision before/after 身份。
|
||||
- 处理:新账本冻结完整源快照,旧账本从权威 manifest、完成任务和版本记录有界恢复;服务身份用 `service-origin-v1` 哈希规范化 External base URL,确认 UI 只展示去除路径与凭据的服务 origin。对素材画布生成账本,当前 Key 不能验证旧 Key-bound 指纹时,确认前保持零网络动作,由用户显式确认当前服务后才继续原 GET 或精确重放冻结 POST;对全类型资源编辑,同类旧指纹保留原 operation 进入对账,不自动接管或重放。扫描在读取每个目录条目时先计数,任何文件都消耗预算。提交前复验源摘要,远端请求只使用账本已确认的稳定 `objectKey`,恢复始终复用原 operation 和请求字节。
|
||||
- 队列与事务:独立恢复面板必须展示后端权威队列的所有 operation,读取失败不能伪装为空。`remote-failed` 不再重放,只能显式标为 `archived` 并保留账本;`reconciliation-required` 不能归档。派生 asset 使用 `prepared -> media-installed -> manifest-written -> revision-written -> committed` journal,只对可证明状态前向恢复;文件/摘要、manifest before/after 或 revision before/after 不匹配时进入人工对账。committed 后只有 staging 与正式媒体摘要一致、manifest 按 ID 或路径唯一精确匹配 journal asset 时才尽力清理;删除 I/O 失败保持 durable committed,身份或媒体漂移保留 staging 并进入对账。version journal 同样冻结 project revision before/after 身份;manifest 已有子版本但 journal 缺失,或旧 journal 面对已推进 revision 无法补证时都失败关闭。
|
||||
- 验证:覆盖任务视频/版本旧账本恢复、所有目录条目上限、Key 轮换与旧 Key 无法验证时的显式确认、401/403/404 保留 operation、远端明确失败只归档且零新网络/扣费、三条乱序恢复队列、项目切换迟到结果、asset transaction 各崩溃阶段、committed 后 staging 清理成功/删除 I/O 失败/媒体或 manifest 漂移保留、源摘要漂移拒绝、committed 视频二次派生,以及 manifest 子版本缺 journal、旧 version journal 无法证明 revision 推进与 version journal exactly-once。
|
||||
|
||||
## 无限画布延迟草稿与零位移不能制造新状态(2026-08-11)
|
||||
|
||||
- 现象:r5 的 `loadDraft` 比 r6 更晚回包时会把草稿回退;pointerdown 后没有任何移动,pointerup 仍增加一条空 undo 并触发 CAS 保存;Tauri 自行维护 Shift toggle 后,Shift 单击唯一选中图层会意外清空选择。同一 `canvas.failed` 视图还可能把草稿保存或提交故障显示成生成重试。
|
||||
- 原因:延迟回包只与发起时 revision 比较,没有在落地时复核当前最高 revision;指针按下就 capture history,而不是等首次真实几何变化;宿主复制了共享 selection 规则;失败状态没有携带发生故障的 operation 类别。
|
||||
- 处理:generation progress、保存队列、生成/提交回包和延迟 `loadDraft` 统一用当前 scope、触发最低 revision 与回包当下草稿的单调门禁;同 revision 只允许完整相等回包。Tauri 指针和键盘选择复用 `resolveLayerPointerSelection`。pointerdown 只冻结快照,首次真实 move/resize/pan 才 capture 一次;零位移、未变选择和锁定图层不增加 undo、documentVersion 或草稿保存。
|
||||
- 失败边界:`canvas.failed` 必须携带 `generation / draft-save / asset-commit / recovery / cancellation`,只有 `generation` 失败显示“返回修改/重新确认”。保存/CAS 只重试或重载,提交/恢复只安全恢复或对账,取消故障只保留草稿继续编辑;初始恢复失败也不得进入生成重试。
|
||||
- 验证:用 deferred Promise 覆盖 r5/r6 逆序、保存与 progress 交错和 scope 切换;同时覆盖 Shift 单选自身、多选拖动、指针完整序列、零位移、首次有效移动只一条 history,以及五类失败的可访问名称与按钮集。
|
||||
|
||||
@@ -1001,7 +1001,7 @@ game-project/
|
||||
- 阶段五只接图片生成与图片精修,通过阶段二共享 `ImageCanvasGenerationPort` 调用客户端 Tauri adapter,并复用现有编辑器项目、统一生成队列、资产上传/换签、稳定引用解析和受控下载能力;不复制网站 `useImageCanvasGenerationWorkflow`,不新增角色动画、视频、音频、图集或平行生成系统。
|
||||
- 生成面板和确认摘要只操作本地状态;用户点击独立“确认生成”后才通过 `canvas.asset_generate` confirm 权限边界。本地生成意图建立时即分配稳定 `intentId/generationId/idempotencyKey`,取消确认则直接丢弃且零 External 调用;一旦提交,重复点击、重启和未知结果保持原身份,不能自动确认或换键重扣。
|
||||
- 主站网页画布继续使用当前平台登录态调用 `/api/editor/*`、`/api/assets/*` 与 `/api/runtime/external-generation/jobs/*`。AI 游戏创作 Tauri 客户端的图片、图片引用、视频、音效和背景音乐远端编辑固定调用 `/api/external/v1/*`,凭据只读取发布 AppData 私有 `editorApi.baseUrl/apiKey`;不实现登录后自动签发 Developer API Key,不把站内 Access Token 传给 Tauri 生成命令,也不把开发者 Key 打包进客户端或传入 WebView。两条入口继续由后端解析 owner、沿统一生成队列预扣/退款泥点并只在服务端读取供应商配置。
|
||||
- 调用模式、精确相对 endpoint、精确请求、prompt、idempotency key、operationId 和稳定产物引用只进入 `.agent/workbench/asset-canvas/generations/` 私有账本。本地参考图 direct-upload ticket 的 host/formFields/policy/signature/临时 Authorization 只存在内存;恢复账本只允许保存稳定 bucket/objectKey。API Key、Authorization、Provider URL、签名 URL和绝对路径不进入账本或项目事实;External 配置指纹变化不得借旧 Key 身份自动重放。草稿/manifest/event/日志只投影安全阶段与本地身份,远端 taskId 不写 manifest `source.taskId`。
|
||||
- 调用模式、精确相对 endpoint、精确请求、prompt、idempotency key、operationId 和稳定产物引用只进入 `.agent/workbench/asset-canvas/generations/` 私有账本。本地参考图 direct-upload ticket 的 host/formFields/policy/signature/临时 Authorization 只存在内存;恢复账本只允许保存稳定 bucket/objectKey。API Key、Authorization、Provider URL、签名 URL和绝对路径不进入账本或项目事实。当前账本用 `service-origin-v1` 绑定规范化 External base URL 的服务指纹,不把 Key 当作 operation 身份;确认面板只展示去除路径与凭据的服务 origin。升级前 Key-bound 指纹只在当前 Key 仍能验证时自动迁移,无法验证时在零网络动作前要求用户显式确认当前服务;确认后 `accepted/running` 只 GET 原 operation,`prepared` 只可精确重放冻结 endpoint、原始请求字节和原幂等键的 POST。草稿/manifest/event/日志只投影安全阶段与本地身份,远端 taskId 不写 manifest `source.taskId`。
|
||||
- 固定状态顺序为 `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。
|
||||
@@ -1015,3 +1015,13 @@ game-project/
|
||||
- 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 和普通聊天工作区状态把绝对路径收口为项目名称。
|
||||
|
||||
## 2026-08-11 Tauri 无限画布恢复与一致性收口
|
||||
|
||||
- 当前产品只从现有资源进入非破坏性编辑,“新增资源”保持禁用。图片进入 refine 画布,其他现役类型进入统一派生编辑壳;所有成功路径都追加新文件、新 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` 只读展示对账。读取失败保留错误并允许重试,任一操作后重读权威队列;项目切换后的迟到结果必须丢弃。modal 支持 Escape、初始焦点和关闭后焦点归还,窄屏操作按钮改为纵向全宽布局。
|
||||
- 草稿应用统一经过单调 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`,不得显示生成重试动作。
|
||||
|
||||
@@ -138,6 +138,15 @@ export type ImageCanvasGenerationProgress = {
|
||||
errorCode: string | null;
|
||||
};
|
||||
|
||||
export type ImageCanvasGenerationServiceIdentityConfirmation = {
|
||||
generationId: string;
|
||||
operationId: string | null;
|
||||
operationState: string;
|
||||
serviceOrigin: string;
|
||||
challenge: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
export type ImageCanvasGenerationCommitResult = {
|
||||
resourceId: string;
|
||||
assetId: string;
|
||||
@@ -230,7 +239,12 @@ export interface ImageCanvasGenerationPort {
|
||||
recoverImages(input: {
|
||||
scope: ImageCanvasHostScope;
|
||||
onProgress?: (progress: ImageCanvasGenerationProgress) => void;
|
||||
}): Promise<ImageCanvasHostResult<{ resumedGenerationIds: string[] }>>;
|
||||
}): Promise<
|
||||
ImageCanvasHostResult<{
|
||||
resumedGenerationIds: string[];
|
||||
serviceIdentityConfirmations: ImageCanvasGenerationServiceIdentityConfirmation[];
|
||||
}>
|
||||
>;
|
||||
removeBackground(input: {
|
||||
scope: ImageCanvasHostScope;
|
||||
expectedDraftRevision: number;
|
||||
|
||||
@@ -108,6 +108,20 @@ describe('shared image canvas core', () => {
|
||||
isMultiSelectGesture: true,
|
||||
}),
|
||||
).toEqual(['a', 'b']);
|
||||
expect(
|
||||
resolvePointerSelection({
|
||||
targetId: 'a',
|
||||
selectedIds: ['a'],
|
||||
isMultiSelectGesture: true,
|
||||
}),
|
||||
).toEqual(['a']);
|
||||
expect(
|
||||
resolvePointerSelection({
|
||||
targetId: 'a',
|
||||
selectedIds: ['a', 'b'],
|
||||
isMultiSelectGesture: true,
|
||||
}),
|
||||
).toEqual(['b']);
|
||||
const layers = [
|
||||
layer('a', { x: 10, y: 10 }),
|
||||
layer('b', { x: 140, y: 20 }),
|
||||
|
||||
Reference in New Issue
Block a user