完成客户端素材创作无限画布阶段三闭环
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Frontend tests (pull_request) Failing after 20s
Project CI / Native shell tests (pull_request) Successful in 11m31s

新增客户端共享画布宿主适配与独立创作界面
实现草稿并发保存、恢复副本和本地媒体安全校验
实现项目写锁内的可恢复素材提交与清单登记
补齐非破坏精修、幂等并发和故障恢复测试
同步阶段三技术方案与团队共享记忆
This commit is contained in:
2026-08-05 15:01:43 +08:00
parent a5a8a6b085
commit 02b16ae8ad
17 changed files with 6708 additions and 6 deletions
@@ -107,8 +107,17 @@ const rustSharedContractSource = fs.readFileSync(
);
const allowedUncalledTauriCommands = [
'chat_with_game_creator_agent',
'commit_local_project_asset',
'create_local_project_asset_canvas_draft',
'discard_local_project_asset_canvas_draft',
'open_game_creator_launcher_window',
'open_game_creator_workspace_window',
'read_local_project_asset_canvas_draft',
'read_local_project_asset_canvas_media',
'recover_local_project_asset_canvas_transactions',
'stage_local_project_asset_canvas_image',
'store_local_project_asset_canvas_media',
'update_local_project_asset_canvas_draft',
];
const sourceExtensions = new Set([
'.json',
+14
View File
@@ -1591,6 +1591,7 @@ dependencies = [
"base64 0.22.1",
"chromiumoxide",
"futures",
"getrandom 0.3.4",
"http",
"image",
"jsonschema",
@@ -2171,10 +2172,23 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"image-webp",
"moxcms",
"num-traits",
"png 0.18.1",
"tiff",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
@@ -19,8 +19,9 @@ agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" }
base64 = "0.22"
chromiumoxide = "0.9.1"
futures = "0.3"
getrandom = "0.3"
http = "1"
image = { version = "0.25", default-features = false, features = ["png"] }
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
jsonschema = { version = "0.49.3", default-features = false }
oxc_allocator = "0.143.0"
oxc_ast = "0.143.0"
@@ -232,6 +232,94 @@ 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 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 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) 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", 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", payload)
.map_err(|error| error.to_string())
});
}
Ok(execution.result)
}
#[tauri::command]
pub(crate) async fn control_agent_run(
app: tauri::AppHandle,
@@ -2209,6 +2209,15 @@ 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,
update_local_project_asset_canvas_draft,
store_local_project_asset_canvas_media,
stage_local_project_asset_canvas_image,
read_local_project_asset_canvas_media,
discard_local_project_asset_canvas_draft,
recover_local_project_asset_canvas_transactions,
commit_local_project_asset,
get_local_game_project_revision,
get_local_game_manifest
])
@@ -4,6 +4,7 @@ use similar::TextDiff;
use std::io::{Seek, SeekFrom};
mod agent_db;
mod asset_canvas;
mod checkpoint;
mod conversation;
mod export;
@@ -15,6 +16,7 @@ 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
@@ -0,0 +1,161 @@
.asset-canvas-surface {
position: relative;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
width: 100%;
height: min(800px, 100vh);
min-height: 540px;
overflow: hidden;
border-radius: 16px;
background: #e8e6e2;
color: #2d2926;
}
.asset-canvas-surface--loading {
place-items: center;
}
.asset-canvas-surface__toolbar {
z-index: 4;
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid rgba(63, 54, 48, 0.12);
background: rgba(252, 251, 249, 0.96);
}
.asset-canvas-surface button,
.asset-canvas-surface input,
.asset-canvas-surface select,
.asset-canvas-surface__import {
min-height: 34px;
border: 1px solid rgba(63, 54, 48, 0.18);
border-radius: 9px;
background: #fff;
color: inherit;
font: inherit;
}
.asset-canvas-surface button,
.asset-canvas-surface__import {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 11px;
cursor: pointer;
}
.asset-canvas-surface button:disabled {
cursor: default;
opacity: 0.45;
}
.asset-canvas-surface input,
.asset-canvas-surface select {
min-width: 0;
padding: 0 9px;
}
.asset-canvas-surface__import input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
.asset-canvas-surface__spacer {
flex: 1;
}
.asset-canvas-surface__save {
border-color: #c7653d !important;
background: #c7653d !important;
color: #fff !important;
}
.asset-canvas-surface__viewport {
grid-row: 2;
min-height: 0;
background-image: radial-gradient(rgba(45, 41, 38, 0.12) 1px, transparent 1px);
background-size: 18px 18px;
}
.asset-canvas-surface__resize-handle {
position: absolute;
right: -8px;
bottom: -8px;
width: 16px;
height: 16px;
border: 2px solid #fff;
border-radius: 50%;
background: #c7653d;
cursor: nwse-resize;
}
.asset-canvas-surface__viewport-tools {
position: absolute;
z-index: 5;
right: 14px;
bottom: 46px;
display: grid;
gap: 8px;
}
.asset-canvas-surface__zoom {
display: flex;
justify-content: flex-end;
gap: 4px;
}
.asset-canvas-surface__zoom button {
min-height: 30px;
background: rgba(255, 255, 255, 0.94);
}
.asset-canvas-surface .genarrative-image-canvas__minimap {
width: 132px;
height: 84px;
padding: 0;
overflow: hidden;
background: rgba(255, 255, 255, 0.94);
}
.asset-canvas-surface .genarrative-image-canvas__minimap-layer {
background: rgba(199, 101, 61, 0.32);
}
.asset-canvas-surface .genarrative-image-canvas__minimap-viewport {
border: 1px solid #c7653d;
}
.asset-canvas-surface__status {
z-index: 4;
display: flex;
min-height: 34px;
align-items: center;
gap: 12px;
padding: 0 12px;
border-top: 1px solid rgba(63, 54, 48, 0.12);
background: rgba(252, 251, 249, 0.96);
font-size: 12px;
}
@media (max-width: 760px) {
.asset-canvas-surface {
height: 100dvh;
min-height: 480px;
border-radius: 0;
}
.asset-canvas-surface__toolbar {
overflow-x: auto;
}
.asset-canvas-surface__toolbar input {
width: 112px;
flex: 0 0 auto;
}
}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,15 @@
# 决策记录
## 2026-08-05 无限画布阶段三以 Tauri 持久草稿和项目 mutation 事务形成独立图片闭环
- 决策:阶段三新增独立 `AssetCanvasSurface` 和 Tauri Host Adapter,直接消费共享 core/React/ports;前端状态固定为 `canvas.creating / editing / saving / recovering / failed`generation 明确使用 unsupported mock。本阶段不接资源总览入口、保存后自动选中或真实 AI,避免提前修改工作台状态合同。
- 草稿边界:`.agent/workbench/asset-canvas/` 使用持久 OS 文件锁和 draft revision CAS,不使用 React state、localStorage 或进程内 mutex 作为正式状态。主草稿损坏时,固定 recovery JSON 必须通过针对实际字节的 SHA-256、schema/身份和全部受控媒体复核才可恢复;连续保存产生的 revision 直接从 CAS 结果推进。
- 提交边界:正式提交复用项目 mutation 写锁,锁内按身份/revision 重检、prepared journal/ledger、最终图片安装、manifest、project revision、回读验证、ledger/草稿终态推进。`commitId/idempotencyKey` 与完整请求指纹绑定;同请求重放返回同一 asset/event,同 revision 竞争不得覆盖。释放锁后才发布至少一次 Tauri eventemit 失败不回滚已提交 manifest。
- 恢复边界:最终文件分为 absent、matches、mismatch。只有 absent 或摘要/大小/尺寸完全匹配时可自动回滚;mismatch 保留现场并标记 `reconciliation-required`。manifest after/revision before 前向补 revision,二者 after 时补 ledger、草稿和事件。只能删除能证明属于当前事务的新文件。
- 精修与异步边界:refine 源必须是 manifest 唯一登记且通过签名、完整解码、容量、普通文件、符号链接/硬链接门禁的图片;源 asset/文件永久保留,新 `canvas-<commitId>` 通过规范 resourceId 引用源。前端以 project/draft/session epoch 丢弃迟到 Promise/事件,以 eventId 去重 command/event 投影。
- 验证:阶段三 Surface `8/8`、共享 core/React `7/7`、AppSurface `351/351`、Rust 素材画布定向测试 `11/11`,并执行客户端 typecheck、Rust check、编码与 diff 门禁。
- 关联:`docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
## 2026-08-05 无限画布阶段二以共享包成为网站与 Tauri 唯一核心源码
- 决策:把现役网站画布的纯交互模型、history/viewport/stage hooks 和通用 React 容器迁到 `packages/image-canvas-core``packages/image-canvas-react`;原 `src/components/image-editor` 同名模型/hook 只保留 re-export。网站 Stage/World/Dock/Portal 已直接使用共享 React,Tauri 壳只建立共享源码编译入口和作用域 CSS,不在本阶段接素材创作业务闭环或修改资源总览。
@@ -14,6 +14,14 @@
- 关联:相关文件、文档、提交或 Issue
```
## 正式保存不能等待 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 错误。
@@ -928,3 +928,13 @@ game-project/
- 共享样式固定为 `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 未安装时前向补 revisionmanifest/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(...)` 警告,不影响测试结果。
+73 -5
View File
@@ -33,23 +33,88 @@ export type ImageCanvasHostResult<T> =
export type ImageCanvasMediaRef =
| { kind: 'project-asset'; assetId: string }
| {
kind: 'draft-media';
mediaId: string;
mediaType: 'image/png' | 'image/jpeg' | 'image/webp';
sha256: string;
byteLength: number;
pixelWidth: number;
pixelHeight: number;
}
| { kind: 'host-resource'; resourceId: string };
export type ImageCanvasDraftLayer = {
layerId: string;
resourceId: string;
title: string;
mediaRef: ImageCanvasMediaRef;
x: number;
y: number;
width: number;
height: number;
originalWidth: number;
originalHeight: number;
zIndex: number;
groupId: string | null;
hidden: boolean;
locked: boolean;
flipX: boolean;
flipY: boolean;
};
export type ImageCanvasDraftCanvas = {
viewport: { x: number; y: number; scale: number };
backgroundColor: string;
layers: ImageCanvasDraftLayer[];
selectedLayerIds: string[];
primarySelectedLayerId: string | null;
};
export type ImageCanvasDraft = {
schemaVersion: 'game-creator-asset-canvas-draft.v1';
draftId: string;
projectId: string;
intent: 'create' | 'refine';
sourceAssetId: string | null;
sourceResourceId: string | null;
revision: number;
hostRevision: string;
canvas: unknown;
status:
| 'editing'
| 'generating'
| 'commit-prepared'
| 'committed'
| 'cancelled'
| 'reconciliation-required';
canvas: ImageCanvasDraftCanvas;
generations: ImageCanvasGenerationRecord[];
pendingCommit: {
commitId: string;
idempotencyKey: string;
requestFingerprint: string;
} | null;
lastCommit: {
commitId: string;
idempotencyKey: string;
assetId: string;
eventId: string;
committedProjectRevision: number;
} | null;
createdAt: number;
updatedAt: number;
};
export type ImageCanvasGenerationRecord = {
generationId: string;
idempotencyKey: string;
status: 'pending' | 'running' | 'completed' | 'failed';
resourceIds: string[];
errorCode?: string | null;
status: 'accepted' | 'polling' | 'completed' | 'failed';
prompt: string;
operationId: string | null;
referenceResourceIds: string[];
outputMediaIds: string[];
errorCode: string | null;
createdAt: number;
updatedAt: number;
};
export type ImageCanvasHostInputImage = {
@@ -149,6 +214,9 @@ export interface ImageCanvasCompletionPort {
resourceId: string;
draftRevision: number;
hostRevision: string;
commitStatus?: 'committed' | 'already-committed';
manifest?: unknown;
eventId?: string;
}>
>;
}
+1
View File
@@ -84,6 +84,7 @@ export default defineConfig({
'apps/admin-web/src/**/*.test.ts',
'apps/admin-web/src/**/*.test.tsx',
'apps/ai-game-creator-shell/tests/**/*.test.ts',
'apps/ai-game-creator-shell/tests/**/*.test.tsx',
'miniprogram/**/*.test.js',
'scripts/**/*.test.ts',
'packages/shared/src/contracts/hostBridge.test.ts',