收敛 UI Editor 节点状态
Project CI / Repository checks (pull_request) Failing after 14s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / Native shell tests (pull_request) Failing after 1m49s
Project CI / Frontend tests (pull_request) Failing after 57s

统一 StageStatus 为 NoProblem、NeedReview 和 Blocked

移除 Pending 与 Passed 的前后端、LLM 草稿及持久化夹具用法

补充旧状态拒绝测试并更新 UI Editor 技术方案
This commit is contained in:
2026-08-19 15:24:51 +08:00
parent 34511244c8
commit 995db645e2
19 changed files with 90 additions and 90 deletions
@@ -23,7 +23,7 @@ const SYSTEM_PROMPT: &str = r#"
* 每个 change 的 components 是该节点完整的新渲染栈,空数组表示明确清空。数组顺序从底到顶渲染。
* 对每个 Component,直接完整返回其全部参数.
* 有任何困难或者不确定把状态设为 NeedReview,说明中文原因。
* 纯结构节点可以返回空数组并标为 Passed
* 纯结构节点可以返回空数组并标为 NoProblem
* 面向用户的 reason 使用中文。
"#;
@@ -31,7 +31,7 @@ const SYSTEM_PROMPT: &str = r#"
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
enum DraftStatus {
Passed,
NoProblem,
NeedReview(String),
}
@@ -134,7 +134,7 @@ fn validate_and_materialize(
}
let components = change.components;
let components_status = match change.components_status {
DraftStatus::Passed => StageStatus::Passed,
DraftStatus::NoProblem => StageStatus::NoProblem,
DraftStatus::NeedReview(reason) if reason.trim().is_empty() => {
return Err("组件待审状态必须包含原因".to_string())
}
@@ -286,7 +286,7 @@ mod tests {
let unapproved = BindingChangeDraft {
node_id: id("other"),
components: Vec::new(),
components_status: DraftStatus::Passed,
components_status: DraftStatus::NoProblem,
};
assert!(validate_and_materialize(vec![unapproved], &editable, &sprites).is_err());
@@ -300,7 +300,7 @@ mod tests {
},
},
)],
components_status: DraftStatus::Passed,
components_status: DraftStatus::NoProblem,
};
assert!(validate_and_materialize(vec![foreign], &editable, &sprites).is_err());
}
@@ -312,7 +312,7 @@ mod tests {
vec![BindingChangeDraft {
node_id: id("editable"),
components: Vec::new(),
components_status: DraftStatus::Passed,
components_status: DraftStatus::NoProblem,
}],
&editable,
&HashSet::new(),
@@ -320,7 +320,7 @@ mod tests {
.expect("valid changed-only clear");
assert_eq!(result.changes.len(), 1);
assert!(result.changes[0].components.is_empty());
assert_eq!(result.changes[0].components_status, StageStatus::Passed);
assert_eq!(result.changes[0].components_status, StageStatus::NoProblem);
}
#[test]
@@ -287,8 +287,8 @@ mod materialize {
metadata: NodeMetadata {
name: container_name,
description: container_description,
layout_status: StageStatus::Passed,
components_status: StageStatus::Passed,
layout_status: StageStatus::NoProblem,
components_status: StageStatus::NoProblem,
allow_llm_edit_layout: true,
allow_llm_edit_component: true,
source: NodeSource::Llm,
@@ -415,8 +415,8 @@ mod tests {
metadata: NodeMetadata {
name: id.to_string(),
description: String::new(),
layout_status: StageStatus::Passed,
components_status: StageStatus::Passed,
layout_status: StageStatus::NoProblem,
components_status: StageStatus::NoProblem,
allow_llm_edit_layout: true,
allow_llm_edit_component: true,
source: NodeSource::Human,
@@ -459,7 +459,7 @@ mod tests {
result.root.children_display_mode,
ChildrenDisplayMode::Exclusive
);
assert_eq!(result.root.metadata.components_status, StageStatus::Passed);
assert_eq!(result.root.metadata.components_status, StageStatus::NoProblem);
assert_eq!(result.root.children.len(), 2);
}
}
@@ -258,7 +258,7 @@ fn convert_node(
transform.set_resolved_rect(&parent_rect, target_rect);
let status = if blocked_reasons.is_empty() {
match &source.confidence {
Confidence::Confident => StageStatus::Passed,
Confidence::Confident => StageStatus::NoProblem,
Confidence::UnSure(reason) => StageStatus::NeedReview(reason.clone()),
}
} else {
@@ -276,7 +276,7 @@ fn convert_node(
name: source.name.clone(),
description: source.description.clone(),
layout_status: status,
components_status: StageStatus::Passed,
components_status: StageStatus::NoProblem,
allow_llm_edit_layout: true,
allow_llm_edit_component: true,
source: NodeSource::Llm,
@@ -440,7 +440,7 @@ mod tests {
converted.layout.transform.resolve(&root_rect),
UIRect::new(Point2::new(50.0, 25.0), Vector2::new(100.0, 50.0)),
);
assert_eq!(converted.metadata.components_status, StageStatus::Passed);
assert_eq!(converted.metadata.components_status, StageStatus::NoProblem);
}
#[test]
@@ -677,8 +677,8 @@ pub(crate) async fn recognize_ui_impl(
metadata: NodeMetadata {
name: "页面根节点".to_string(),
description: String::new(),
layout_status: StageStatus::Passed,
components_status: StageStatus::Passed,
layout_status: StageStatus::NoProblem,
components_status: StageStatus::NoProblem,
allow_llm_edit_layout: true,
allow_llm_edit_component: true,
source: NodeSource::System,
@@ -27,12 +27,23 @@ pub enum NodeSource {
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub enum StageStatus {
Pending,
Passed,
NoProblem,
NeedReview(String), // reason inside
Blocked(String), // reason inside
}
#[cfg(test)]
mod tests {
use super::StageStatus;
#[test]
fn rejects_retired_status_values() {
for value in ["\"Pending\"", "\"Passed\""] {
assert!(serde_json::from_str::<StageStatus>(value).is_err());
}
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub struct NodeMetadata {
@@ -757,8 +757,8 @@ mod tests {
"metadata": {
"name": "页面根节点",
"description": "",
"layout_status": "Passed",
"components_status": "Passed",
"layout_status": "NoProblem",
"components_status": "NoProblem",
"allow_llm_edit_layout": true,
"allow_llm_edit_component": true,
"source": "System"
@@ -783,8 +783,8 @@ mod tests {
"metadata": {
"name": "拖拽节点",
"description": "",
"layout_status": "Passed",
"components_status": "Passed",
"layout_status": "NoProblem",
"components_status": "NoProblem",
"allow_llm_edit_layout": true,
"allow_llm_edit_component": true,
"source": "Human"
@@ -934,8 +934,8 @@ mod tests {
"metadata": {
"name": "根节点",
"description": "",
"layout_status": "Pending",
"components_status": "Pending",
"layout_status": "NoProblem",
"components_status": "NoProblem",
"allow_llm_edit_layout": true,
"allow_llm_edit_component": true,
"source": "System"
@@ -1128,8 +1128,8 @@ mod tests {
"metadata": {
"name": "根节点",
"description": "",
"layout_status": "Pending",
"components_status": "Pending",
"layout_status": "NoProblem",
"components_status": "NoProblem",
"allow_llm_edit_layout": false,
"allow_llm_edit_component": false,
"source": "System"
@@ -145,37 +145,27 @@ export function validateLayoutReviewPrerequisites(
function layoutStatusIssue(
status: StageStatus,
): UiEditorPrerequisiteIssue | null {
switch (status) {
case 'Pending':
return { code: 'layout-pending', message: '存在尚未识别的节点布局' };
case 'Blocked':
return { code: 'layout-blocked', message: '存在被阻塞的节点结果' };
case 'Passed':
return null;
default:
return {
code: 'layout-needs-review',
message: status.NeedReview || '存在需要人工审阅的节点结果',
};
if (status === 'NoProblem') return null;
if ('Blocked' in status) {
return { code: 'layout-blocked', message: '存在被阻塞的节点结果' };
}
return {
code: 'layout-needs-review',
message: status.NeedReview || '存在需要人工审阅的节点结果',
};
}
function componentStatusIssue(
status: StageStatus,
): UiEditorPrerequisiteIssue | null {
switch (status) {
case 'Pending':
return { code: 'components-pending', message: '存在尚未绑定的节点组件' };
case 'Blocked':
return { code: 'components-blocked', message: '存在被阻塞的节点结果' };
case 'Passed':
return null;
default:
return {
code: 'components-needs-review',
message: status.NeedReview || '存在需要人工审阅的节点结果',
};
if (status === 'NoProblem') return null;
if ('Blocked' in status) {
return { code: 'components-blocked', message: '存在被阻塞的节点结果' };
}
return {
code: 'components-needs-review',
message: status.NeedReview || '存在需要人工审阅的节点结果',
};
}
function visitNodes(
@@ -17,7 +17,7 @@ export type UiTreeNodeTarget = {
export type StageStatusOverview = {
total: number;
needsAttention: number;
passed: number;
noProblem: number;
blocked: number;
};
@@ -50,14 +50,14 @@ export function getStageStatusOverview(
const overview: StageStatusOverview = {
total: 0,
needsAttention: 0,
passed: 0,
noProblem: 0,
blocked: 0,
};
for (const { node } of collectUiTreeNodeTargets(uiTrees)) {
const status = node.metadata[field];
overview.total += 1;
if (status === 'Passed') overview.passed += 1;
if (status === 'NoProblem') overview.noProblem += 1;
if (isBlocked(status)) overview.blocked += 1;
if (isBlocked(status) || isNeedReview(status)) {
overview.needsAttention += 1;
@@ -1,4 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type StageStatus =
'Pending' | 'Passed' | { NeedReview: string } | { Blocked: string };
'NoProblem' | { NeedReview: string } | { Blocked: string };
@@ -132,8 +132,8 @@ function createPageRoot(state: State): Node {
metadata: {
name: '页面根节点',
description: '',
layout_status: 'Passed',
components_status: 'Passed',
layout_status: 'NoProblem',
components_status: 'NoProblem',
allow_llm_edit_layout: true,
allow_llm_edit_component: true,
source: 'System',
@@ -163,8 +163,8 @@ function createHumanNode(state: State): Node {
metadata: {
name: '新节点',
description: '',
layout_status: 'Passed',
components_status: 'Passed',
layout_status: 'NoProblem',
components_status: 'NoProblem',
allow_llm_edit_layout: true,
allow_llm_edit_component: true,
source: 'Human',
@@ -63,8 +63,8 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
metadata: {
name: 'UI Trees',
description: '',
layout_status: 'Passed',
components_status: 'Passed',
layout_status: 'NoProblem',
components_status: 'NoProblem',
allow_llm_edit_layout: false,
allow_llm_edit_component: false,
source: 'System',
@@ -839,11 +839,10 @@ function NodeStageSelect({
if (readOnly) return;
const next = event.target.value;
if (next === 'NeedReview' || next === 'Blocked') return;
onChange(next === 'Pending' ? 'Pending' : 'Passed');
onChange('NoProblem');
}}
>
<option value="Pending"></option>
<option value="Passed"></option>
<option value="NoProblem"></option>
{kind === 'NeedReview' ? (
<option value="NeedReview"></option>
) : null}
@@ -53,7 +53,7 @@ export function RecognitionOverview({
disabled={overview.needsAttention === 0}
onClick={attentionCycle.focusNext}
/>
<OverviewValue label="已通过" value={overview.passed} />
<OverviewValue label="无问题" value={overview.noProblem} />
<OverviewAction
label="必须修复"
value={overview.blocked}
@@ -40,7 +40,7 @@ function text(font: 'SystemFont' | { Bound: string }): Component {
function node(
id: string,
components: Component[],
componentsStatus: Node['metadata']['components_status'] = 'Passed',
componentsStatus: Node['metadata']['components_status'] = 'NoProblem',
children: Node[] = [],
): Node {
return {
@@ -61,7 +61,7 @@ function node(
metadata: {
name: id,
description: '',
layout_status: 'Passed',
layout_status: 'NoProblem',
components_status: componentsStatus,
allow_llm_edit_layout: true,
allow_llm_edit_component: true,
@@ -93,7 +93,7 @@ const trees: UITree[] = [
root: node(
'root',
[image('validSprite'), text({ Bound: 'font-id' })],
'Passed',
'NoProblem',
[
node('review', [image(null)], { NeedReview: '确认素材' }),
node('blocked', [text('SystemFont')], { Blocked: '素材绑定失败' }),
@@ -32,7 +32,7 @@ function node(id: string, status: StageStatus, children: Node[] = []): Node {
name: id,
description: '',
layout_status: status,
components_status: 'Pending',
components_status: 'NoProblem',
allow_llm_edit_layout: true,
allow_llm_edit_component: true,
source: 'System',
@@ -46,14 +46,14 @@ function node(id: string, status: StageStatus, children: Node[] = []): Node {
const trees: UITree[] = [
{
src_ui_design: 'page-a',
root: node('root-a', 'Passed', [
root: node('root-a', 'NoProblem', [
node('review-a', { NeedReview: '请检查' }),
node('blocked-a', { Blocked: '节点范围越界' }),
]),
},
{
src_ui_design: 'page-b',
root: node('root-b', 'Pending', [node('passed-b', 'Passed')]),
root: node('root-b', 'NoProblem', [node('no-problem-b', 'NoProblem')]),
},
];
@@ -62,7 +62,7 @@ describe('stageStatusOverview', () => {
expect(getStageStatusOverview(trees, 'layout_status')).toEqual({
total: 5,
needsAttention: 2,
passed: 2,
noProblem: 3,
blocked: 1,
});
});
@@ -73,7 +73,7 @@ describe('stageStatusOverview', () => {
'review-a',
'blocked-a',
'root-b',
'passed-b',
'no-problem-b',
]);
});
@@ -336,7 +336,7 @@ describe('UiEditorPage', () => {
act(() => {
result.current.highlightStatusField('components_status');
result.current.setNodeMetadata({ components_status: 'Passed' });
result.current.setNodeMetadata({ components_status: 'NoProblem' });
});
expect(result.current.highlightedStatusField).toBeNull();
});
@@ -28,8 +28,8 @@ function node(id: string, children: UiNode[] = []): UiNode {
metadata: {
name: id,
description: '',
layout_status: 'Passed',
components_status: 'Passed',
layout_status: 'NoProblem',
components_status: 'NoProblem',
allow_llm_edit_layout: true,
allow_llm_edit_component: true,
source: 'System',
@@ -69,8 +69,8 @@ function nodeWithSprite(id: string): Node {
metadata: {
name: 'Image',
description: '',
layout_status: 'Passed',
components_status: 'Pending',
layout_status: 'NoProblem',
components_status: 'NoProblem',
allow_llm_edit_layout: true,
allow_llm_edit_component: true,
source: 'Llm',
@@ -106,8 +106,8 @@ function pageRoot(id: string, children: Node[] = []): Node {
metadata: {
name: id,
description: '',
layout_status: 'Passed',
components_status: 'Pending',
layout_status: 'NoProblem',
components_status: 'NoProblem',
allow_llm_edit_layout: true,
allow_llm_edit_component: true,
source: 'System',
@@ -250,8 +250,8 @@ describe('useUiEditorState', () => {
expect.objectContaining({
root: expect.objectContaining({
metadata: expect.objectContaining({
layout_status: 'Passed',
components_status: 'Passed',
layout_status: 'NoProblem',
components_status: 'NoProblem',
}),
}),
}),
@@ -279,8 +279,8 @@ describe('useUiEditorState', () => {
expect(
result.current.state.ui_trees[0]!.root.children[0]?.metadata,
).toMatchObject({
layout_status: 'Passed',
components_status: 'Passed',
layout_status: 'NoProblem',
components_status: 'NoProblem',
});
});
@@ -427,8 +427,8 @@ describe('useUiEditorState', () => {
metadata: {
name: '页面根节点',
description: '',
layout_status: 'Passed',
components_status: 'Pending',
layout_status: 'NoProblem',
components_status: 'NoProblem',
allow_llm_edit_layout: true,
allow_llm_edit_component: true,
source: 'System',
@@ -27,8 +27,8 @@ function node(id: string, children: UiNode[] = []): UiNode {
metadata: {
name: id,
description: '',
layout_status: 'Passed',
components_status: 'Passed',
layout_status: 'NoProblem',
components_status: 'NoProblem',
allow_llm_edit_layout: true,
allow_llm_edit_component: true,
source: 'System',
@@ -32,7 +32,7 @@ UI Editor Inspector 的全局只读状态唯一来源是 `controller.editor.isLo
## 2026-08-18 UI Editor 结构识别、合并与增量导入边界
UI Editor 当前把“识别界面结构”定义为结构草稿阶段,而不是完整视觉还原阶段。识别 DTO 只负责输出节点层级、几何、名称、描述和置信度;节点组件暂为空,由后续“绑定视觉素材”阶段补齐 Image / Text 组件。`applyRecognitionResult` 可以整体替换当前 `ui_trees`,但该替换只代表结构结果,不能宣称已经保留截图中的视觉内容;组件状态继续为 `Pending`,前置检查负责阻止跳过绑定阶段。
UI Editor 当前把“识别界面结构”定义为结构草稿阶段,而不是完整视觉还原阶段。识别 DTO 只负责输出节点层级、几何、名称、描述和置信度;节点组件暂为空,由后续“绑定视觉素材”阶段补齐 Image / Text 组件。`applyRecognitionResult` 可以整体替换当前 `ui_trees`,但该替换只代表结构结果,不能宣称已经保留截图中的视觉内容;组件状态使用 `NoProblem`,前置检查仍会根据空组件和素材绑定情况阻止跳过绑定阶段。
多图合并使用 LLM 返回的语义投影树。`Simple``Merged` 只列出希望进入结果树的原始节点;未被计划引用的源节点表示本次合并判定为冗余或不属于目标公共结构,允许被丢弃,不要求 `used_original_ids` 覆盖全部输入节点。重复 ID 和未知 ID 仍然是错误;省略不是隐式复制或随机删除,而是合并计划的正式语义。
@@ -1142,7 +1142,7 @@ game-project/
## 2026-08-18 UI Editor 新建节点组件状态
- 所有非组件绑定流程创建的 UI Editor 节点,`components_status``layout_status` 一致初始化为 `Passed`,覆盖前端新建页面根 / 人工节点,以及 Rust 结构识别与合并产生的节点。组件绑定命令仍只对 LLM 显式返回的节点写入其 `Passed``NeedReview` 结果;未返回的节点状态不变。
- 所有非组件绑定流程创建的 UI Editor 节点,`components_status``layout_status` 一致初始化为 `NoProblem`,覆盖前端新建页面根 / 人工节点,以及 Rust 结构识别与合并产生的节点。组件绑定命令仍只对 LLM 显式返回的节点写入其 `NoProblem``NeedReview` 结果;未返回的节点状态不变。
## 2026-08-18 UI Editor 节点预览可见性