生成 UI 设计代码并接入编辑器按钮

新增 State 到 JS 模块的生成与原子写入

接入保存并生成代码按钮及状态反馈

统一树节点元数据与 ui-node-id 标记

暂置旧 UI HTML 工具为空实现
This commit is contained in:
2026-09-01 13:19:29 +08:00
parent 43f64b3e70
commit 5eea689537
12 changed files with 376 additions and 70 deletions
@@ -2177,9 +2177,7 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec<String> {
fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec<String> {
let sources = direct_codex_game_outputs(root)
.into_iter()
.filter_map(|(relative_path, _, _)| {
std::fs::read_to_string(root.join(relative_path)).ok()
})
.filter_map(|(relative_path, _, _)| std::fs::read_to_string(root.join(relative_path)).ok())
.collect::<Vec<_>>();
let mut available_paths = Vec::new();
if direct_taonier_art_base_is_valid(root) {
@@ -2267,9 +2265,7 @@ fn direct_browser_evidence_needs_art_repair(
fn direct_game_output_completion_error(root: &Path) -> Option<String> {
let entry = agent_runtime_game_entry_relative_path(root);
if !root.join(entry).is_file() {
return Some(format!(
"Codex 返回后未找到 {entry},项目未进入可运行状态"
));
return Some(format!("Codex 返回后未找到 {entry},项目未进入可运行状态"));
}
if !direct_game_sources_reference_taonier_art_package(root) {
return Some(
@@ -3660,6 +3656,7 @@ fn build_direct_codex_system_prompt_with_search(
"工作区边界:只在当前项目目录内工作;不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径。遇到阻断必须说明具体原因、文件和下一步,不要声称未验证的成功。".to_string(),
"AGC 工具授权边界:DirectProject 的 agc_tools 由当前客户端桥接到 AGC 后端,使用客户端已有登录会话和受控凭据完成授权。用户不需要、也不得向你提供、配置、粘贴或创建 API Key、Token、Cookie、URL 或 .env。工具返回 401/403 时,只说明 AGC 客户端登录或权限状态异常并停止,不要索要凭据、猜测外部 API,也不要暴露内部 URL。".to_string(),
DIRECT_AGC_ENGINEERING_GUIDANCE.to_string(),
"UI 设计 HTML 转换工具 `agc_tools.agc_ui_design_to_html` 当前暂未实现。".to_string(),
"工程执行要求:优先复用现有结构;按需读取真实文件,不依赖客户端预注入源码快照;修改后运行与改动相关的本地验证。不要创建 Supervisor、专业 Agent 或平行项目。".to_string(),
format!("提示词与技能:{skill_index}"),
];
@@ -4576,6 +4573,7 @@ mod tests {
assert!(prompt.contains("agc_tools.taonier_prepare_game_art"));
assert!(prompt.contains("agc_tools.agc_generate_image"));
assert!(prompt.contains("agc_tools.agc_browser_playtest"));
assert!(prompt.contains("agc_tools.agc_ui_design_to_html"));
assert!(prompt.contains("DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill"));
assert!(!prompt.contains("客户端会在系统上下文提供有界的当前游戏文件快照"));
assert!(prompt.contains("Codex 不直接保存或伪造项目版本"));
@@ -356,6 +356,23 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value {
"additionalProperties": false
}
}),
json!({
"name": "agc_ui_design_to_html",
"description": "UI 设计 HTML 转换工具当前暂未实现。",
"inputSchema": {
"type": "object",
"properties": {
"assetId": {
"type": "string",
"minLength": 1,
"maxLength": 160,
"description": "当前项目 manifest 中 kind=UI 的 JSON 设计资源 assetId"
}
},
"required": ["assetId"],
"additionalProperties": false
}
}),
];
let mut tools = tools;
if controlled_web_search {
@@ -997,6 +1014,15 @@ async fn call_agc_browser_playtest(arguments: &Value) -> Value {
call_client_tool_bridge("agc_browser_playtest", arguments).await
}
fn call_agc_ui_design_to_html(root: &Path, arguments: &Value) -> Value {
let _ = (root, arguments);
mcp_tool_result(
"agc_ui_design_to_html 暂未实现".to_string(),
Vec::new(),
true,
)
}
async fn call_agc_web_search(arguments: &Value) -> Value {
if !controlled_web_search_enabled() {
return mcp_tool_result("AGC 受控联网搜索未启用".to_string(), Vec::new(), true);
@@ -1017,7 +1043,7 @@ async fn call_agc_web_search(arguments: &Value) -> Value {
.await
}
async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option<Value> {
async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option<Value> {
let id = request.get("id").cloned();
let method = request.get("method").and_then(Value::as_str)?;
if id.is_none() {
@@ -1068,6 +1094,7 @@ async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option
}
"agc_remove_background" => call_agc_remove_background(&arguments).await,
"agc_browser_playtest" => call_agc_browser_playtest(&arguments).await,
"agc_ui_design_to_html" => call_agc_ui_design_to_html(root, &arguments),
"agc_web_search" => call_agc_web_search(&arguments).await,
_ => mcp_tool_result("未知或未审核的 AGC 工具".to_string(), Vec::new(), true),
};
@@ -1178,8 +1205,7 @@ mod tests {
#[test]
fn tool_catalog_preserves_reviewed_resource_contracts() {
assert!(
DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES
> DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES + 1024,
DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES > DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES + 1024,
"MCP request envelope must fit the advertised file-write payload"
);
let specs = direct_tools_mcp_specs();
@@ -1203,7 +1229,8 @@ mod tests {
"agc_import_account_assets",
"agc_create_or_derive_resource",
"agc_remove_background",
"agc_browser_playtest"
"agc_browser_playtest",
"agc_ui_design_to_html"
]
);
let serialized = specs.to_string();
@@ -1,57 +1,14 @@
use super::*;
use crate::ui_editor::html_renderer::render_ui_design_state_html;
use crate::ui_editor::persistence::{load_ui_design_state_at, LoadUiDesignStateInput};
use serde_json::Value;
pub(in crate::agent) fn observe_agent_runtime_ui_design_to_html(
root: &Path,
input: &Value,
) -> AgentRuntimeToolObservation {
let asset_id = input
.get("assetId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let Some(asset_id) = asset_id else {
return AgentRuntimeToolObservation {
tool: "ui.design.to_html".to_string(),
status: "rejected".to_string(),
summary: "ui.design.to_html 缺少 assetId".to_string(),
detail: None,
};
};
let project_id = match game_creator_agent_runtime_context_project_id(root) {
Ok(project_id) => project_id,
Err(error) => return ui_html_error(root, error),
};
let snapshot = match load_ui_design_state_at(LoadUiDesignStateInput {
project_path: root.to_string_lossy().into_owned(),
expected_project_id: project_id,
asset_id: asset_id.to_string(),
}) {
Ok(snapshot) => snapshot,
Err(error) => return ui_html_error(root, error),
};
match render_ui_design_state_html(&snapshot.state) {
Ok(html) => AgentRuntimeToolObservation {
tool: "ui.design.to_html".to_string(),
status: "ok".to_string(),
summary: format!(
"已生成 UI HTMLrevision {}{} 字节)",
snapshot.revision,
html.len()
),
detail: Some(html),
},
Err(error) => ui_html_error(root, error),
}
}
fn ui_html_error(root: &Path, error: String) -> AgentRuntimeToolObservation {
AgentRuntimeToolObservation {
tool: "ui.design.to_html".to_string(),
status: "error".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 500),
summary: "ui.design.to_html 暂未实现".to_string(),
detail: None,
}
}
@@ -1392,7 +1392,7 @@ fn runtime_tool_description(tool: &str) -> &'static str {
"先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-prototype 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。"
}
"ui.design.to_html" => {
"只读读取指定 UI 设计, 生成HTML片段;"
"UI 设计 HTML 转换工具当前暂未实现。"
}
"blackboard.write" => "向项目级共享黑板追加稳定结论。",
"agent.message" => "向一个目标 Agent 写入定向上下文消息。",
@@ -160,6 +160,15 @@ fn save_ui_design_state(
ui_editor::persistence::save_ui_design_state_at(input)
}
#[tauri::command]
fn generate_ui_design_code(
input: ui_editor::persistence::GenerateUiDesignCodeInput,
) -> Result<ui_editor::persistence::GenerateUiDesignCodeResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "file.write")?;
ui_editor::persistence::generate_ui_design_code_at(input)
}
#[tauri::command]
fn ensure_ui_design_resource_for_prototype(
input: ui_editor::resource_bridge::EnsureUiDesignResourceForPrototypeInput,
@@ -2226,6 +2235,7 @@ fn main() {
bind_components,
load_ui_design_state,
save_ui_design_state,
generate_ui_design_code,
ensure_ui_design_resource_for_prototype,
generate_platform_art_asset,
open_canvas_project,
@@ -9,10 +9,10 @@ pub(super) fn font_face_rule(
}
pub(super) fn html_comment(label: &str, value: serde_json::Value) -> Markup {
let text = serde_json::to_string(&value)
let text = serde_json::to_string_pretty(&value)
.unwrap_or_else(|_| "{}".to_string())
.replace("--", "- -");
PreEscaped(format!("<!-- {label}: {text} -->"))
PreEscaped(format!("<!-- {label}:\n{text}\n-->"))
}
pub(super) fn asset_url(path: &str) -> Result<String, String> {
@@ -29,12 +29,12 @@ pub(super) fn render_component(state: &State, component: &Component) -> Result<M
let family = format!("ui-editor-font-{}", hex_id(font_id.as_str()));
style.push_str(&format!("font-family:'{family}';"));
return Ok(html! {
div data-component-kind="Text" style=(style) {
div style=(style) {
(text.content)
}
});
}
Ok(html! { div data-component-kind="Text" style=(style) { (text.content) } })
Ok(html! { div style=(style) { (text.content) } })
}
Component::Image(image) => {
let sprite_id = image
@@ -49,11 +49,11 @@ pub(super) fn render_component(state: &State, component: &Component) -> Result<M
let (style, image_style) = image_styles(&image.image_type, sprite, &src)?;
Ok(match image_style {
Some(image_style) => html! {
div data-component-kind="Image" style=(style) {
div style=(style) {
img src=(src) alt="" aria-hidden="true" draggable="false" style=(image_style);
}
},
None => html! { div data-component-kind="Image" style=(style) {} },
None => html! { div style=(style) {} },
})
}
}
@@ -3,7 +3,7 @@ mod component;
mod container;
mod node;
use self::assets::{font_face_rule, hex_id, html_comment};
use self::assets::{font_face_rule, hex_id, html_comment, trim_float};
use self::component::render_component;
use self::container::{child_container_style, container_style};
use self::node::{is_container, transform_style};
@@ -59,21 +59,86 @@ pub(crate) fn render_ui_design_state_html(state: &State) -> Result<String, Strin
Ok(fragment)
}
pub(crate) fn render_ui_design_state_js(
state: &State,
) -> Result<(String, Vec<String>, usize), String> {
let mut exports = Vec::with_capacity(state.ui_trees.len());
let mut modules = Vec::with_capacity(state.ui_trees.len());
let mut node_count = 0usize;
for tree in &state.ui_trees {
let export_name = tree_export_name(tree.src_ui_design.as_str());
exports.push(export_name.clone());
let image = state
.ui_design_images
.get(&tree.src_ui_design)
.ok_or_else(|| format!("UITree 缺少 src_ui_design{}", tree.src_ui_design.as_str()))?;
let tree_comment = html_comment(
"genarrative-ui-tree",
json!({
"srcUiDesign": tree.src_ui_design.as_str(),
"name": image.metadata.name,
"description": image.metadata.description,
}),
)
.into_string();
let fonts = state
.font_assets
.values()
.map(|font| {
let family = format!("ui-editor-font-{}", hex_id(font.asset_id.as_str()));
font_face_rule(font, &family)
})
.collect::<Result<Vec<_>, _>>()?
.join("");
let root = render_node_with_scale(
state,
&tree.root,
None,
Some((image.pixel_size.x, image.pixel_size.y)),
)?
.into_string();
node_count += count_nodes(&tree.root);
let mut fragment = String::new();
fragment.push_str(&tree_comment);
if !fonts.is_empty() {
fragment.push_str(&html! { style data-ui-fonts { (PreEscaped(fonts)) } }.into_string());
}
fragment.push_str(&root);
let escaped = escape_template_literal(&pretty_html_fragment(&fragment));
modules.push(format!("export const {export_name} = `\n{escaped}\n`;"));
}
let mut output = String::from(
"// UI 设计生成模块:仅导出 HTML 片段,不执行任何注入逻辑。\n\
// 重新生成会覆盖本文件中的手动修改。\n\
// 可通过宿主页面设置 --ui-scale 调整整体像素缩放,例如:\n\
// document.documentElement.style.setProperty('--ui-scale', '0.85');\n\
// 注入示例(仅供 Agent 按需修改):\n\
// import { tree_example } from './generated-xxx.js';\n\
// document.body.insertAdjacentHTML('beforeend', tree_example);\n\
// const node = document.querySelector('[ui-node-id=\"...\"]');\n\n",
);
output.push_str(&modules.join("\n\n"));
if !output.ends_with('\n') {
output.push('\n');
}
Ok((output, exports, node_count))
}
fn render_tree(state: &State, tree: &UITree) -> Result<Markup, String> {
state
let image = state
.ui_design_images
.get(&tree.src_ui_design)
.ok_or_else(|| format!("UITree 缺少 src_ui_design{}", tree.src_ui_design.as_str()))?;
let tree_comment = html_comment(
"genarrative-ui-tree",
json!({"srcUiDesign": tree.src_ui_design.as_str()}),
json!({
"srcUiDesign": tree.src_ui_design.as_str(),
}),
);
let style = "position:relative;width:100%;height:100%;min-height:0;";
Ok(html! {
(tree_comment)
div data-ui-tree=(tree.src_ui_design.as_str()) style=(style) {
(render_node(state, &tree.root, None)?)
}
div style=(style) { (render_node(state, &tree.root, None)?) }
})
}
@@ -81,8 +146,27 @@ fn render_node(
state: &State,
node: &Node,
parent_container: Option<&Container>,
) -> Result<Markup, String> {
render_node_with_scale(state, node, parent_container, None)
}
fn render_node_with_scale(
state: &State,
node: &Node,
parent_container: Option<&Container>,
root_scale: Option<(f32, f32)>,
) -> Result<Markup, String> {
let mut style = transform_style(&node.layout, parent_container.is_some())?;
if let Some((width, height)) = root_scale {
if !width.is_finite() || width <= 0.0 || !height.is_finite() || height <= 0.0 {
return Err("UI 设计尺寸必须为正有限数值".to_string());
}
style.push_str(&format!(
"--ui-scale:min(calc(100vw / {}px),calc(100vh / {}px));",
trim_float(width),
trim_float(height)
));
}
style.push_str("border:0;outline:0;background:transparent;overflow:visible;");
style.push_str(&container_style(&node.layout.container));
if let Some(parent) = parent_container {
@@ -115,10 +199,11 @@ fn render_node(
.children
.iter()
.map(|child| {
render_node(
render_node_with_scale(
state,
child,
is_container(&node.layout.container).then_some(&node.layout.container),
None,
)
})
.collect::<Result<Vec<_>, _>>()?
@@ -128,9 +213,62 @@ fn render_node(
Ok(html! {
(comment)
@if let Some(group_comment) = exclusive_comment { (group_comment) }
div data-node-id=(node.id.as_str()) style=(style) {
div ui-node-id=(node.id.as_str()) style=(style) {
(PreEscaped(components.concat()))
(PreEscaped(children.concat()))
}
})
}
fn count_nodes(node: &Node) -> usize {
1 + node.children.iter().map(count_nodes).sum::<usize>()
}
fn tree_export_name(id: &str) -> String {
let mut name = String::from("tree_");
for character in id.chars() {
if character.is_ascii_alphanumeric() || character == '_' {
name.push(character);
} else {
name.push('_');
}
}
name
}
fn escape_template_literal(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace('`', "\\`")
.replace("${", "\\${")
}
fn pretty_html_fragment(value: &str) -> String {
let mut depth = 0usize;
let mut lines = Vec::new();
for raw in value.replace("><", ">\n<").lines() {
let line = raw.trim();
if line.is_empty() {
continue;
}
let closing = line.starts_with("</");
if closing {
depth = depth.saturating_sub(1);
}
lines.push(format!("{}{}", " ".repeat(depth), line));
if line.starts_with('<')
&& !closing
&& !line.starts_with("<!--")
&& !line.starts_with("<!")
&& !line.ends_with("/>")
&& !line.starts_with("<img")
&& !line.starts_with("<input")
&& !line.starts_with("<br")
&& !line.starts_with("<hr")
&& !line.contains("</")
{
depth += 1;
}
}
lines.join("\n")
}
@@ -1,5 +1,6 @@
use crate::ui_editor::component::text::FontSource;
use crate::ui_editor::component::Component;
use crate::ui_editor::html_renderer::render_ui_design_state_js;
use crate::ui_editor::layout::node::Node;
use crate::ui_editor::resource::ui_design_image::{
UIDesignImage, UIDesignImageMetadata, UIDesignImageRole,
@@ -43,6 +44,23 @@ pub(crate) struct LoadUiDesignStateInput {
pub(crate) asset_id: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct GenerateUiDesignCodeInput {
pub(crate) project_path: String,
pub(crate) expected_project_id: String,
pub(crate) asset_id: String,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GenerateUiDesignCodeResult {
pub(crate) relative_path: String,
pub(crate) tree_exports: Vec<String>,
pub(crate) tree_count: usize,
pub(crate) node_count: usize,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct SaveUiDesignStateInput {
@@ -149,6 +167,44 @@ pub(crate) fn load_ui_design_state_at(
})
}
pub(crate) fn generate_ui_design_code_at(
input: GenerateUiDesignCodeInput,
) -> Result<GenerateUiDesignCodeResult, String> {
let root = Path::new(input.project_path.trim());
let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?;
let asset_id = required_identifier(&input.asset_id, "assetId")?;
let _lock = acquire_project_write_lock(root, "ui_design.code_generate")?;
let asset = ui_design_asset(root, &expected_project_id, &asset_id)?;
let document =
read_ui_design_document_locked(root, &asset.local_path, &expected_project_id, &asset_id)?;
let (content, tree_exports, node_count) = render_ui_design_state_js(&document.state)?;
let relative_path = format!("ui/generated-{}.js", generated_file_stem(&asset_id));
let path = resolve_local_project_path(root, &relative_path)?;
write_ui_design_raw_file(&path, "UI 设计生成代码", content.as_bytes())?;
Ok(GenerateUiDesignCodeResult {
relative_path,
tree_count: tree_exports.len(),
tree_exports,
node_count,
})
}
fn generated_file_stem(asset_id: &str) -> String {
let mut stem = String::new();
for character in asset_id.chars() {
if character.is_ascii_alphanumeric() || matches!(character, '_' | '-') {
stem.push(character);
} else {
stem.push('_');
}
}
if stem.is_empty() {
"ui-design".to_string()
} else {
stem
}
}
pub(crate) fn save_ui_design_state_at(
input: SaveUiDesignStateInput,
) -> Result<SaveUiDesignStateResult, String> {
@@ -14,6 +14,13 @@ export type UiDesignStateSaveResult =
}
| { status: 'conflict'; current: UiDesignStateSnapshot };
export type UiDesignCodeGenerationResult = {
relativePath: string;
treeExports: string[];
treeCount: number;
nodeCount: number;
};
export type IUiDesignStateStore = {
load(assetId: string): Promise<UiDesignStateSnapshot>;
save(
@@ -21,6 +28,7 @@ export type IUiDesignStateStore = {
expectedRevision: number,
state: State,
): Promise<UiDesignStateSaveResult>;
generateCode(assetId: string): Promise<UiDesignCodeGenerationResult>;
};
export function createTauriUiDesignStateStore(
@@ -44,6 +52,11 @@ export function createTauriUiDesignStateStore(
},
});
},
generateCode(assetId) {
return invoke<UiDesignCodeGenerationResult>('generate_ui_design_code', {
input: { projectPath, expectedProjectId, assetId },
});
},
};
}
@@ -76,6 +89,14 @@ export const uiDesignStateStore: IUiDesignStateStore = {
committedProjectRevision: 0,
};
},
async generateCode() {
return {
relativePath: 'ui/generated-ui-design.js',
treeExports: [],
treeCount: 0,
nodeCount: 0,
};
},
};
export const EMPTY_UI_DESIGN_STATE = EMPTY_UI_EDITOR_STATE;
@@ -60,6 +60,8 @@ export default function UiEditorPage({
);
const [saveWarningOpen, setSaveWarningOpen] = useState(false);
const [saveAfterReturn, setSaveAfterReturn] = useState(false);
const [generateAfterWarning, setGenerateAfterWarning] = useState(false);
const [generateSuccess, setGenerateSuccess] = useState<string | null>(null);
const [returnConfirmOpen, setReturnConfirmOpen] = useState(false);
async function save(afterReturn = false) {
@@ -71,11 +73,23 @@ export default function UiEditorPage({
}
}
async function saveAndGenerate() {
setGenerateSuccess(null);
if (await session.save.save()) {
const result = await session.save.generateCode();
if (result) {
setGenerateAfterWarning(false);
setGenerateSuccess(`代码已生成:${result.relativePath}`);
}
}
}
function requestSave(afterReturn = false) {
if (!resourceId || session.save.isSaving || session.workflow.isAiRunning) {
return;
}
setSaveAfterReturn(afterReturn);
setGenerateAfterWarning(false);
if (session.save.hasWarnings()) {
setSaveWarningOpen(true);
return;
@@ -83,6 +97,25 @@ export default function UiEditorPage({
void save(afterReturn);
}
function requestSaveAndGenerate() {
if (
!resourceId ||
session.save.isSaving ||
session.save.isGenerating ||
session.workflow.isAiRunning
) {
return;
}
setSaveAfterReturn(false);
setGenerateSuccess(null);
setGenerateAfterWarning(true);
if (session.save.hasWarnings()) {
setSaveWarningOpen(true);
return;
}
void saveAndGenerate();
}
function requestBack() {
if (session.save.isDirty) {
setReturnConfirmOpen(true);
@@ -122,6 +155,25 @@ export default function UiEditorPage({
>
{session.save.isSaving ? '保存中…' : '保存'}
</button>
<button
type="button"
className="rounded-lg border border-orange-600 px-3 py-1.5 text-sm font-semibold text-orange-700 disabled:opacity-60"
disabled={
session.save.isSaving ||
session.save.isGenerating ||
session.save.isLoading ||
Boolean(session.save.loadError) ||
session.save.persistedRevision === null ||
session.save.isLocked
}
onClick={requestSaveAndGenerate}
>
{session.save.isSaving
? '保存中…'
: session.save.isGenerating
? '生成中…'
: '保存并生成代码'}
</button>
</div>
</header>
) : null}
@@ -133,6 +185,22 @@ export default function UiEditorPage({
<span>{session.save.saveError}</span>
</div>
) : null}
{session.save.generateError ? (
<div
className="mx-4 mt-3 flex shrink-0 items-center justify-between gap-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-800"
role="alert"
>
<span>{session.save.generateError}</span>
</div>
) : null}
{generateSuccess ? (
<div
className="mx-4 mt-3 shrink-0 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs text-emerald-800"
role="status"
>
{generateSuccess}
</div>
) : null}
<ToolNavigation
activeStep={session.workflow.activeStep}
furthestStepIndex={session.workflow.furthestStepIndex}
@@ -273,7 +341,11 @@ export default function UiEditorPage({
className="rounded-lg bg-orange-600 px-3 py-2 text-xs font-semibold text-white"
onClick={() => {
setSaveWarningOpen(false);
void save(saveAfterReturn);
if (generateAfterWarning) {
void saveAndGenerate();
} else {
void save(saveAfterReturn);
}
}}
>
@@ -186,6 +186,8 @@ export function useUiEditorSession(
);
const [saveError, setSaveError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [generateError, setGenerateError] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const normalizedInitialStepIndex =
initialStep === 'reference-analysis'
? 0
@@ -1071,6 +1073,28 @@ export function useUiEditorSession(
}
}
async function generateCode() {
if (
!resourceId ||
isGenerating ||
isLoading ||
loadError ||
editor.isLocked
) {
return null;
}
setGenerateError(null);
setIsGenerating(true);
try {
return await stateStore.generateCode(resourceId);
} catch (cause) {
setGenerateError(cause instanceof Error ? cause.message : String(cause));
return null;
} finally {
setIsGenerating(false);
}
}
return {
input: {
projectPath,
@@ -1237,9 +1261,12 @@ export function useUiEditorSession(
isSaving,
isDirty,
saveError,
isGenerating,
generateError,
hasWarnings: () => postCheckIssuesForSave(editor.state).length > 0,
warnings: () => postCheckIssuesForSave(editor.state),
save,
generateCode,
},
};
}