将视觉步骤切换为自动分离

前端仅调用一次 separate_ui,并完成 cut 图片登记、SpriteAsset 回填和 State 保存。

增加 sidecar 恢复选择面板,旧 bind_components 标记为未调用遗留命令。
This commit is contained in:
2026-09-09 20:51:18 +08:00
parent 5844afa14d
commit 7fb7d246c0
4 changed files with 280 additions and 48 deletions
@@ -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',
@@ -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, };
@@ -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 (
<section className="shrink-0 border-b border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) px-4 py-3">
@@ -56,6 +57,44 @@ export function WorkflowActionCard({
) : null}
</div>
</div>
<ThemedModal
open={workflow.separationRecovery !== null}
ariaLabel="恢复自动分离"
onClose={workflow.cancelSeparationRecovery}
closeOnBackdrop={false}
closeOnEscape={false}
panelClassName="w-full max-w-md rounded-2xl p-5 shadow-2xl"
>
<h3 className="m-0 text-base font-semibold"></h3>
<p className="mt-3 text-sm text-(--platform-text-soft)">
{' '}
{workflow.separationRecovery?.bound_node_count ?? 0}{' '}
</p>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
className="rounded-lg border border-(--platform-subpanel-border) px-3 py-1.5 text-xs"
onClick={workflow.cancelSeparationRecovery}
>
</button>
<button
type="button"
className="rounded-lg border border-orange-500 px-3 py-1.5 text-xs"
onClick={() => void workflow.restartSeparation()}
>
</button>
<button
type="button"
className="rounded-lg bg-orange-600 px-3 py-1.5 text-xs font-semibold text-white"
onClick={() => void workflow.continueSeparation()}
>
</button>
</div>
</ThemedModal>
</section>
);
}
@@ -76,8 +115,8 @@ function getStepAction(workflow: UiEditorWorkflowProjection) {
};
}
return {
label: '绑定视觉素材',
runningLabel: '绑定中…',
action: workflow.bindComponents,
label: '自动分离并绑定视觉素材',
runningLabel: '自动分离中…',
action: workflow.separateUi,
};
}
@@ -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<string | null>(null);
const [isBinding, setIsBinding] = useState(false);
const [bindingStatus, setBindingStatus] = useState<string | null>(null);
const [isSeparating, setIsSeparating] = useState(false);
const [separationStatus, setSeparationStatus] = useState<string | null>(null);
const [separationRecovery, setSeparationRecovery] =
useState<SeparationRecoveryDTO | null>(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<WorkflowCompletionNotice | null>(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<ReturnType<typeof prepareSpriteAssetBatch>> =
[];
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<SeparationDTO>('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<LocalImageImportResponse>(
'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<BindingDTO>('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<Component, { Image: unknown }> =>
'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<SeparationRecoveryDTO>(
'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);