From 7fb7d246c045fa4f1d5d1f28c89f8d7dd058453e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 9 Sep 2026 20:51:18 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=86=E8=A7=86=E8=A7=89=E6=AD=A5=E9=AA=A4?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E4=B8=BA=E8=87=AA=E5=8A=A8=E5=88=86=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端仅调用一次 separate_ui,并完成 cut 图片登记、SpriteAsset 回填和 State 保存。 增加 sidecar 恢复选择面板,旧 bind_components 标记为未调用遗留命令。 --- .../scripts/check-config.mjs | 2 + .../ui-editor/types/SeparationRecoveryDTO.ts | 3 + .../components/WorkflowActionCard.tsx | 51 +++- .../src/view/ui-editor/useUiEditorPage.ts | 272 +++++++++++++++--- 4 files changed, 280 insertions(+), 48 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/SeparationRecoveryDTO.ts diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 0149d953a..f2ef7b4b8 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -108,6 +108,8 @@ const rustSharedContractSource = fs.readFileSync( ); const allowedUncalledTauriCommands = [ 'append_direct_project_conversation_message', + // TODO: Remove the retired binding command after the legacy runtime path is removed. + 'bind_components', 'chat_with_game_creator_agent', 'check_ui_editor_font_glyph_coverage', 'create_ui_design_resource', diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/SeparationRecoveryDTO.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/SeparationRecoveryDTO.ts new file mode 100644 index 000000000..38c5bb8b2 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/SeparationRecoveryDTO.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SeparationRecoveryDTO = { exists: boolean, bound_node_count: number, problematic_node_count: number, has_pending_tree: boolean, }; diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowActionCard.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowActionCard.tsx index c5bf945e4..695c10163 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowActionCard.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowActionCard.tsx @@ -1,3 +1,4 @@ +import { ThemedModal } from '../../../components/modal/ThemedModal'; import type { UiEditorWorkflowProjection } from '../useUiEditorPage'; export function WorkflowActionCard({ @@ -10,19 +11,19 @@ export function WorkflowActionCard({ (workflow.activeStep === 'reference-analysis' && workflow.isSuggesting) || (workflow.activeStep === 'structure-recognition' && workflow.isRecognizing) || - (workflow.activeStep === 'visual-binding' && workflow.isBinding); + (workflow.activeStep === 'visual-binding' && workflow.isSeparating); const status = workflow.activeStep === 'reference-analysis' ? workflow.suggestionStatus : workflow.activeStep === 'structure-recognition' ? workflow.recognitionStatus - : workflow.bindingStatus; + : workflow.separationStatus; const hasRun = workflow.activeStep === 'reference-analysis' ? workflow.hasSuggested : workflow.activeStep === 'structure-recognition' ? workflow.hasRecognized - : workflow.hasBound; + : workflow.hasSeparated; return (
@@ -56,6 +57,44 @@ export function WorkflowActionCard({ ) : null} + +

发现未完成的自动分离

+

+ 上次分离留下了可恢复状态(已登记{' '} + {workflow.separationRecovery?.bound_node_count ?? 0}{' '} + 个节点)。请选择继续上次分离,或开始新的分离。 +

+
+ + + +
+
); } @@ -76,8 +115,8 @@ function getStepAction(workflow: UiEditorWorkflowProjection) { }; } return { - label: '绑定视觉素材', - runningLabel: '绑定中…', - action: workflow.bindComponents, + label: '自动分离并绑定视觉素材', + runningLabel: '自动分离中…', + action: workflow.separateUi, }; } diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index a580a652d..4ff18bcf9 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -2,7 +2,6 @@ import { invoke } from '@tauri-apps/api/core'; import { useCallback, useEffect, useMemo, useState } from 'react'; import type { ImportedAsset } from '../../components/AssetImporter'; -import { applyBindingResult } from '../../features/ui-editor/binding'; import { prepareDesignImageBatch, prepareFontAssetBatch, @@ -15,7 +14,6 @@ import { type StageStatusField, } from '../../features/ui-editor/stageStatusOverview'; import { collectUiNodeIds } from '../../features/ui-editor/treeUtils'; -import type { BindingDTO } from '../../features/ui-editor/types/BindingDTO'; import type { ChildrenDisplayMode } from '../../features/ui-editor/types/ChildrenDisplayMode'; import type { Component } from '../../features/ui-editor/types/Component'; import type { FontAssetId } from '../../features/ui-editor/types/FontAssetId'; @@ -23,6 +21,8 @@ import type { MergeDTO } from '../../features/ui-editor/types/MergeDTO'; import type { Node as UiNode } from '../../features/ui-editor/types/Node'; import type { NodeId } from '../../features/ui-editor/types/NodeId'; import type { RecognitionDTO } from '../../features/ui-editor/types/RecognitionDTO'; +import type { SeparationDTO } from '../../features/ui-editor/types/SeparationDTO'; +import type { SeparationRecoveryDTO } from '../../features/ui-editor/types/SeparationRecoveryDTO'; import type { SpriteAssetId } from '../../features/ui-editor/types/SpriteAssetId'; import type { SpriteBorder } from '../../features/ui-editor/types/SpriteBorder'; import type { State } from '../../features/ui-editor/types/State'; @@ -37,6 +37,7 @@ import { } from '../../features/ui-editor/uiDesignStateStore'; import { applyUiDesignSuggestions } from '../../features/ui-editor/uiDesignSuggestions'; import { useUiEditorFontFaces } from '../../features/ui-editor/useUiEditorFontFaces'; +import { addSpriteAssetsToState } from '../../features/ui-editor/useUiEditorState'; import { EMPTY_UI_EDITOR_STATE, type NodeLayoutPatch, @@ -70,7 +71,15 @@ import { } from './model'; import { useUiEditorNodeFocus } from './useUiEditorNodeFocus'; -const ASSET_BATCH_SIZE = 5; +const SEPARATION_IMPORT_BATCH_SIZE = 100; + +type LocalImageImportResponse = { + assets: Array<{ id: string; localPath: string; assetKind?: string | null }>; +}; + +function normalizeProjectRelativePath(path: string): string { + return path.replaceAll('\\', '/').replace(/^\/+/, ''); +} type StatusFieldHighlight = { nodeId: NodeId; @@ -250,11 +259,13 @@ export function useUiEditorSession( ); const [isMerging, setIsMerging] = useState(false); const [mergeStatus, setMergeStatus] = useState(null); - const [isBinding, setIsBinding] = useState(false); - const [bindingStatus, setBindingStatus] = useState(null); + const [isSeparating, setIsSeparating] = useState(false); + const [separationStatus, setSeparationStatus] = useState(null); + const [separationRecovery, setSeparationRecovery] = + useState(null); const [hasSuggested, setHasSuggested] = useState(false); const [hasRecognized, setHasRecognized] = useState(false); - const [hasBound, setHasBound] = useState(false); + const [hasSeparated, setHasSeparated] = useState(false); const [completionNotice, setCompletionNotice] = useState(null); @@ -393,7 +404,8 @@ export function useUiEditorSession( image.metadata.role === 'Page' && !isSlaveToDescendant(images, id as UIDesignImageId, activeImageId), ); - const isAiRunning = isSuggesting || isRecognizing || isBinding || isMerging; + const isAiRunning = + isSuggesting || isRecognizing || isMerging || isSeparating; const isWorkflowBusy = isAiRunning || isSaving || isGenerating || isLoading || editor.isLocked; const stateSignature = JSON.stringify(editor.state); @@ -1061,53 +1073,227 @@ export function useUiEditorSession( } } - async function bindComponents() { - if (isBinding || isWorkflowBusy) return; + async function runSeparationWorkflow() { + if (!resourceId) return; + setIsSeparating(true); + setSeparationStatus(null); setCompletionNotice(null); - setBindingStatus(null); - setIsBinding(true); + let preparedSprites: Awaited> = + []; try { + let backfillErrors: string[] = []; + let separationResult: SeparationDTO | null = null; await editor.runWithStateLocked(async (snapshot) => { - const allSpriteIds = Object.keys(snapshot.sprite_assets); - const batches: string[][] = []; + const result = await invoke('separate_ui', { + projectPath, + assetId: resourceId, + state: snapshot, + }); + separationResult = result; + + const uniquePaths = [ + ...new Set( + result.bound_nodes.map((bound) => + normalizeProjectRelativePath(bound.cut_image_path), + ), + ), + ]; + const importedByPath = new Map< + string, + { id: string; localPath: string; assetKind: string | null } + >(); for ( let index = 0; - index < allSpriteIds.length; - index += ASSET_BATCH_SIZE + index < uniquePaths.length; + index += SEPARATION_IMPORT_BATCH_SIZE ) { - batches.push(allSpriteIds.slice(index, index + ASSET_BATCH_SIZE)); + const relativePaths = uniquePaths.slice( + index, + index + SEPARATION_IMPORT_BATCH_SIZE, + ); + const imported = await invoke( + 'import_local_project_image_assets', + { projectPath, relativePaths }, + ); + for (const [assetIndex, asset] of imported.assets.entries()) { + const normalizedAsset = { + id: asset.id, + localPath: normalizeProjectRelativePath(asset.localPath), + assetKind: asset.assetKind ?? null, + }; + // The importer may copy a sidecar file into assets/uploads and + // therefore return a different localPath. Keep both identities: + // the cut path is the separation contract, while the returned + // path is the SpriteAsset resource path. + importedByPath.set(normalizedAsset.localPath, normalizedAsset); + const requestedPath = relativePaths[assetIndex]; + if (requestedPath) { + importedByPath.set( + normalizeProjectRelativePath(requestedPath), + normalizedAsset, + ); + } + } } - if (batches.length === 0) batches.push([]); - let current = snapshot; - for (const [index, spriteIds] of batches.entries()) { - setBindingStatus(`绑定组件中(${index + 1}/${batches.length})…`); - const result = await invoke('bind_components', { - projectPath, - state: current, - spriteIds, - }); - current = applyBindingResult(current, result); - editor.replaceState(current, { - history: index < batches.length - 1 ? 'skip' : 'record', - }); + + const missingImports = uniquePaths.filter( + (path) => !importedByPath.has(path), + ); + backfillErrors = missingImports.map( + (path) => `未能登记分离图片:${path}`, + ); + const importedAssets: ImportedAsset[] = [ + ...new Map( + [...importedByPath.values()].map((asset) => [asset.id, asset]), + ).values(), + ]; + preparedSprites = await prepareSpriteAssetBatch( + projectPath, + importedAssets, + ); + const spriteById = new Map( + preparedSprites.map((item) => [ + item.resource.asset_id, + item.resource, + ]), + ); + const spriteByPath = new Map( + [...importedByPath.entries()].flatMap(([path, asset]) => { + const sprite = spriteById.get(asset.id); + return sprite ? [[path, sprite] as const] : []; + }), + ); + const added = addSpriteAssetsToState( + snapshot, + preparedSprites.map((item) => item.resource), + ); + if (!added.ok) { + throw new Error(uiEditorOperationError(added.reason)); } + const next = added.value; + for (const bound of result.bound_nodes) { + const path = normalizeProjectRelativePath(bound.cut_image_path); + const sprite = spriteByPath.get(path); + if (!sprite) { + backfillErrors.push( + `节点 ${bound.node_id} 缺少已登记的分离图片:${path}`, + ); + continue; + } + const location = next.ui_trees + .map((tree) => findUiNodeLocation(tree.root, bound.node_id)) + .find((candidate) => candidate !== null); + if (!location) { + backfillErrors.push(`节点 ${bound.node_id} 已不存在,素材已保留`); + continue; + } + const imageComponent = location.node.components.find( + (component): component is Extract => + 'Image' in component && component.Image.target_graphic === null, + ); + if (!imageComponent || !('Image' in imageComponent)) { + backfillErrors.push( + `节点 ${bound.node_id} 没有可回填的未绑定 Image 组件,素材已保留`, + ); + continue; + } + imageComponent.Image.target_graphic = sprite.asset_id; + } + editor.replaceState(next); + }); + + setPreviewUrls((current) => ({ + ...current, + ...Object.fromEntries( + preparedSprites.map((item) => [ + item.resource.asset_id, + item.previewUrl, + ]), + ), + })); + if (separationResult === null) throw new Error('自动分离没有返回结果'); + const completedResult = separationResult as SeparationDTO; + if (!(await save())) { + throw new Error( + '分离结果已写入编辑器,但 State 保存失败;sidecar 已保留,可继续恢复。', + ); + } + if (backfillErrors.length > 0) { reportWorkflowCompletion( 'visual-binding', - 'success', - `视觉素材绑定完成:已处理 ${batches.length}/${batches.length} 个批次`, - setBindingStatus, + 'failure', + `自动分离已完成,但有 ${backfillErrors.length} 项未能回填;已登记素材并保留恢复状态。\n${backfillErrors.join('\n')}`, + setSeparationStatus, ); - setHasBound(true); + return; + } + await invoke('finalize_separation', { + projectPath, + assetId: resourceId, }); + setHasSeparated(true); + reportWorkflowCompletion( + 'visual-binding', + 'success', + `自动分离完成:${completedResult.bound_nodes.length} 个已绑定,${completedResult.problematic_nodes.length} 个待处理。`, + setSeparationStatus, + ); } catch (cause) { reportWorkflowCompletion( 'visual-binding', 'failure', cause instanceof Error ? cause.message : String(cause), - setBindingStatus, + setSeparationStatus, ); } finally { - setIsBinding(false); + setIsSeparating(false); + } + } + + async function separateUi() { + if ( + isSeparating || + isWorkflowBusy || + separationRecovery !== null || + !resourceId + ) { + return; + } + try { + const recovery = await invoke( + 'inspect_separation_recovery', + { projectPath, assetId: resourceId }, + ); + if (recovery.exists) { + setSeparationRecovery(recovery); + return; + } + await runSeparationWorkflow(); + } catch (cause) { + setSeparationStatus( + cause instanceof Error ? cause.message : String(cause), + ); + } + } + + async function continueSeparation() { + setSeparationRecovery(null); + await runSeparationWorkflow(); + } + + async function restartSeparation() { + if (!resourceId) return; + setSeparationRecovery(null); + try { + await invoke('discard_separation_recovery', { + projectPath, + assetId: resourceId, + }); + await runSeparationWorkflow(); + } catch (cause) { + setSeparationStatus( + cause instanceof Error ? cause.message : String(cause), + ); } } @@ -1244,17 +1430,15 @@ export function useUiEditorSession( selectedNodeId, focusRequest, operations: { - isBinding, isMerging, isRecognizing, isSuggesting, - bindingStatus, mergeStatus, recognitionStatus, suggestionStatus, }, checkPrerequisites, - bindComponents, + separateUi, mergeUi, recognizeUi, suggestUiDesignSemantics, @@ -1367,11 +1551,15 @@ export function useUiEditorSession( hasRecognized, recognitionStatus, recognizeUi, - isBinding, - hasBound, - bindingStatus, + hasSeparated, completionNotice, - bindComponents, + separateUi, + isSeparating, + separationStatus, + separationRecovery, + continueSeparation, + restartSeparation, + cancelSeparationRecovery: () => setSeparationRecovery(null), requestStepChange, continueToNextStep: () => { if (nextStep) requestStepChange(nextStep);