Compare commits

...

54 Commits

Author SHA1 Message Date
k88936 1d6e7057bc 移除识别绑定旧计算命名
清理请求错误、结果校验和日志中的旧计算及联合文案
2026-09-05 15:39:17 +08:00
k88936 6daff74c85 更新并简化 UI 识别与绑定系统提示 2026-09-05 15:22:26 +08:00
k88936 ad49056d36 更新 UI workflow 阶段决策记录
移除历史阶段描述并记录现行阶段与无迁移约束
2026-09-05 14:14:37 +08:00
k88936 28af87e3ad 统一 UI workflow 阶段并拒绝未知值
现行阶段原地使用并移除旧阶段 fallback 与迁移兼容
2026-09-05 14:11:00 +08:00
k88936 d994ccaa78 更正独立素材上限语义
将128限制恢复为独立素材数量且不计入页面参考图
2026-09-05 13:33:52 +08:00
k88936 42f1109b90 限制联合图片上下文总量
将页面参考图与独立素材的单次请求总数限制为128张
2026-09-05 13:30:03 +08:00
k88936 b772ec3efb 记录联合识别重跑与阶段排名语义
说明重复识别会生成新结果并明确 workflow_stage_rank 的后端持久化边界
2026-09-05 13:25:38 +08:00
k88936 d014cb1d06 限制联合请求独立素材总量
将单次识别绑定请求的独立素材数量限制为128个
2026-09-05 13:24:58 +08:00
k88936 e34ae89db3 移除废弃的独立识别路径
让联合识别绑定成为 UI 编辑器唯一实现并清理旧状态投影
2026-09-05 13:11:38 +08:00
k88936 88853d0a30 清理联合识别后的节点选择
替换生成新树后清除已失效的旧节点选中状态
2026-09-05 13:09:40 +08:00
k88936 fabc64df6c 避免工作流渲染校验重复克隆状态
直接复用联合结果写入后的 State 完成组件校验
2026-09-05 13:08:50 +08:00
k88936 27803a6e78 明确联合结果按图片返回树
让提示与每张输入图一棵树的校验契约一致
2026-09-05 13:07:27 +08:00
k88936 828b3e1173 消除联合请求初始化崩溃路径
将 provider client 与配置 unwrap 改为显式错误
2026-09-05 13:02:38 +08:00
k88936 deb60065a5 校验联合结果素材引用
拒绝不存在的图片素材组件引用

拒绝不存在的字体绑定引用
2026-09-05 12:58:49 +08:00
k88936 5c6a940b9c 稳定联合识别图片上下文顺序
页面角色优先作为 ROOT

其余界面图按 ID 排序保持请求可复现
2026-09-05 12:49:56 +08:00
k88936 8df5ebff69 简化联合工作流动作状态
移除旧识别状态回退与重复分支

统一联合步骤的运行中和完成态显示
2026-09-05 12:47:43 +08:00
k88936 041b51adc1 归一化UI编辑器完成态入口
将旧 visual-binding 初始步骤归一到联合识别绑定步骤

简化工作流步骤索引计算
2026-09-05 12:46:55 +08:00
k88936 5a31809e21 修正联合概览状态定位
结构问题高亮 layout_status

组件问题高亮 components_status
2026-09-05 12:44:26 +08:00
k88936 44bebae0a9 统一UI编辑器联合计算入口
工作流导航收敛为参考分析与一键识别绑定两步

联合概览同时展示结构与组件状态
2026-09-05 12:01:54 +08:00
k88936 62793ac445 正式合并UI识别与组件绑定
删除旧 recognition 与 binding 命令及前端 DTO

新增 recognition_and_binding 单次 workflow 与联合概览

移除 debug 超时特判并直达 binding-ready
2026-09-05 11:55:47 +08:00
k88936 f630c518f3 重构UI工作流联合识别绑定契约
更新 UI workflow 以 recognition_and_binding 单次计算为正式识别路径

保留 merge 能力但移出联合识别绑定流程

声明联合提示词 placeholder 与 binding-ready 直达阶段
2026-09-05 11:07:42 +08:00
k88936 f5f94111ca Feat/图片绑定选择器改用带预览的模态框 (#269)
Project CI / Native shell tests (push) Successful in 20m12s
Project CI / Repository checks (push) Successful in 2m42s
Project CI / Frontend tests (push) Successful in 3m39s
Project CI / Backend tests (push) Successful in 7m18s
Project CI / Backend tests (pull_request) Failing after 9s
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / Native shell tests (pull_request) Failing after 32m52s
Project CI / Frontend tests (pull_request) Failing after 44m27s
before:
![image.png](/attachments/5ae7df1f-cf57-4712-8038-f65ae83e2ffa)

after:
![shotmd-1788417438.jpg](/attachments/676e653d-2561-4eca-b28f-9725d3b8af02)

close #268

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/269
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-09-03 20:02:48 +08:00
k88936 4cd53688e9 Feat/优化UI编辑器inspector offset编辑部分UI (#265)
Project CI / Repository checks (push) Successful in 2m21s
Project CI / Frontend tests (push) Successful in 3m37s
Project CI / Backend tests (push) Successful in 6m21s
Project CI / Native shell tests (push) Successful in 19m48s
before:
![image.png](/attachments/e0344964-55c6-4ba1-9191-a79d81f32b2e)
after:
![shotmd-1788414839.jpg](/attachments/14c8a723-b2df-48d5-9773-7f576bcef873)
close #267

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/265
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-09-03 19:36:18 +08:00
kdletters cb7bc50ef9 Merge pull request 'UI编辑器撤销回退功能' (#261) from feat/ui-editor-edit-history into master
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/261
2026-09-03 19:31:25 +08:00
kdletters b7641f94b7 Merge branch 'master' into feat/ui-editor-edit-history
Project CI / Repository checks (pull_request) Successful in 2m40s
Project CI / Frontend tests (pull_request) Successful in 3m6s
Project CI / Backend tests (pull_request) Successful in 6m40s
Project CI / Native shell tests (pull_request) Successful in 20m27s
2026-09-03 19:30:43 +08:00
k88936 520b9344ef Fix/AGC上下文丢失问题 (#247)
Project CI / Frontend tests (push) Successful in 3m27s
Project CI / Backend tests (push) Successful in 7m20s
Project CI / Repository checks (push) Successful in 3m1s
Project CI / Native shell tests (push) Has been cancelled
权衡之后选择附加历史消息构造一个巨大的prompt
before:
```
1. user:  aabb
2. assistant: bbaa 这里codex崩溃了或者重启上下文丢失
```
after:
重启之后发送新消息abab
 codex看到:
```
user:  aabb
assistant: bbaa
这里崩溃了!
user: abab
```

移除了原来的"会话最后只有用户消息就自动重新发送用户消息"功能, 因为存在冲突: 现在的实现是每次用户手动发送新消息才做以上的步骤

before and after:
![shotmd-1788345830.jpg](/attachments/28549e4d-9913-459a-bae2-a195b8ee9e04)

Close #249

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/247
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-09-03 19:30:21 +08:00
k88936 51b7d51d01 Merge branch 'master' into feat/ui-editor-edit-history
Project CI / Backend tests (pull_request) Successful in 7m2s
Project CI / Repository checks (pull_request) Successful in 3m8s
Project CI / Frontend tests (pull_request) Successful in 3m9s
Project CI / Native shell tests (pull_request) Successful in 21m38s
2026-09-03 16:08:23 +08:00
k88936 0c0cad7fa4 Merge branch 'master' into feat/ui-editor-edit-history
Project CI / Repository checks (pull_request) Failing after 1m57s
Project CI / Frontend tests (pull_request) Successful in 4m9s
Project CI / Backend tests (pull_request) Successful in 6m17s
Project CI / Native shell tests (pull_request) Failing after 20m35s
2026-09-03 14:44:36 +08:00
k88936 9fd160efc5 合并批量组件绑定历史记录
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Successful in 3m7s
Project CI / Native shell tests (pull_request) Successful in 22m4s
批处理中的前置绑定批次跳过历史,最后一批使用非跳过替换统一形成撤销边界

为跳过后记录历史的状态基线补充撤销回归测试
2026-09-03 14:42:38 +08:00
k88936 e4680d6f70 统一节点变换实时预览
Project CI / Frontend tests (pull_request) Successful in 3m17s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / Native shell tests (pull_request) Successful in 19m52s
预览拖动和缩放时同步解析保持子节点页面矩形的变换

将检查器的子节点保持选项传入预览交互并补充回归测试
2026-09-03 13:38:19 +08:00
k88936 79d3e5e6de 保留颜色草稿卸载时的修改
组件卸载前提交仍未关闭的颜色草稿,避免切换面板或删除组件导致颜色丢失
2026-09-03 13:17:15 +08:00
k88936 08caa1f29f 防止资源比较循环递归溢出
为 sameResource 增加对象对访问跟踪,遇到循环结构时安全终止递归
2026-09-03 13:15:49 +08:00
k88936 6b332e8c12 阻止快捷键重复触发历史操作
忽略 keydown 重复事件,避免长按撤销或重做键耗尽历史记录
2026-09-03 13:15:17 +08:00
k88936 cb86ab8be9 减少编辑器历史快照复制
提交历史时复用已克隆的 nextState,避免每次编辑产生冗余深拷贝
2026-09-03 13:15:01 +08:00
k88936 071998fee9 提取编辑器历史上限常量
为撤销历史容量 100 增加命名常量,便于理解和调节内存权衡
2026-09-03 13:14:43 +08:00
k88936 d60d93a327 简化预览变换查找
单次读取节点预览变换,移除重复 Map 查找和非空断言
2026-09-03 13:14:18 +08:00
k88936 e75450357d 优化预览变换回退分配
复用空的预览变换映射,避免节点递归渲染重复分配
2026-09-03 13:14:00 +08:00
k88936 d6f0c2642f 提交颜色选择器结束态草稿
Project CI / Repository checks (pull_request) Failing after 19s
Project CI / Backend tests (pull_request) Failing after 19s
Project CI / Frontend tests (pull_request) Successful in 2m41s
Project CI / Native shell tests (pull_request) Successful in 14m54s
使用 RgbaColorPicker 的 onChangeEnd 处理指针与键盘结束\n关闭颜色弹层时提交仍存在的草稿
2026-09-03 12:17:36 +08:00
k88936 074ee615ae 更新节点拖拽提交测试
断言 pointermove 仅更新预览\n断言 pointerup 才提交一次节点变换
2026-09-03 12:17:25 +08:00
k88936 d44c924f5e 简化 UI 编辑器历史提交边界
移除撤销事务 API 与额外事务状态

让绑定每个 batch 独立提交一条撤销记录

同步更新规格和独立提交测试
2026-09-03 12:14:21 +08:00
k88936 b07c8a5e25 优化编辑器状态相等性检查
用可提前退出的递归比较替代完整状态序列化\n减少每次编辑提交的无谓 JSON 字符串构造
2026-09-03 12:00:36 +08:00
k88936 37c465dbc3 稳定编辑器快捷键监听
按稳定的撤销重做回调绑定全局监听\n尊重已被其他处理器阻止的键盘事件
2026-09-03 11:54:59 +08:00
k88936 95f5904b92 放宽编辑器快捷键焦点限制
仅将文本输入控件视为可编辑目标\n保持工具栏按钮聚焦时仍可使用撤销重做快捷键
2026-09-03 11:54:27 +08:00
k88936 e616e2c899 清理透明度输入的颜色草稿
透明度修改提交前显式清除颜色草稿\n避免无效更新后残留未提交颜色
2026-09-03 11:51:58 +08:00
k88936 f79ccccf44 稳定预览手势清理回调
通过 ref 读取最新预览回调\n避免回调身份变化时重复取消进行中的手势
2026-09-03 11:51:34 +08:00
k88936 093a2ace76 优化预览变换状态更新
仅在预览变换实际变化时创建新 Map\n避免取消不存在手势时触发预览树重渲染
2026-09-03 11:51:07 +08:00
k88936 90623f136d 登记 UI 编辑器撤销重做规范
将撤销重做规格加入文档总览入口
2026-09-03 11:08:52 +08:00
k88936 dc0ea0eda0 接入 UI 编辑器撤销重做入口
在编辑器头部增加撤销与重做按钮及禁用态

增加桌面 Cmd/Ctrl 快捷键并保留文本控件原生行为
2026-09-03 11:07:19 +08:00
k88936 0544e78996 修正清空编辑器历史边界
清空编辑器时重置撤销与重做栈

补充清空操作不会进入历史的测试
2026-09-03 11:06:47 +08:00
k88936 e379459820 降低 UI 编辑器颜色选择器提交频率
颜色拖动期间使用本地草稿预览

指针释放时一次提交最终颜色 State
2026-09-03 11:05:58 +08:00
k88936 15caf1cafc 收敛 UI 编辑器锁与历史事务
新增统一的加锁并包裹历史事务操作

将 AI 建议、识别、合并和批量绑定改用统一入口
2026-09-03 11:05:04 +08:00
k88936 f79724cb17 实现 UI 编辑器 State 撤销重做核心
在统一 State 提交入口增加 100 条快照历史与 no-op 过滤

提供撤销、重做、事务分组及加载重置历史 API

补充 State 历史、事务回滚和加载清理测试
2026-09-03 11:01:21 +08:00
k88936 c519f65ac2 补充 UI 编辑器撤销重做规范
明确撤销与重做的 State 范围、事务边界和生命周期

明确连续控件与节点变换的低频提交规则

明确资产文件、副作用、快捷键和验收标准
2026-09-03 10:58:54 +08:00
k88936 8a18b1b181 调整 UI 编辑器节点变换为松开时提交
Project CI / Repository checks (pull_request) Failing after 16s
Project CI / Frontend tests (pull_request) Failing after 2m3s
Project CI / Backend tests (pull_request) Failing after 20s
Project CI / Native shell tests (pull_request) Failing after 4m30s
拖动和缩放期间仅更新预览临时变换

指针松开时提交最终变换,取消操作不写入 State

补充 UI 编辑器拖动变换提交边界文档
2026-09-03 10:51:52 +08:00
52 changed files with 2653 additions and 1977 deletions
@@ -88,10 +88,12 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
setStatus('');
try {
const updated = await updateAdminErrorReport(token, selected.batchId, {
status: nextStatus,
note: selected.note,
});
setSelected((current) => (current ? { ...current, ...updated } : current));
status: nextStatus,
note: selected.note,
});
setSelected((current) =>
current ? { ...current, ...updated } : current,
);
await loadRef.current();
} catch (error) {
if (isAdminApiError(error) && error.status === 401) onUnauthorized();
@@ -244,7 +246,9 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
value={selected.note ?? ''}
onChange={(event) =>
setSelected((current) =>
current ? { ...current, note: event.target.value } : current,
current
? { ...current, note: event.target.value }
: current,
)
}
maxLength={2000}
@@ -1943,17 +1943,23 @@ impl CodexAppServerConnection {
snapshot: &AgentRuntimeProviderRequestSnapshot,
request: &LlmRunRequest,
llm: &GameCreatorLlmConfig,
) -> Result<CodexThreadLease, platform_llm::LlmError> {
) -> Result<(CodexThreadLease, bool), platform_llm::LlmError> {
let key = CodexNodeThreadKey::from(snapshot);
let mut threads = self.inner.threads.lock().await;
// The caller holds `turn_gate` while invoking this method. Returning
// the creation bit from the same threads lock keeps the replay
// decision atomic with thread reuse/creation.
if let Some(entry) = threads.get_mut(&key) {
entry.active_uses = entry.active_uses.saturating_add(1);
entry.last_used = next_game_creator_codex_app_server_usage_tick();
return Ok(CodexThreadLease {
connection: self.clone(),
key,
thread_id: entry.thread_id.clone(),
});
return Ok((
CodexThreadLease {
connection: self.clone(),
key,
thread_id: entry.thread_id.clone(),
},
false,
));
}
if threads.len() >= GAME_CREATOR_CODEX_APP_SERVER_THREAD_MAX {
let evict_key = threads
@@ -2015,11 +2021,14 @@ impl CodexAppServerConnection {
active_uses: 1,
},
);
Ok(CodexThreadLease {
connection: self.clone(),
key,
thread_id,
})
Ok((
CodexThreadLease {
connection: self.clone(),
key,
thread_id,
},
true,
))
}
async fn register_turn(&self, turn_id: &str) -> mpsc::UnboundedReceiver<CodexTurnEvent> {
@@ -2085,11 +2094,60 @@ impl CodexAppServerConnection {
mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>,
mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
mut audit: Option<&mut DirectCodexTurnAudit>,
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
self.run_turn_with_direct_observer_and_history(
snapshot,
llm,
request,
None,
None,
on_agent_message_delta,
direct_observer,
audit,
)
.await
}
async fn run_turn_with_direct_observer_and_history(
&self,
snapshot: &AgentRuntimeProviderRequestSnapshot,
llm: &GameCreatorLlmConfig,
request: LlmRunRequest,
direct_history_root: Option<&std::path::Path>,
direct_client_turn_id: Option<&str>,
mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>,
mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
mut audit: Option<&mut DirectCodexTurnAudit>,
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
let _turn_guard = self.inner.turn_gate.lock().await;
let thread_lease = self.thread_for(snapshot, &request, llm).await?;
let (thread_lease, thread_created) = self.thread_for(snapshot, &request, llm).await?;
self.wait_for_initial_client_mcp_startup().await;
let thread_id = thread_lease.thread_id.clone();
let mut request = request;
if thread_created && self.inner.workspace_mode.uses_direct_conversation() {
// DirectProject owns the append-only project history in AGC. The
// replay builder derives a bounded prompt without mutating that
// durable fact source, so a new ephemeral thread can recover the
// newest contiguous context within the model budget.
let current_prompt = direct_codex_current_user_prompt(&request).to_string();
let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path);
let history_prompt = build_direct_codex_history_prompt(
history_root,
direct_client_turn_id.unwrap_or("__none__"),
&current_prompt,
&request,
llm,
)
.map_err(platform_llm::LlmError::InvalidRequest)?;
if let Some(message) = request
.messages
.iter_mut()
.rev()
.find(|message| message.role == LlmMessageRole::User)
{
message.content = history_prompt;
}
}
let prompt = if self.inner.workspace_mode.uses_direct_conversation() {
direct_codex_user_prompt(&request)
} else {
@@ -3020,6 +3078,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at(
user_prompt,
None,
None,
None,
)
.await
}
@@ -3034,6 +3093,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer(
root,
system_prompt,
user_prompt,
None,
Some(observer),
None,
)
@@ -3090,6 +3150,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
root: &std::path::Path,
system_prompt: String,
user_prompt: String,
client_turn_id: Option<&str>,
observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
audit: Option<&mut DirectCodexTurnAudit>,
) -> Result<String, String> {
@@ -3129,11 +3190,6 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
};
let api_kind =
parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| error.to_string())?;
let request = LlmRunRequest::single_turn(system_prompt, user_prompt)
.with_api_kind(api_kind)
.with_model(config.llm.model.clone())
.with_request_timeout_ms(config.llm.request_timeout_ms)
.with_max_output_tokens(16_000);
let connection = CodexAppServerConnection::acquire_at_workspace(
&snapshot,
&config.llm,
@@ -3142,13 +3198,176 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
)
.await
.map_err(|error| error.to_string())?;
let request = LlmRunRequest::single_turn(system_prompt, user_prompt)
.with_api_kind(api_kind)
.with_model(config.llm.model.clone())
.with_request_timeout_ms(config.llm.request_timeout_ms)
.with_max_output_tokens(16_000);
connection
.run_turn_with_direct_observer(&snapshot, &config.llm, request, None, observer, audit)
.run_turn_with_direct_observer_and_history(
&snapshot,
&config.llm,
request,
Some(&codex_root),
client_turn_id,
None,
observer,
audit,
)
.await
.map(|value| value.text)
.map_err(|error| error.to_string())
}
/// Builds the prompt used when a new DirectProject thread needs replay.
///
/// The JSONL history remains immutable; only the derived prompt is bounded.
/// When the full request exceeds the replay target, the oldest contiguous
/// records are omitted and a plain `system:` marker is prepended.
pub(crate) fn build_direct_codex_history_prompt(
root: &std::path::Path,
client_turn_id: &str,
current_prompt: &str,
base_request: &LlmRunRequest,
llm: &GameCreatorLlmConfig,
) -> Result<String, String> {
let conversation = read_local_conversation_for_session_at(root, None, None)?;
let current_message_id = format!("direct-codex:{client_turn_id}:user");
// The deliberately simple role-prefix format is part of the Direct
// replay contract. Do not introduce an envelope or implicit escaping
// here without updating the persisted-history compatibility decision.
let lines = conversation
.messages
.iter()
.filter(|message| message.message_id.as_deref() != Some(current_message_id.as_str()))
.map(|message| format!("{}: {}", message.role, message.content))
.collect::<Vec<_>>();
let current_line = format!("user: {}", current_prompt.trim());
let full_prompt = format_direct_codex_replay_prompt(&lines, &current_line, None);
let target_budget = direct_codex_replay_target_budget(llm, base_request)?;
if direct_codex_replay_prompt_fits(base_request, llm, &full_prompt, target_budget)? {
return Ok(full_prompt);
}
// The persisted project conversation is immutable. We only derive a
// bounded prompt for this replay, keeping the newest contiguous records.
let omission_marker = DIRECT_CODEX_REPLAY_OMISSION_MARKER;
let mut selected_reversed = Vec::new();
for line in lines.iter().rev() {
let mut candidate_reversed = selected_reversed.clone();
candidate_reversed.push(line.as_str());
let candidate_lines = candidate_reversed.iter().rev().copied().collect::<Vec<_>>();
let candidate_prompt = format_direct_codex_replay_prompt(
&candidate_lines,
&current_line,
Some(omission_marker),
);
if direct_codex_replay_prompt_fits(base_request, llm, &candidate_prompt, target_budget)? {
selected_reversed.push(line.as_str());
} else {
break;
}
}
let selected_lines = selected_reversed.iter().rev().copied().collect::<Vec<_>>();
let marked_prompt =
format_direct_codex_replay_prompt(&selected_lines, &current_line, Some(omission_marker));
if direct_codex_replay_prompt_fits(base_request, llm, &marked_prompt, target_budget)? {
return Ok(marked_prompt);
}
// If the marker itself would push the request over the target, preserve
// the current user request and omit only the marker.
let current_only_prompt =
format_direct_codex_replay_prompt(&[] as &[&str], &current_line, None);
if direct_codex_replay_prompt_fits(base_request, llm, &current_only_prompt, target_budget)? {
return Ok(current_only_prompt);
}
direct_codex_replay_validate_context_budget(base_request, llm, &current_only_prompt)
}
const DIRECT_CODEX_REPLAY_OMISSION_MARKER: &str =
"system: Earlier conversation history was omitted due to context budget.";
fn format_direct_codex_replay_prompt(
history_lines: &[impl AsRef<str>],
current_line: &str,
omission_marker: Option<&str>,
) -> String {
let mut lines = Vec::with_capacity(history_lines.len() + 2);
if let Some(marker) = omission_marker {
lines.push(marker.to_string());
}
lines.extend(history_lines.iter().map(|line| line.as_ref().to_string()));
lines.push(current_line.to_string());
lines.join("\n")
}
fn direct_codex_replay_target_budget(
llm: &GameCreatorLlmConfig,
request: &LlmRunRequest,
) -> Result<u64, String> {
const SAFETY_MARGIN_TOKENS: u64 = 4_096;
let max_output_tokens = u64::from(request.max_output_tokens.unwrap_or(0));
let hard_budget = llm
.context_window_tokens
.checked_sub(max_output_tokens)
.and_then(|value| value.checked_sub(SAFETY_MARGIN_TOKENS))
.ok_or_else(|| "Direct replay 没有可用的输入上下文预算".to_string())?;
Ok(llm.auto_compact_token_limit.min(hard_budget))
}
fn direct_codex_replay_estimate(
base_request: &LlmRunRequest,
prompt: &str,
) -> Result<LlmRunRequest, String> {
let mut request = base_request.clone();
let user = request
.messages
.iter_mut()
.rev()
.find(|message| message.role == LlmMessageRole::User)
.ok_or_else(|| "Direct replay 请求缺少 user message".to_string())?;
user.content = prompt.to_string();
Ok(request)
}
fn direct_codex_replay_prompt_fits(
base_request: &LlmRunRequest,
llm: &GameCreatorLlmConfig,
prompt: &str,
target_budget: u64,
) -> Result<bool, String> {
let request = direct_codex_replay_estimate(base_request, prompt)?;
let estimated = estimate_game_creator_llm_request_tokens(&request)?;
if estimated > target_budget {
return Ok(false);
}
validate_game_creator_llm_request_context_budget(
llm,
&request,
estimated,
"Direct replay 请求",
)?;
Ok(true)
}
fn direct_codex_replay_validate_context_budget(
base_request: &LlmRunRequest,
llm: &GameCreatorLlmConfig,
prompt: &str,
) -> Result<String, String> {
let request = direct_codex_replay_estimate(base_request, prompt)?;
let estimated = estimate_game_creator_llm_request_tokens(&request)?;
validate_game_creator_llm_request_context_budget(
llm,
&request,
estimated,
"Direct replay 请求",
)?;
Ok(prompt.to_string())
}
/// Direct home-page chat never binds Codex to a user project. It gets a
/// fresh isolated read-only workspace and a stable in-process thread so a
/// normal conversation can continue without creating a project, assets, a
@@ -3234,6 +3453,119 @@ pub(in crate::agent) fn shutdown_game_creator_codex_app_servers_impl() -> Result
mod tests {
use super::*;
#[test]
fn direct_history_prompt_replays_all_project_messages_in_order() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "history-project", "history").expect("init");
for (role, content, message_id) in [
("user", "hello", "direct-codex:old:user"),
(
"assistant",
"partial\nunexpected interrupt happened here",
"partial-id",
),
("tool", "file-read result", "tool-id"),
("user", "stored raw request", "direct-codex:new-turn:user"),
] {
append_local_conversation_message_for_session_idempotent_at(
root.path(),
None,
None,
LocalConversationMessage {
role: role.to_string(),
content: content.to_string(),
agent_id: None,
},
message_id,
)
.expect("append history");
}
let request = LlmRunRequest::single_turn("system", "new request")
.with_model("fixture-model")
.with_max_output_tokens(16_000);
let prompt = build_direct_codex_history_prompt(
root.path(),
"new-turn",
"new request",
&request,
&test_llm(),
)
.expect("build prompt");
assert_eq!(
prompt,
"user: hello\nassistant: partial\nunexpected interrupt happened here\ntool: file-read result\nuser: new request"
);
}
#[test]
fn direct_history_prompt_slides_old_prefix_when_budget_is_exceeded() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "window-project", "window").expect("init");
for (role, content, message_id) in [
("user", "old ".repeat(1_000), "old-user"),
("assistant", "middle ".repeat(100), "middle-assistant"),
("tool", "newest ".repeat(100), "newest-tool"),
] {
append_local_conversation_message_for_session_idempotent_at(
root.path(),
None,
None,
LocalConversationMessage {
role: role.to_string(),
content,
agent_id: None,
},
message_id,
)
.expect("append history");
}
let mut llm = test_llm();
llm.auto_compact_token_limit = 800;
let request = LlmRunRequest::single_turn("system", "new request")
.with_model("fixture-model")
.with_max_output_tokens(16_000);
let before =
std::fs::read_to_string(root.path().join(".agent/conversations/project.jsonl"))
.expect("read history");
let prompt = build_direct_codex_history_prompt(
root.path(),
"new-turn",
"new request",
&request,
&llm,
)
.expect("build prompt");
let after = std::fs::read_to_string(root.path().join(".agent/conversations/project.jsonl"))
.expect("read history");
assert!(prompt.starts_with(DIRECT_CODEX_REPLAY_OMISSION_MARKER));
assert!(prompt.contains("tool: newest"));
assert!(!prompt.contains("user: old"));
assert!(prompt.ends_with("user: new request"));
assert_eq!(before, after);
}
#[test]
fn direct_history_prompt_rejects_current_request_that_exceeds_context() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "oversized-project", "oversized").expect("init");
let mut llm = test_llm();
llm.context_window_tokens = 5_000;
llm.auto_compact_token_limit = 1_000;
let request = LlmRunRequest::single_turn("system", "new request")
.with_model("fixture-model")
.with_max_output_tokens(100);
let error = build_direct_codex_history_prompt(
root.path(),
"new-turn",
&"request ".repeat(10_000),
&request,
&llm,
)
.expect_err("oversized request should fail");
assert!(error.contains("Direct replay 请求"));
}
#[test]
fn direct_item_activities_are_closed_safe_categories() {
let allowed = [
@@ -3835,6 +3835,7 @@ async fn run_direct_game_creator_turn_inner(
DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
})?;
let reply = if let Some(emitter) = turn_emitter {
let client_turn_id = emitter.turn_id().to_string();
let emitter = emitter.clone();
let mut has_streamed = false;
let mut latest_accumulated_text = None;
@@ -3856,6 +3857,7 @@ async fn run_direct_game_creator_turn_inner(
root,
system_prompt,
prompt.to_string(),
Some(&client_turn_id),
Some(&mut observer),
audit,
)
@@ -3866,6 +3868,7 @@ async fn run_direct_game_creator_turn_inner(
system_prompt,
prompt.to_string(),
None,
None,
audit,
)
.await
@@ -4189,6 +4192,27 @@ fn persist_direct_codex_assistant_reply_at(
.map(|_| ())
}
fn persist_direct_codex_user_prompt_at(
root: &Path,
client_turn_id: &str,
prompt: &str,
) -> Result<(), String> {
enforce_project_permission_policy(root, "conversation.write")?;
let _lock = acquire_project_write_lock(root, "conversation.write")?;
append_local_conversation_message_for_session_idempotent_at(
root,
None,
None,
LocalConversationMessage {
role: "user".to_string(),
content: prompt.trim().to_string(),
agent_id: None,
},
&format!("direct-codex:{client_turn_id}:user"),
)
.map(|_| ())
}
#[tauri::command]
pub(crate) async fn chat_with_game_creator_direct_codex(
project_path: String,
@@ -4220,6 +4244,15 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
return Err(error);
}
};
if let Err(error) = persist_direct_codex_user_prompt_at(root, &turn_id, &user_prompt) {
audit.finish(false);
turn_emitter.emit("failed", Some("none"), None);
return Err(redact_agent_runtime_error(
root,
&format!("Direct 用户消息持久化失败,已拒绝发起回合:{error}"),
500,
));
}
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
root,
&user_prompt,
@@ -4261,6 +4294,24 @@ pub(crate) async fn chat_with_game_creator_home_direct_codex(
mod tests {
use super::*;
fn direct_test_llm() -> GameCreatorLlmConfig {
GameCreatorLlmConfig {
api_key: "fixture-secret".to_string(),
base_url: "https://example.invalid/v1".to_string(),
model: "fixture-model".to_string(),
api_kind: "openai_responses".to_string(),
reasoning_effort: "high".to_string(),
stream: false,
web_search_enabled: false,
context_window_tokens: 128_000,
auto_compact_token_limit: 64_000,
tool_output_token_limit: 12_000,
request_timeout_ms: 10_000,
max_retries: 0,
retry_backoff_ms: 100,
}
}
#[test]
fn client_turn_id_is_strictly_normalized_and_bounded() {
assert_eq!(
@@ -4326,6 +4377,83 @@ mod tests {
);
}
#[test]
fn direct_user_prompt_is_persisted_idempotently_before_reply_and_survives_retry() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "direct-user-project", "用户消息落盘")
.expect("init project");
let turn_id = "client-turn-user-0001";
let normalized_prompt = "请先检查附件,再继续上一轮的游戏设计。\n\n项目附件:\n- 原文件名:需求.md;项目路径:需求.md;类型:text/markdown;大小:12 字节";
persist_direct_codex_user_prompt_at(root.path(), turn_id, normalized_prompt)
.expect("persist user prompt before turn");
persist_direct_codex_user_prompt_at(root.path(), turn_id, normalized_prompt)
.expect("retry reuses the same user message identity");
let conversation = read_local_conversation_for_session_at(root.path(), None, None)
.expect("read project conversation");
let message_id = format!("direct-codex:{turn_id}:user");
let persisted = conversation
.messages
.iter()
.filter(|message| message.message_id.as_deref() == Some(message_id.as_str()))
.collect::<Vec<_>>();
assert_eq!(persisted.len(), 1);
assert_eq!(persisted[0].role, "user");
assert_eq!(persisted[0].content, normalized_prompt);
assert!(
persist_direct_codex_user_prompt_at(root.path(), turn_id, "不同的重试请求")
.expect_err("same turn identity cannot be rebound")
.contains("messageId 冲突")
);
}
#[test]
fn direct_history_recovers_multiple_user_assistant_turns_after_thread_restart() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "direct-restart-project", "重启恢复")
.expect("init project");
let request = LlmRunRequest::single_turn("system", "继续实现第二轮需求")
.with_model("fixture-model")
.with_max_output_tokens(16_000);
persist_direct_codex_user_prompt_at(root.path(), "client-turn-0001", "先做一个主菜单")
.expect("persist first user turn");
persist_direct_codex_assistant_reply_at(
root.path(),
"client-turn-0001",
"主菜单已完成,下一步可以继续扩展关卡。",
)
.expect("persist first assistant turn");
persist_direct_codex_user_prompt_at(
root.path(),
"client-turn-0002",
"请在主菜单基础上增加关卡选择",
)
.expect("persist second user turn");
persist_direct_codex_assistant_reply_at(
root.path(),
"client-turn-0002",
"关卡选择页面已加入,并保留主菜单入口。",
)
.expect("persist second assistant turn");
// 模拟 app-server 重启后没有内存 thread:从项目 JSONL 重建时,
// 两轮完整的 user/assistant 上下文都必须保留。
let prompt = crate::agent::codex_app_server::build_direct_codex_history_prompt(
root.path(),
"client-turn-0003",
"继续实现第二轮需求",
&request,
&direct_test_llm(),
)
.expect("rebuild history after restart");
assert_eq!(
prompt,
"user: 先做一个主菜单\nassistant: 主菜单已完成,下一步可以继续扩展关卡。\nuser: 请在主菜单基础上增加关卡选择\nassistant: 关卡选择页面已加入,并保留主菜单入口。\nuser: 继续实现第二轮需求"
);
}
#[test]
fn direct_turn_update_payload_is_the_exact_camel_case_contract() {
let value = serde_json::to_value(GameCreatorDirectTurnUpdateEvent {
@@ -93,6 +93,10 @@ impl DirectGameCreatorTurnUpdateEmitter {
},
);
}
pub(crate) fn turn_id(&self) -> &str {
&self.turn_id
}
}
pub(crate) fn start_game_creator_manifest_invalidation_event_sink(
@@ -321,11 +321,11 @@ async fn suggest_ui_design_semantic(
}
#[tauri::command]
async fn recognize_ui(
async fn recognition_and_binding(
project_path: String,
state: ui_editor::state::State,
) -> Result<ui_editor::commands::RecognitionDTO, String> {
ui_editor::commands::recognize_ui_impl(project_path, state).await
) -> Result<ui_editor::commands::RecognitionAndBindingDTO, String> {
ui_editor::commands::recognition_and_binding_impl(project_path, state).await
}
#[tauri::command]
@@ -333,15 +333,6 @@ async fn merge_ui(state: ui_editor::state::State) -> Result<ui_editor::commands:
ui_editor::commands::merge_ui_impl(state).await
}
#[tauri::command]
async fn bind_components(
project_path: String,
state: ui_editor::state::State,
sprite_ids: Vec<String>,
) -> Result<ui_editor::commands::BindingDTO, String> {
ui_editor::commands::bind_components_impl(project_path, state, sprite_ids).await
}
#[tauri::command]
fn load_ui_design_state(
input: ui_editor::persistence::LoadUiDesignStateInput,
@@ -2540,9 +2531,8 @@ fn main() {
read_ui_editor_font_bytes,
check_ui_editor_font_glyph_coverage,
suggest_ui_design_semantic,
recognize_ui,
recognition_and_binding,
merge_ui,
bind_components,
load_ui_design_state,
save_ui_design_state,
generate_ui_design_code,
File diff suppressed because it is too large Load Diff
@@ -1,14 +1,13 @@
pub mod binding;
pub mod merge;
pub mod recognition;
pub mod recognition_and_binding;
pub mod ui_design_suggestion;
pub mod utils;
pub use binding::BindingDTO;
pub(crate) use binding::{bind_components_impl, bind_components_impl_with_provider};
pub(crate) use merge::merge_ui_impl;
pub use merge::MergeDTO;
pub(crate) use merge::{merge_ui_impl, merge_ui_impl_with_provider};
pub use recognition::RecognitionDTO;
pub(crate) use recognition::{recognize_ui_impl, recognize_ui_impl_with_provider};
pub use recognition_and_binding::RecognitionAndBindingDTO;
pub(crate) use recognition_and_binding::{
recognition_and_binding_impl, recognition_and_binding_impl_with_provider,
};
pub(crate) use ui_design_suggestion::suggest_ui_design_semantic_impl;
pub use ui_design_suggestion::UIDesignSuggestionTreeNode;
@@ -1,8 +1,4 @@
use crate::ui_editor::commands::binding::BindingChange;
use crate::ui_editor::commands::{
bind_components_impl_with_provider, merge_ui_impl_with_provider,
recognize_ui_impl_with_provider,
};
use crate::ui_editor::commands::recognition_and_binding_impl_with_provider;
use crate::ui_editor::layout::node::{Node, StageStatus};
use crate::ui_editor::persistence::{
initialize_ui_design_state_at, load_ui_design_state_at, save_ui_design_state_at,
@@ -96,7 +92,6 @@ struct UiWorkflowPageDeclaration {
#[serde(rename_all = "kebab-case")]
pub(crate) enum UiWorkflowPageStage {
ReferenceReady,
StructureReady,
BindingReady,
ApplicationReady,
Completed,
@@ -258,7 +253,7 @@ pub(crate) async fn run_ui_workflow_at_with_provider(
// binding commands as the editor. There is intentionally no
// deterministic fallback here: a missing provider or malformed
// response is returned to the caller and leaves the durable stage
// at reference-ready/structure-ready rather than claiming UI
// at reference-ready rather than claiming UI
// semantics were recognized.
recognize_page_semantics(root, &manifest.project_id, page, provider_identity).await?;
}
@@ -689,6 +684,17 @@ fn find_page_ui_resource(
{
return Err(format!("页面 {} 的 UI workflow 资源身份冲突", page.page_id));
}
let generation_kind = asset
.source
.generation_kind
.as_deref()
.ok_or_else(|| format!("页面 {} 的 UI workflow 阶段缺失", page.page_id))?;
if workflow_stage_rank(generation_kind).is_none() {
return Err(format!(
"页面 {} 的 UI workflow 阶段无效:{generation_kind}",
page.page_id
));
}
let mut expected_references = vec![
canonical_resource_id(source),
canonical_resource_id(design_asset),
@@ -760,7 +766,7 @@ fn ensure_page_ui_resource(
prompt: None,
model: None,
generation_route: None,
generation_kind: Some("ui-workflow".to_string()),
generation_kind: Some("ui-workflow.reference-ready".to_string()),
reference_resource_ids: {
let mut references = vec![
canonical_resource_id(source),
@@ -924,10 +930,9 @@ fn install_page_component_assets(
/// Runs the provider-backed editor pipeline for one workflow page.
///
/// `recognize_ui_impl` owns multimodal semantic recognition and strict tool
/// response validation. `bind_components_impl` owns visual component binding
/// and its allowlisted sprite validation. This wrapper only persists their
/// DTOs under the UI State revision gate; it never manufactures a tree when a
/// `recognition_and_binding_impl` owns the single multimodal request, strict
/// response validation, and allowlisted sprite validation. This wrapper only
/// persists its DTO under the UI State revision gate; it never manufactures a tree when a
/// provider is unavailable or returns an invalid result.
async fn recognize_page_semantics(
root: &Path,
@@ -942,225 +947,44 @@ async fn recognize_page_semantics(
expected_project_id: project_id.to_string(),
asset_id: page.ui_asset.id.clone(),
})?;
let mut stage = page
.ui_asset
.source
.generation_kind
.as_deref()
.unwrap_or("ui-workflow")
.to_string();
let has_page_tree = snapshot
.state
.ui_trees
.iter()
.filter(|tree| tree.src_ui_design == image_id)
.count()
== 1;
// Every provider-backed phase is persisted independently. A retry resumes
// from the latest truthful manifest stage instead of repeating completed
// calls or manufacturing fallback output.
if !matches!(
stage.as_str(),
"ui-workflow.structure-ready" | "ui-workflow.merge-ready" | "ui-workflow.binding-ready"
) {
let project_path = root.to_string_lossy().into_owned();
let recognition = recognize_ui_impl_with_provider(
project_path,
snapshot.state.clone(),
provider_identity,
)
.await
.map_err(|error| format!("页面 {} UI 语义识别失败:{error}", page.input.page_id))?;
if recognition.ui_trees.len() != 1 || recognition.ui_trees[0].src_ui_design != image_id {
return Err(format!(
"页面 {} UI 语义识别返回的树与页面设计图不匹配",
page.input.page_id
));
}
let mut state = snapshot.state;
state.ui_trees = recognition.ui_trees;
match save_ui_design_state_at(SaveUiDesignStateInput {
project_path: root.to_string_lossy().into_owned(),
expected_project_id: project_id.to_string(),
asset_id: page.ui_asset.id.clone(),
expected_revision: snapshot.revision,
state,
})? {
SaveUiDesignStateResult::Saved { .. } | SaveUiDesignStateResult::Unchanged { .. } => {}
SaveUiDesignStateResult::Conflict { .. } => {
return Err(format!(
"页面 {} UI 语义识别保存 revision 冲突,请重试",
page.input.page_id
));
}
}
update_page_manifest_stage(root, &page.ui_asset.id, "structure-ready")?;
stage = "ui-workflow.structure-ready".to_string();
} else if !has_page_tree {
let result = recognition_and_binding_impl_with_provider(
root.to_string_lossy().into_owned(),
snapshot.state.clone(),
provider_identity,
)
.await
.map_err(|error| format!("页面 {} UI 联合识别绑定失败:{error}", page.input.page_id))?;
if result.ui_trees.len() != 1 || result.ui_trees[0].src_ui_design != image_id {
return Err(format!(
"页面 {} manifest 已记录语义识别阶段,但 UI State 缺少唯一结构树",
"页面 {} UI 联合识别绑定返回的树与页面设计图不匹配",
page.input.page_id
));
}
if stage == "ui-workflow.binding-ready" {
return Ok(());
}
if stage == "ui-workflow.structure-ready" {
let merge_snapshot = load_ui_design_state_at(LoadUiDesignStateInput {
project_path: root.to_string_lossy().into_owned(),
expected_project_id: project_id.to_string(),
asset_id: page.ui_asset.id.clone(),
})?;
let merged = merge_ui_impl_with_provider(
root.to_string_lossy().into_owned(),
merge_snapshot.state.clone(),
provider_identity,
)
.await
.map_err(|error| format!("页面 {} UI 多树合并失败:{error}", page.input.page_id))?;
if merged.ui_tree.src_ui_design != image_id {
return Err(format!(
"页面 {} UI 多树合并结果未绑定主页面设计图",
page.input.page_id
));
}
let mut state = merge_snapshot.state;
state.ui_trees = vec![merged.ui_tree];
match save_ui_design_state_at(SaveUiDesignStateInput {
project_path: root.to_string_lossy().into_owned(),
expected_project_id: project_id.to_string(),
asset_id: page.ui_asset.id.clone(),
expected_revision: merge_snapshot.revision,
state,
})? {
SaveUiDesignStateResult::Saved { .. } | SaveUiDesignStateResult::Unchanged { .. } => {}
SaveUiDesignStateResult::Conflict { .. } => {
return Err(format!(
"页面 {} UI 多树合并保存 revision 冲突,请重试",
page.input.page_id
));
}
}
update_page_manifest_stage(root, &page.ui_asset.id, "merge-ready")?;
stage = "ui-workflow.merge-ready".to_string();
}
if stage != "ui-workflow.merge-ready" {
let mut state = snapshot.state;
state.ui_trees = result.ui_trees;
if !state_has_renderable_component(&state) {
return Err(format!(
"页面 {} UI workflow 阶段无法进入组件绑定:{stage}",
"页面 {} UI 联合识别绑定未形成可渲染组件",
page.input.page_id
));
}
let mut binding_snapshot = load_ui_design_state_at(LoadUiDesignStateInput {
match save_ui_design_state_at(SaveUiDesignStateInput {
project_path: root.to_string_lossy().into_owned(),
expected_project_id: project_id.to_string(),
asset_id: page.ui_asset.id.clone(),
})?;
let mut sprite_ids = binding_snapshot
.state
.sprite_assets
.keys()
.map(|id| id.as_str().to_string())
.collect::<Vec<_>>();
sprite_ids.sort();
if sprite_ids.is_empty() {
return Err(format!(
"页面 {} UI 语义识别已完成,但没有可用于组件绑定的页面素材",
page.input.page_id
));
}
let mut changed_nodes = 0usize;
for batch in sprite_ids.chunks(crate::ui_editor::commands::binding::ASSET_BATCH_SIZE) {
let binding = bind_components_impl_with_provider(
root.to_string_lossy().into_owned(),
binding_snapshot.state.clone(),
batch.to_vec(),
provider_identity,
)
.await
.map_err(|error| format!("页面 {} UI 组件语义绑定失败:{error}", page.input.page_id))?;
if binding.changes.is_empty() {
continue;
expected_revision: snapshot.revision,
state,
})? {
SaveUiDesignStateResult::Saved { .. } | SaveUiDesignStateResult::Unchanged { .. } => {}
SaveUiDesignStateResult::Conflict { .. } => {
return Err(format!(
"页面 {} UI 联合识别绑定保存 revision 冲突,请重试",
page.input.page_id
));
}
let mut state = binding_snapshot.state.clone();
let changes = binding
.changes
.into_iter()
.map(|change| (change.node_id.clone(), change))
.collect::<HashMap<_, _>>();
changed_nodes += apply_binding_changes(&mut state.ui_trees, &changes);
binding_snapshot = match save_ui_design_state_at(SaveUiDesignStateInput {
project_path: root.to_string_lossy().into_owned(),
expected_project_id: project_id.to_string(),
asset_id: page.ui_asset.id.clone(),
expected_revision: binding_snapshot.revision,
state,
})? {
SaveUiDesignStateResult::Saved {
state, revision, ..
}
| SaveUiDesignStateResult::Unchanged {
state, revision, ..
} => crate::ui_editor::persistence::UiDesignStateSnapshot { state, revision },
SaveUiDesignStateResult::Conflict { .. } => {
return Err(format!(
"页面 {} UI 组件绑定保存 revision 冲突,请重试",
page.input.page_id
));
}
};
}
if changed_nodes == 0 || !state_has_renderable_component(&binding_snapshot.state) {
return Err(format!(
"页面 {} UI 组件语义绑定未形成可渲染组件,拒绝进入 binding-ready",
page.input.page_id
));
}
let mut binding_blockers = Vec::new();
let mut component_count = 0usize;
for tree in &binding_snapshot.state.ui_trees {
collect_binding_blockers(&tree.root, &mut component_count, &mut binding_blockers);
}
if !binding_blockers.is_empty() {
// Keep the provider result available for review, but do not claim the
// binding stage. A subsequent recognize operation can retry binding
// from the durable structure-ready state.
return Ok(());
}
update_page_manifest_stage(root, &page.ui_asset.id, "binding-ready")
}
fn apply_binding_changes(
trees: &mut [crate::ui_editor::state::UITree],
changes: &HashMap<crate::ui_editor::utils::NodeId, BindingChange>,
) -> usize {
fn apply_node(
node: &mut Node,
changes: &HashMap<crate::ui_editor::utils::NodeId, BindingChange>,
) -> usize {
let mut changed = 0;
if let Some(change) = changes.get(&node.id) {
node.components = change.components.clone();
node.metadata.components_status = change.components_status.clone();
changed += 1;
}
for child in &mut node.children {
changed += apply_node(child, changes);
}
changed
}
trees
.iter_mut()
.map(|tree| apply_node(&mut tree.root, changes))
.sum()
}
fn state_has_renderable_component(state: &crate::ui_editor::state::State) -> bool {
fn has_component(node: &Node) -> bool {
!node.components.is_empty() || node.children.iter().any(has_component)
@@ -1183,13 +1007,16 @@ fn update_page_manifest_stage(root: &Path, asset_id: &str, stage: &str) -> Resul
return Err(format!("UI workflow 资源 {} 类型不匹配", asset_id));
}
let next_kind = format!("ui-workflow.{stage}");
let current_rank = asset
let current_kind = asset
.source
.generation_kind
.as_deref()
.and_then(workflow_stage_rank)
.unwrap_or(0);
let next_rank = workflow_stage_rank(&next_kind).unwrap_or(0);
.ok_or_else(|| format!("UI workflow 资源 {} 阶段缺失", asset_id))?;
let current_rank = workflow_stage_rank(current_kind).ok_or_else(|| {
format!("UI workflow 资源 {} 阶段无效:{current_kind}", asset_id)
})?;
let next_rank = workflow_stage_rank(&next_kind)
.ok_or_else(|| format!("UI workflow 下一阶段无效:{next_kind}"))?;
if current_rank >= next_rank {
return Ok(false);
}
@@ -1207,11 +1034,9 @@ fn update_page_manifest_stage(root: &Path, asset_id: &str, stage: &str) -> Resul
fn workflow_stage_rank(kind: &str) -> Option<u8> {
match kind {
"ui-workflow.reference-ready" => Some(1),
"ui-workflow.structure-ready" => Some(2),
"ui-workflow.merge-ready" => Some(3),
"ui-workflow.binding-ready" => Some(4),
"ui-workflow.application-ready" => Some(5),
"ui-workflow.completed" => Some(6),
"ui-workflow.binding-ready" => Some(2),
"ui-workflow.application-ready" => Some(3),
"ui-workflow.completed" => Some(4),
_ => None,
}
}
@@ -1268,7 +1093,6 @@ fn derive_page_status(
if matching_trees.len() != 1 {
blockers.push("尚未形成唯一的页面 UI 结构树".to_string());
} else {
stage = UiWorkflowPageStage::StructureReady;
let mut component_count = 0usize;
collect_binding_blockers(&matching_trees[0].root, &mut component_count, &mut blockers);
if component_count == 0 {
+79 -94
View File
@@ -297,56 +297,6 @@ function directCodexConversationMessageId(
return `${DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX}${turnId}:${role}`;
}
function directCodexConversationTurnId(
message: ChatMessage,
role: ChatMessage['role'],
) {
if (message.role !== role) {
return null;
}
const messageId = message.messageId?.trim() ?? '';
const roleSuffix = `:${role}`;
if (
!messageId.startsWith(DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX) ||
!messageId.endsWith(roleSuffix)
) {
return null;
}
const turnId = messageId.slice(
DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX.length,
-roleSuffix.length,
);
return /^[a-z0-9][a-z0-9-]{5,159}$/iu.test(turnId) ? turnId : null;
}
export function unansweredDirectCodexConversationTurn(messages: ChatMessage[]) {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (!message) {
continue;
}
const turnId = directCodexConversationTurnId(message, 'user');
if (!turnId) {
continue;
}
const assistantMessageId = directCodexConversationMessageId(
turnId,
'assistant',
);
if (
messages.some(
(candidate) =>
candidate.role === 'assistant' &&
candidate.messageId?.trim() === assistantMessageId,
)
) {
continue;
}
return { prompt: message.text, turnId };
}
return null;
}
export function isDirectCodexTurnAlreadyRunningError(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return message
@@ -544,6 +494,12 @@ export function App({
useState<number | null>(null);
const [directCodexTransientReply, setDirectCodexTransientReply] =
useState('');
const directCodexTransientReplyRef = useRef('');
const directCodexInterruptedPartialRef = useRef<{
projectPath: string;
text: string;
messageId: string;
} | null>(null);
const [
directCodexTransientReplyUpdatedAt,
setDirectCodexTransientReplyUpdatedAt,
@@ -583,6 +539,7 @@ export function App({
setDirectCodexProgress('');
setDirectCodexProgressUpdatedAt(null);
setDirectCodexTransientReply('');
directCodexTransientReplyRef.current = '';
setDirectCodexTransientReplyUpdatedAt(null);
}
@@ -596,6 +553,7 @@ export function App({
}
activeDirectCodexTurnRef.current = null;
setDirectCodexTransientReply('');
directCodexTransientReplyRef.current = '';
setDirectCodexTransientReplyUpdatedAt(null);
return true;
}
@@ -1234,10 +1192,10 @@ export function App({
: Date.now();
if (payload.status === 'failed') {
activeDirectCodexTurnRef.current = null;
setDirectCodexProgress('处理失败,正在同步错误');
setDirectCodexProgressUpdatedAt(updatedAt);
setDirectCodexTransientReply('');
setDirectCodexTransientReplyUpdatedAt(null);
setDirectCodexProgress('处理失败,正在同步错误');
setDirectCodexProgressUpdatedAt(updatedAt);
return;
}
if (payload.status === 'completed') {
@@ -1249,6 +1207,7 @@ export function App({
}
if (typeof payload.accumulatedText === 'string') {
setDirectCodexTransientReply(payload.accumulatedText);
directCodexTransientReplyRef.current = payload.accumulatedText;
setDirectCodexTransientReplyUpdatedAt(updatedAt);
}
},
@@ -1844,9 +1803,8 @@ export function App({
);
if (localProjectPathRef.current === nextProjectPath) {
// Any history read started before this terminal append may hold
// a user-only snapshot. Invalidate it before releasing the
// in-memory claim so that stale hydration cannot replay the
// same billable Direct turn.
// A stale history snapshot may still be missing this terminal
// append. Invalidate it before releasing the in-memory claim.
projectSupervisorHistoryLoadVersionRef.current += 1;
}
recoveredDirectCodexTurnClaimsRef.current.delete(claimKey);
@@ -2702,9 +2660,6 @@ export function App({
projectConversation.messages,
supervisorConversation?.messages ?? [],
);
const unansweredDirectTurn = directCodexProductRuntime
? unansweredDirectCodexConversationTurn(conversationMessages)
: null;
if (
conversationContainsProjectSupervisorResponseStream(
supervisorConversation?.messages ?? [],
@@ -2756,24 +2711,6 @@ export function App({
});
return nextConversationMessages;
});
if (unansweredDirectTurn) {
const claimKey = `${nextProjectPath}\u0000${unansweredDirectTurn.turnId}`;
void Promise.resolve().then(() => {
if (
projectSupervisorHistoryLoadVersionRef.current !== loadVersion ||
localProjectPathRef.current !== nextProjectPath ||
activeDirectCodexTurnRef.current ||
recoveredDirectCodexTurnClaimsRef.current.has(claimKey)
) {
return;
}
recoveredDirectCodexTurnClaimsRef.current.add(claimKey);
void executeChatAgentReply({
prompt: unansweredDirectTurn.prompt,
clientTurnId: unansweredDirectTurn.turnId,
});
});
}
} catch (error) {
if (
projectSupervisorHistoryLoadVersionRef.current !== loadVersion ||
@@ -5464,6 +5401,23 @@ export function App({
},
},
);
const persistDirectPartialMessage = (messageId: string, text: string) =>
directInvoke<LocalConversationResult>(
'append_local_conversation_message',
{
projectPath: directProjectPath,
agentId: null,
messageId,
message: {
role: 'assistant',
// An interrupted partial is intentionally a normal assistant
// record so replay sees exactly what Codex emitted before the
// disconnect; the marker is product data, not UI metadata.
content: `${text.trim()}\nunexpected interrupt happened here`,
agentId: null,
},
},
);
const recoveredDirectCodexTurnClaimKey = `${directProjectPath}\u0000${clientTurnId}`;
recoveredDirectCodexTurnClaimsRef.current.add(
recoveredDirectCodexTurnClaimKey,
@@ -5478,26 +5432,40 @@ export function App({
setDirectCodexProgress('已发送消息,正在等待陶泥儿回复');
setDirectCodexProgressUpdatedAt(Date.now());
setDirectCodexTransientReply('');
directCodexTransientReplyRef.current = '';
setDirectCodexTransientReplyUpdatedAt(null);
setProjectSupervisorRuntimeError('');
try {
// Persist the original user intent and stable turn identity before
// Codex can start any billable or externally visible work. The
// regular conversation writer may race this call, but messageId
// idempotency makes both writers converge on the same record.
await directInvoke<LocalConversationResult>(
'append_local_conversation_message',
{
projectPath: directProjectPath,
agentId: null,
messageId: directUserMessageId,
message: {
role: 'user',
content: prompt,
const interruptedPartial = directCodexInterruptedPartialRef.current;
if (
interruptedPartial?.projectPath === directProjectPath &&
interruptedPartial.text.trim()
) {
await persistDirectPartialMessage(
interruptedPartial.messageId,
interruptedPartial.text,
);
directCodexInterruptedPartialRef.current = null;
}
// Rust owns the normalized user record for attachment turns so the
// durable message includes the same bounded project mapping that is
// sent to Codex. Plain turns keep the optimistic browser write; the
// Rust writer then converges on it through messageId idempotency.
if (!attachments?.length) {
await directInvoke<LocalConversationResult>(
'append_local_conversation_message',
{
projectPath: directProjectPath,
agentId: null,
messageId: directUserMessageId,
message: {
role: 'user',
content: prompt,
agentId: null,
},
},
},
);
);
}
const directTurnInput: {
projectPath: string;
prompt: string;
@@ -5531,10 +5499,10 @@ export function App({
}
}
// Rust persists a successful Direct reply before returning Ok. The
// browser append is redundant, so the hydrated-turn claim can be
// browser append is redundant, so the in-memory turn claim can be
// released without reopening the Provider side effect. Invalidate
// any user-only history snapshot captured before Rust committed the
// terminal reply first.
// any history snapshot captured before Rust committed the terminal
// reply first.
if (localProjectPathRef.current === directProjectPath) {
projectSupervisorHistoryLoadVersionRef.current += 1;
}
@@ -5574,6 +5542,23 @@ export function App({
'陶泥儿智能创作',
true,
);
const partial = directCodexTransientReplyRef.current.trim();
if (partial) {
const partialMessageId =
globalThis.crypto?.randomUUID?.() ||
`direct-partial-${Date.now().toString(36)}`;
directCodexInterruptedPartialRef.current = {
projectPath: directProjectPath,
text: partial,
messageId: partialMessageId,
};
try {
await persistDirectPartialMessage(partialMessageId, partial);
} catch {
// The next user send retries this idempotent append before
// constructing the replay prompt.
}
}
try {
await persistDirectAssistantMessage(visibleMessage);
if (localProjectPathRef.current === directProjectPath) {
@@ -1,21 +0,0 @@
import type { BindingDTO } from './types/BindingDTO';
import type { Node } from './types/Node';
import type { State } from './types/State';
function applyChanges(node: Node, result: BindingDTO): void {
const change = result.changes.find(
(candidate) => candidate.node_id === node.id,
);
if (change) {
node.components = structuredClone(change.components);
node.metadata.components_status = structuredClone(change.components_status);
}
for (const child of node.children) applyChanges(child, result);
}
/** Applies only explicit component changes; omitted nodes remain untouched. */
export function applyBindingResult(state: State, result: BindingDTO): State {
const next = structuredClone(state);
for (const tree of next.ui_trees) applyChanges(tree.root, result);
return next;
}
@@ -1,3 +1,6 @@
export const SPRITE_CHECKERBOARD_CLASS_NAME =
'bg-[linear-gradient(45deg,#eee_25%,transparent_25%),linear-gradient(-45deg,#eee_25%,transparent_25%),linear-gradient(45deg,transparent_75%,#eee_75%),linear-gradient(-45deg,transparent_75%,#eee_75%)] bg-[length:14px_14px]';
export function SpriteImagePreview({
src,
alt,
@@ -1,16 +0,0 @@
import type { RecognitionDTO } from './types/RecognitionDTO';
import type { State } from './types/State';
/**
* 识别结果是整棵结构草稿树的替换结果,不与旧树逐节点合并。
* 识别阶段有意不携带视觉组件;组件绑定由后续 visual-binding 阶段完成。
*/
export function applyRecognitionResult(
state: State,
result: RecognitionDTO,
): State {
return {
...structuredClone(state),
ui_trees: structuredClone(result.ui_trees),
};
}
@@ -0,0 +1,15 @@
import type { RecognitionAndBindingDTO } from './types/RecognitionAndBindingDTO';
import type { State } from './types/State';
/**
* 联合识别绑定结果是整棵最终树的替换结果,不与旧树逐节点合并。
*/
export function applyRecognitionAndBindingResult(
state: State,
result: RecognitionAndBindingDTO,
): State {
return {
...structuredClone(state),
ui_trees: structuredClone(result.ui_trees),
};
}
@@ -1,6 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Component } from "./Component";
import type { NodeId } from "./NodeId";
import type { StageStatus } from "./StageStatus";
export type BindingChange = { node_id: NodeId, components: Array<Component>, components_status: StageStatus, };
@@ -1,4 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { BindingChange } from "./BindingChange";
export type BindingDTO = { changes: Array<BindingChange>, };
@@ -1,4 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { UITree } from "./UITree";
export type RecognitionDTO = { ui_trees: Array<UITree>, };
export type RecognitionAndBindingDTO = { ui_trees: Array<UITree>, };
@@ -33,6 +33,8 @@ export const EMPTY_UI_EDITOR_STATE: State = {
font_assets: {},
};
const MAX_HISTORY_LENGTH = 100;
export type UiEditorOperationFailureReason =
| 'locked'
| 'duplicate'
@@ -45,6 +47,15 @@ export type UiEditorOperationResult<T = undefined> =
| { ok: true; value: T }
| { ok: false; reason: UiEditorOperationFailureReason };
export type UiEditorHistoryState = {
canUndo: boolean;
canRedo: boolean;
};
export type UiEditorReplaceStateOptions = {
history?: 'record' | 'reset' | 'skip';
};
type UiEditorOperationFailure = Extract<UiEditorOperationResult, { ok: false }>;
export type NodeMetadataPatch = Partial<
@@ -227,8 +238,47 @@ function cloneState(state: State): State {
return structuredClone(state);
}
function sameResource<T>(left: T, right: T): boolean {
return JSON.stringify(left) === JSON.stringify(right);
function sameResource<T>(
left: T,
right: T,
seenPairs = new WeakMap<object, WeakSet<object>>(),
): boolean {
if (Object.is(left, right)) return true;
if (
typeof left !== 'object' ||
left === null ||
typeof right !== 'object' ||
right === null
) {
return false;
}
const leftObject = left as object;
const rightObject = right as object;
let seenRightObjects = seenPairs.get(leftObject);
if (seenRightObjects?.has(rightObject)) return true;
if (!seenRightObjects) {
seenRightObjects = new WeakSet<object>();
seenPairs.set(leftObject, seenRightObjects);
}
seenRightObjects.add(rightObject);
if (Array.isArray(left) || Array.isArray(right)) {
if (!Array.isArray(left) || !Array.isArray(right)) return false;
if (left.length !== right.length) return false;
return left.every((value, index) =>
sameResource(value, right[index], seenPairs),
);
}
const leftRecord = left as Record<string, unknown>;
const rightRecord = right as Record<string, unknown>;
const leftKeys = Object.keys(leftRecord);
const rightKeys = Object.keys(rightRecord);
if (leftKeys.length !== rightKeys.length) return false;
return leftKeys.every(
(key) =>
Object.prototype.hasOwnProperty.call(rightRecord, key) &&
sameResource(leftRecord[key], rightRecord[key], seenPairs),
);
}
function visitComponents(nodes: Node[], visit: (component: Component) => void) {
@@ -505,15 +555,82 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
return next;
});
const [isLocked, setIsLocked] = useState(false);
const [historyState, setHistoryState] = useState<UiEditorHistoryState>({
canUndo: false,
canRedo: false,
});
const stateRef = useRef(state);
const isLockedRef = useRef(false);
const undoStackRef = useRef<Array<{ before: State; after: State }>>([]);
const redoStackRef = useRef<Array<{ before: State; after: State }>>([]);
const pendingHistoryBeforeRef = useRef<State | null>(null);
stateRef.current = state;
const commit = useCallback((nextState: State) => {
const syncHistoryState = useCallback(() => {
setHistoryState({
canUndo: undoStackRef.current.length > 0,
canRedo: redoStackRef.current.length > 0,
});
}, []);
const applyState = useCallback((nextState: State) => {
stateRef.current = nextState;
setState(nextState);
}, []);
const commit = useCallback(
(nextState: State) => {
const current = stateRef.current;
const before = pendingHistoryBeforeRef.current ?? current;
if (sameResource(before, nextState)) {
pendingHistoryBeforeRef.current = null;
return false;
}
undoStackRef.current.push({
before: cloneState(before),
after: nextState,
});
if (undoStackRef.current.length > MAX_HISTORY_LENGTH) {
undoStackRef.current.shift();
}
redoStackRef.current = [];
pendingHistoryBeforeRef.current = null;
syncHistoryState();
applyState(nextState);
return true;
},
[applyState, syncHistoryState],
);
const resetHistory = useCallback(() => {
undoStackRef.current = [];
redoStackRef.current = [];
pendingHistoryBeforeRef.current = null;
syncHistoryState();
}, [syncHistoryState]);
const undo = useCallback(() => {
if (isLockedRef.current) return false;
const entry = undoStackRef.current.pop();
if (!entry) return false;
pendingHistoryBeforeRef.current = null;
redoStackRef.current.push(entry);
applyState(cloneState(entry.before));
syncHistoryState();
return true;
}, [applyState, syncHistoryState]);
const redo = useCallback(() => {
if (isLockedRef.current) return false;
const entry = redoStackRef.current.pop();
if (!entry) return false;
pendingHistoryBeforeRef.current = null;
undoStackRef.current.push(entry);
applyState(cloneState(entry.after));
syncHistoryState();
return true;
}, [applyState, syncHistoryState]);
const guard = useCallback((): UiEditorOperationFailure | null => {
// Every semantic write exits before reading or committing State while locked.
return isLockedRef.current ? { ok: false, reason: 'locked' } : null;
@@ -534,6 +651,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
try {
return await operation(snapshot);
} finally {
pendingHistoryBeforeRef.current = null;
isLockedRef.current = false;
setIsLocked(false);
}
@@ -1397,22 +1515,38 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
const clearState = useCallback((): UiEditorOperationResult => {
const blocked = guard();
if (blocked) return blocked;
commit(cloneState(EMPTY_UI_EDITOR_STATE));
resetHistory();
applyState(cloneState(EMPTY_UI_EDITOR_STATE));
return { ok: true, value: undefined };
}, [commit, guard]);
}, [applyState, guard, resetHistory]);
const replaceState = useCallback(
(nextState: State) => {
(nextState: State, options: UiEditorReplaceStateOptions = {}) => {
const next = cloneState(nextState);
synchronizeDesignImageTrees(next);
commit(next);
if (options.history === 'reset') {
resetHistory();
applyState(next);
} else if (options.history === 'skip') {
if (!pendingHistoryBeforeRef.current) {
pendingHistoryBeforeRef.current = cloneState(stateRef.current);
}
applyState(next);
} else {
commit(next);
}
},
[commit],
[applyState, commit, resetHistory],
);
return {
state,
historyState,
undo,
redo,
resetHistory,
isLocked,
runWithStateLocked,
setImageName,
@@ -1,6 +1,7 @@
import { Image as ImageIcon, Plus, Type } from 'lucide-react';
import { useMemo } from 'react';
import { SPRITE_CHECKERBOARD_CLASS_NAME } from '../../../features/ui-editor/components/SpriteImagePreview';
import { visitUiNodes } from '../../../features/ui-editor/treeUtils';
import type { Node as UiNode } from '../../../features/ui-editor/types/Node';
import type { NodeId } from '../../../features/ui-editor/types/NodeId';
@@ -184,7 +185,7 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
<button
key={sprite.asset_id}
type="button"
className="relative aspect-square overflow-hidden rounded-lg border border-(--platform-subpanel-border) bg-[linear-gradient(45deg,#eee_25%,transparent_25%),linear-gradient(-45deg,#eee_25%,transparent_25%),linear-gradient(45deg,transparent_75%,#eee_75%),linear-gradient(-45deg,transparent_75%,#eee_75%)] bg-[length:14px_14px]"
className={`relative aspect-square overflow-hidden rounded-lg border border-(--platform-subpanel-border) ${SPRITE_CHECKERBOARD_CLASS_NAME}`}
title={`${sprite.metadata.name} · 引用 ${spriteReferenceCounts[sprite.asset_id] ?? 0}`}
onClick={() => input.selectSprite(sprite.asset_id)}
>
@@ -293,6 +293,7 @@ function renderComponentEditor(
<ImagePanel
component={component.Image}
sprites={props.sprites}
previewUrls={props.previewUrls}
readOnly={readOnly}
onChange={(next) => updateComponent(index, { Image: next })}
/>
@@ -0,0 +1,165 @@
import { Check, ImageIcon, X } from 'lucide-react';
import { useState } from 'react';
import { ThemedModal } from '../../../../../components/modal/ThemedModal';
import { SPRITE_CHECKERBOARD_CLASS_NAME } from '../../../../../features/ui-editor/components/SpriteImagePreview';
import type { SpriteAsset } from '../../../../../features/ui-editor/types/SpriteAsset';
export function ImageAssetSelector({
value,
sprites,
previewUrls,
readOnly,
onChange,
}: {
value: string | null;
sprites: Record<string, SpriteAsset>;
previewUrls: Record<string, string>;
readOnly: boolean;
onChange: (value: string | null) => void;
}) {
const [open, setOpen] = useState(false);
const [failedPreviewUrls, setFailedPreviewUrls] = useState<
Record<string, string>
>({});
const selectedSprite = value ? sprites[value] : undefined;
let selectedLabel = '未绑定';
if (selectedSprite) {
selectedLabel = selectedSprite.metadata.name || value || '未绑定';
} else if (value) {
selectedLabel = `素材不存在(${value}`;
}
const buttonLabel = value ? '更换素材' : '选择素材';
function close() {
setOpen(false);
}
function select(nextValue: string | null) {
onChange(nextValue);
close();
}
function markPreviewFailed(id: string, url: string) {
setFailedPreviewUrls((current) => {
if (current[id] === url) return current;
return { ...current, [id]: url };
});
}
return (
<>
<div className="block text-[11px] font-semibold text-(--platform-text-soft)">
<div className="mt-1 flex items-center gap-2">
<div
className={`min-w-0 flex-1 truncate rounded-lg border border-(--platform-subpanel-border) bg-white/65 px-2 py-2 text-xs ${value && !selectedSprite ? 'text-red-600' : 'text-(--platform-text-strong)'}`}
title={selectedLabel}
>
{selectedLabel}
</div>
<button
type="button"
className="h-9 shrink-0 rounded-lg border border-orange-200 bg-orange-50 px-3 text-xs font-semibold text-orange-800 disabled:cursor-not-allowed disabled:opacity-40"
disabled={readOnly}
onClick={() => setOpen(true)}
>
{buttonLabel}
</button>
</div>
</div>
<ThemedModal
open={open}
onClose={close}
ariaLabel="选择图片素材"
panelClassName="flex max-h-[80vh] w-[720px] max-w-[calc(100vw-2rem)] flex-col rounded-2xl p-5"
>
<div className="flex items-center justify-between gap-3">
<h2 className="m-0 text-base font-semibold"></h2>
<button
type="button"
className="grid size-8 place-items-center rounded-lg text-(--platform-text-soft) hover:bg-black/5"
aria-label="关闭素材选择器"
onClick={close}
>
<X size={16} aria-hidden="true" />
</button>
</div>
<div className="mt-4 min-h-0 flex-1 overflow-y-auto pr-1">
<div className="grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-3">
<button
type="button"
className={`flex min-h-36 flex-col items-center justify-center rounded-xl border border-dashed p-3 text-xs ${value === null ? 'border-orange-400 bg-orange-50 text-orange-800' : 'border-(--platform-subpanel-border) bg-white/45 text-(--platform-text-soft)'}`}
aria-pressed={value === null}
onClick={() => select(null)}
>
<X size={22} aria-hidden="true" />
<span className="mt-2 font-semibold"></span>
<span className="mt-1 text-[10px]"></span>
</button>
{value && !selectedSprite ? (
<div className="flex min-h-36 flex-col items-center justify-center rounded-xl border border-red-200 bg-red-50 p-3 text-center text-xs text-red-700">
<ImageIcon size={22} aria-hidden="true" />
<span className="mt-2 font-semibold"></span>
<span className="mt-1 break-all text-[10px]">{value}</span>
</div>
) : null}
{Object.entries(sprites).map(([id, sprite]) => {
const previewUrl = previewUrls[id];
const hasPreview =
Boolean(previewUrl) && failedPreviewUrls[id] !== previewUrl;
const selected = value === id;
return (
<button
key={id}
type="button"
className={`relative overflow-hidden rounded-xl border p-2 text-left transition ${selected ? 'border-orange-400 bg-orange-50 ring-2 ring-orange-200' : 'border-(--platform-subpanel-border) bg-white/45 hover:border-orange-200'}`}
aria-pressed={selected}
onClick={() => select(id)}
>
<div
className={`grid aspect-[4/3] place-items-center overflow-hidden rounded-lg ${SPRITE_CHECKERBOARD_CLASS_NAME}`}
>
{hasPreview ? (
<img
src={previewUrl}
alt={sprite.metadata.name || id}
className="size-full object-contain"
onError={() => {
if (previewUrl) markPreviewFailed(id, previewUrl);
}}
/>
) : (
<ImageIcon
size={24}
className="text-(--platform-text-soft)"
aria-hidden="true"
/>
)}
</div>
<span className="mt-2 block truncate text-xs font-semibold text-(--platform-text-strong)">
{sprite.metadata.name || id}
</span>
{selected ? (
<span className="absolute right-2 top-2 grid size-6 place-items-center rounded-full bg-orange-500 text-white">
<Check size={14} aria-hidden="true" />
</span>
) : null}
</button>
);
})}
</div>
{Object.keys(sprites).length === 0 ? (
<p className="m-0 py-10 text-center text-xs text-(--platform-text-soft)">
</p>
) : null}
</div>
</ThemedModal>
</>
);
}
@@ -4,10 +4,12 @@ import type { ImageType } from '../../../../../features/ui-editor/types/ImageTyp
import type { UiEditorOperationResult } from '../../../../../features/ui-editor/useUiEditorState';
import type { ImageEditorProps } from './componentEditorTypes';
import { ComponentNumberInput, ComponentSelect } from './ComponentField';
import { ImageAssetSelector } from './ImageAssetSelector';
export function ImagePanel({
component,
sprites,
previewUrls,
readOnly,
onChange,
}: ImageEditorProps) {
@@ -17,24 +19,13 @@ export function ImagePanel({
return (
<div className="space-y-3">
<ComponentSelect
label="目标素材"
value={component.target_graphic ?? ''}
disabled={readOnly}
onChange={(event) =>
update({
...component,
target_graphic: event.target.value || null,
})
}
>
<option value=""></option>
{Object.entries(sprites).map(([id, sprite]) => (
<option key={id} value={id}>
{sprite.metadata.name || id}
</option>
))}
</ComponentSelect>
<ImageAssetSelector
value={component.target_graphic}
sprites={sprites}
previewUrls={previewUrls}
readOnly={readOnly}
onChange={(target_graphic) => update({ ...component, target_graphic })}
/>
<ComponentSelect
label="图片类型"
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { type RgbaColor, RgbaColorPicker } from 'react-colorful';
import type { FontSizing } from '../../../../../features/ui-editor/types/FontSizing';
@@ -21,19 +21,49 @@ export function TextPanel({
onChange,
}: TextEditorProps) {
const [colorOpen, setColorOpen] = useState(false);
const rgba: RgbaColor = {
const [colorDraft, setColorDraft] = useState<RgbaColor | null>(null);
const colorDraftRef = useRef<RgbaColor | null>(null);
const componentRef = useRef(component);
const onChangeRef = useRef(onChange);
componentRef.current = component;
onChangeRef.current = onChange;
const committedRgba: RgbaColor = {
r: component.color[0] ?? 255,
g: component.color[1] ?? 255,
b: component.color[2] ?? 255,
a: (component.color[3] ?? 255) / 255,
};
const rgba = colorDraft ?? committedRgba;
useEffect(() => {
colorDraftRef.current = null;
setColorDraft(null);
}, [component.color]);
useEffect(
() => () => {
const draft = colorDraftRef.current;
if (!draft) return;
onChangeRef.current({
...componentRef.current,
color: [draft.r, draft.g, draft.b, Math.round(draft.a * 255)],
});
},
[],
);
const bestFit =
'BestFit' in component.font_sizing ? component.font_sizing.BestFit : null;
const updateColor = (next: RgbaColor) =>
const commitColor = (next: RgbaColor = rgba) => {
if (!colorDraftRef.current) return;
colorDraftRef.current = null;
setColorDraft(null);
onChange({
...component,
color: [next.r, next.g, next.b, Math.round(next.a * 255)],
});
};
const toggleColorOpen = () => {
if (colorOpen) commitColor();
setColorOpen((open) => !open);
};
return (
<div className="space-y-3">
@@ -159,7 +189,7 @@ export function TextPanel({
type="button"
className="mt-1 flex h-9 w-full items-center gap-2 rounded-lg border border-(--platform-subpanel-border) bg-white/65 px-2 text-left text-xs disabled:opacity-40"
disabled={readOnly}
onClick={() => setColorOpen((open) => !open)}
onClick={toggleColorOpen}
>
<span
className="size-5 rounded border border-black/15"
@@ -172,24 +202,35 @@ export function TextPanel({
</ComponentField>
{colorOpen && !readOnly ? (
<div className="absolute right-0 top-full z-20 mt-2 w-64 rounded-xl border border-(--platform-subpanel-border) bg-white p-3 shadow-xl">
<RgbaColorPicker color={rgba} onChange={updateColor} />
<div
onPointerCancel={() => {
colorDraftRef.current = null;
setColorDraft(null);
}}
>
<RgbaColorPicker
color={rgba}
onChange={(next) => {
colorDraftRef.current = next;
setColorDraft(next);
}}
onChangeEnd={commitColor}
/>
</div>
<ComponentNumberInput
label="Alpha"
min={0}
max={255}
step={1}
value={component.color[3]}
onChange={(event) =>
value={Math.round(rgba.a * 255)}
onChange={(event) => {
colorDraftRef.current = null;
setColorDraft(null);
onChange({
...component,
color: [
component.color[0],
component.color[1],
component.color[2],
Number(event.target.value),
],
})
}
color: [rgba.r, rgba.g, rgba.b, Number(event.target.value)],
});
}}
/>
</div>
) : null}
@@ -9,6 +9,7 @@ import type { UiEditorOperationResult } from '../../../../../features/ui-editor/
export type ComponentPanelProps = {
components: Component[];
sprites: Record<string, SpriteAsset>;
previewUrls: Record<string, string>;
fonts: Record<string, FontAsset>;
fontFaces: Record<string, UiEditorFontFaceState>;
projectPath: string;
@@ -35,6 +36,7 @@ export type ComponentEditorProps<T> = {
export type ImageEditorProps = ComponentEditorProps<ImageComponent> & {
sprites: Record<string, SpriteAsset>;
previewUrls: Record<string, string>;
};
export type TextEditorProps = ComponentEditorProps<TextComponent> & {
@@ -111,6 +111,7 @@ export function InspectorSidebar({
onTransformChange={inspector.setNodeTransform}
onLayoutChange={inspector.setNodeLayout}
sprites={inspector.sprites}
previewUrls={inspector.previewUrls}
fonts={inspector.fonts}
fontFaces={inspector.fontFaces}
projectPath={inspector.projectPath}
@@ -288,6 +289,7 @@ function NodeInspector({
onTransformChange,
onLayoutChange,
sprites,
previewUrls,
fonts,
fontFaces,
projectPath,
@@ -317,6 +319,7 @@ function NodeInspector({
onTransformChange: UiEditorInspectorProjection['setNodeTransform'];
onLayoutChange: UiEditorInspectorProjection['setNodeLayout'];
sprites: UiEditorInspectorProjection['sprites'];
previewUrls: UiEditorInspectorProjection['previewUrls'];
fonts: UiEditorInspectorProjection['fonts'];
fontFaces: UiEditorInspectorProjection['fontFaces'];
projectPath: string;
@@ -497,6 +500,7 @@ function NodeInspector({
<ComponentPanel
components={node.components}
sprites={sprites}
previewUrls={previewUrls}
fonts={fonts}
fontFaces={fontFaces}
projectPath={projectPath}

Some files were not shown because too many files have changed in this diff Show More