修复直连Codex美术资源实时刷新

美术资源与恢复结果提交后发送manifest失效事件。

直连回合失败或超时后仍对账已提交资源。

完善UI编辑器与资源工作台的返回和布局边界。

补充实时刷新回归测试与技术决策文档。
This commit is contained in:
2026-08-21 15:52:36 +08:00
parent 94b3feeb72
commit 479ed73f89
10 changed files with 281 additions and 54 deletions
@@ -36,6 +36,7 @@ const DIRECT_CODEX_ART_ASSET_PATHS: [&str; 3] = [
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
DIRECT_CODEX_SPRITESHEET_ASSET_PATH,
];
const DIRECT_CODEX_ART_AGENT_ID: &str = "direct-codex-art";
const DIRECT_CODEX_GAME_OUTPUTS: [(&str, &str, &str); 3] = [
("game/index.html", "game-entry", "text/html"),
("game/style.css", "game-style", "text/css"),
@@ -249,7 +250,7 @@ fn direct_taonier_art_generation_runtime_context(
_ => return Err("直连美术生成请求包含未声明的输出路径".to_string()),
};
Ok(PlatformArtGenerationRuntimeContext {
agent_id: "direct-codex-art".to_string(),
agent_id: DIRECT_CODEX_ART_AGENT_ID.to_string(),
task_id: format!("direct-codex-art-{stage}"),
session_id: project_id,
run_id: stage.to_string(),
@@ -676,6 +677,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at(
let _ = std::fs::remove_file(&output);
return Err(error);
}
emit_game_creator_manifest_invalidated(root, DIRECT_CODEX_ART_AGENT_ID);
emit_direct_game_creator_progress(
root,
"art.spritesheet.recovered",
@@ -796,6 +798,7 @@ async fn generate_direct_taonier_art_asset_at(
));
}
};
emit_game_creator_manifest_invalidated(root, DIRECT_CODEX_ART_AGENT_ID);
if generated.asset.local_path != output_path {
return Err(format!(
"陶泥儿美术包生成返回后未形成可用的已登记平台素材合同 {output_path},已终止代码生成"
@@ -261,10 +261,10 @@ pub(crate) use entrypoints::{
chat_with_game_creator_role_agent_stream_at,
chat_with_game_creator_role_agent_stream_for_session_at,
configure_game_creator_manifest_invalidation_event_sink, emit_direct_game_creator_progress,
emit_game_creator_agent_runtime_update, game_creator_agent_runtime_update_event,
generate_local_game_draft_at, read_game_creator_agent_runtime_at,
read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at,
set_game_creator_agent_runtime_update_app_handle,
emit_game_creator_agent_runtime_update, emit_game_creator_manifest_invalidated,
game_creator_agent_runtime_update_event, generate_local_game_draft_at,
read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at,
read_game_creator_agent_runtimes_at, set_game_creator_agent_runtime_update_app_handle,
start_game_creator_manifest_invalidation_event_sink,
};
#[cfg(test)]
@@ -155,6 +155,18 @@ fn relay_game_creator_manifest_invalidation(root: &Path, agent_id: &str) -> Resu
.map_err(|error| format!("发送 manifest 失效事件失败:{error}"))
}
pub(crate) fn emit_game_creator_manifest_invalidated(root: &Path, agent_id: &str) {
let event = GameCreatorManifestInvalidatedEvent {
project_path: root.to_string_lossy().into_owned(),
agent_id: agent_id.to_string(),
};
if let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() {
let _ = app.emit("game-creator-manifest-invalidated", event);
return;
}
let _ = relay_game_creator_manifest_invalidation(root, agent_id);
}
pub(crate) fn game_creator_agent_runtime_update_event(
root: &Path,
runtime: AgentRuntimeResult,
@@ -105,6 +105,33 @@ fn manifest_invalidation_sink_isolation_relays_non_supervisor_runtime_update() {
fs::remove_dir_all(root).ok();
}
#[test]
fn direct_codex_art_commit_relays_standalone_manifest_invalidation() {
let sink_guard = acquire_game_creator_manifest_invalidation_event_sink_test_guard();
let root = unique_project_path();
let relay_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
.expect("bind direct Codex manifest invalidation relay fixture");
let relay_port = relay_listener
.local_addr()
.expect("read direct Codex manifest invalidation relay fixture address")
.port();
let relay_token = "c".repeat(64);
sink_guard
.configure(relay_port, &relay_token)
.expect("configure direct Codex manifest invalidation relay fixture");
emit_game_creator_manifest_invalidated(&root, "direct-codex-art");
let relay_payload = read_manifest_invalidation_relay_payload_with_deadline(&relay_listener)
.expect("receive direct Codex manifest invalidation relay within deadline");
let relay: GameCreatorManifestInvalidationRelayEnvelope =
serde_json::from_slice(&relay_payload)
.expect("parse direct Codex manifest invalidation relay");
assert_eq!(relay.token, relay_token);
assert_eq!(relay.event.project_path, root.to_string_lossy());
assert_eq!(relay.event.agent_id, "direct-codex-art");
}
#[test]
fn manifest_invalidation_sink_isolation_bounds_timeouts_and_cleans_up_with_raii() {
let cleanup_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
+17 -10
View File
@@ -5907,16 +5907,10 @@ export function App({
return sessionId;
}
async function refreshDirectProjectSurface(
async function refreshDirectProjectPreview(
invoke: TauriInvoke,
nextProjectPath: string,
) {
try {
await refreshManifest(nextProjectPath);
} catch {
// The direct turn result is still authoritative for the chat. A missing
// or legacy manifest must not hide the Codex reply.
}
try {
const current = await invoke<LocalPreviewStatus>(
'get_local_game_preview_status',
@@ -6020,6 +6014,7 @@ export function App({
setChatAgentBusy(true);
setDirectCodexProgress('已提交需求,正在准备智能创作');
setProjectSupervisorRuntimeError('');
let directTurnSucceeded = false;
try {
const reply = await directInvoke<string>(
'chat_with_game_creator_direct_codex',
@@ -6028,6 +6023,7 @@ export function App({
prompt,
},
);
directTurnSucceeded = true;
if (localProjectPathRef.current === directProjectPath) {
setMessages((current) => [
...appendDirectUserMessageIfMissing(current),
@@ -6042,7 +6038,6 @@ export function App({
},
]);
setDirectCodexProgress('项目已更新,正在刷新资源和运行预览');
await refreshDirectProjectSurface(directInvoke, directProjectPath);
}
} catch (error) {
const message =
@@ -6068,8 +6063,20 @@ export function App({
]);
}
} finally {
setChatAgentBusy(false);
setDirectCodexProgress('');
try {
if (localProjectPathRef.current === directProjectPath) {
await refreshManifest(directProjectPath);
if (directTurnSucceeded) {
await refreshDirectProjectPreview(
directInvoke,
directProjectPath,
);
}
}
} finally {
setChatAgentBusy(false);
setDirectCodexProgress('');
}
}
return;
}
+12 -1
View File
@@ -4858,6 +4858,10 @@ iframe.preview-frame {
min-height: 0;
}
.game-workbench-layout.is-ui-editor {
grid-template-columns: minmax(0, 1fr);
}
.game-workbench-stage,
.game-workbench-chat {
min-width: 0;
@@ -4879,10 +4883,17 @@ iframe.preview-frame {
box-shadow: var(--platform-nav-active-shadow);
}
.game-workbench-stage[data-resource-view-state^='resources.asset-canvas'] {
.game-workbench-stage[data-resource-view-state^='resources.asset-canvas'],
.game-workbench-stage[data-resource-view-state='resources.ui-editor'] {
grid-template-rows: auto minmax(0, 1fr);
}
.game-workbench-stage[data-resource-view-state='resources.ui-editor']
> div:last-child {
grid-row: 2;
min-height: 0;
}
.game-workbench-stage[data-resource-view-state^='resources.asset-canvas']
> .asset-canvas-surface {
grid-row: 2;
@@ -2514,7 +2514,11 @@ export default function ProjectDevelopmentView({
const openResourceEditor = useCallback(
(resource: ProjectResource) => {
if (resource.subtype === 'UI' && resource.manifestAssetId !== null) {
if (resource.subtype === 'UI') {
if (resource.manifestAssetId === null) {
setAssetCanvasNotice('该 UI 资源缺少有效的正式资产身份');
return;
}
canvasOpenEpochRef.current += 1;
advanceFocusGeneration();
activeFocusFlowIdRef.current = null;
@@ -3218,7 +3222,7 @@ export default function ProjectDevelopmentView({
}
function showRunView() {
if (!runAvailable) {
if (!runAvailable || uiEditorRoute) {
return;
}
canvasOpenEpochRef.current += 1;
@@ -3245,7 +3249,9 @@ export default function ProjectDevelopmentView({
className="launcher-page launcher-project-development game-project-workbench"
aria-label="项目开发工作台"
>
<div className="game-workbench-layout">
<div
className={`game-workbench-layout${uiEditorRoute ? ' is-ui-editor' : ''}`}
>
<section
className="game-workbench-stage"
aria-label="项目主视窗"
@@ -3284,8 +3290,9 @@ export default function ProjectDevelopmentView({
aria-describedby={
!runAvailable ? 'run-unavailable-hint' : undefined
}
data-unavailable={!runAvailable || undefined}
data-unavailable={!runAvailable || uiEditorRoute || undefined}
className={mode === 'run' ? 'is-active' : ''}
disabled={uiEditorRoute !== null}
onClick={showRunView}
>
@@ -3295,7 +3302,7 @@ export default function ProjectDevelopmentView({
<button
type="button"
className="game-workbench-play-button"
disabled={!runAvailable || !onPlay}
disabled={!runAvailable || !onPlay || uiEditorRoute !== null}
onClick={() => {
showRunView();
onPlay?.();
@@ -3309,7 +3316,8 @@ export default function ProjectDevelopmentView({
</button>
{mode === 'resources' &&
!assetCanvasRoute &&
!resourceEditorRoute ? (
!resourceEditorRoute &&
!uiEditorRoute ? (
<>
{pendingResourceEdits.length > 0 ||
pendingResourceEditsLoadState === 'failed' ? (
@@ -3325,6 +3333,14 @@ export default function ProjectDevelopmentView({
: `管理未完成编辑 (${pendingResourceEdits.length})`}
</button>
) : null}
<button
type="button"
disabled
title="新增资源暂未开放,请先选择现有图片进行编辑"
>
<Sparkles size={15} aria-hidden="true" />
</button>
<button
type="button"
onClick={() => void createUiDesignResource()}
@@ -4263,40 +4279,42 @@ export default function ProjectDevelopmentView({
)}
</section>
<aside className="game-workbench-chat" aria-label="陶泥儿 Agent 对话">
<header>
<span className="game-workbench-chat-avatar"></span>
<div>
<strong></strong>
<small>
{professionalDagVisible ? '项目总控 Agent' : '智能创作'}
</small>
</div>
{professionalDagVisible ? (
<button
type="button"
className="game-workbench-approval-trigger"
onClick={() => setApprovalDialogOpen(true)}
aria-label={`审批配置,当前${currentApprovalLabel}`}
>
<Settings2 size={14} aria-hidden="true" />
{currentApprovalLabel}
</button>
) : (
<span
className="game-workbench-approval-trigger"
role="status"
aria-label="自动执行"
>
</span>
)}
</header>
{supervisor}
</aside>
{!uiEditorRoute ? (
<aside className="game-workbench-chat" aria-label="陶泥儿 Agent 对话">
<header>
<span className="game-workbench-chat-avatar"></span>
<div>
<strong></strong>
<small>
{professionalDagVisible ? '项目总控 Agent' : '智能创作'}
</small>
</div>
{professionalDagVisible ? (
<button
type="button"
className="game-workbench-approval-trigger"
onClick={() => setApprovalDialogOpen(true)}
aria-label={`审批配置,当前${currentApprovalLabel}`}
>
<Settings2 size={14} aria-hidden="true" />
{currentApprovalLabel}
</button>
) : (
<span
className="game-workbench-approval-trigger"
role="status"
aria-label="自动执行"
>
</span>
)}
</header>
{supervisor}
</aside>
) : null}
</div>
{professionalDagVisible ? (
{professionalDagVisible && !uiEditorRoute ? (
<footer className="game-agent-dock" aria-label="子 Agent 状态栏">
{agentSummaries.map((agent) => (
<article
@@ -1518,6 +1518,148 @@ export function registerHomeProjectCreationTests() {
);
});
it('refreshes Direct Codex art commits while the turn is still running and after a later failure', async () => {
const projectPath =
'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\live-direct-art';
const manifest = createGameCreationAppManifest(
'live-direct-art',
'直连美术实时刷新',
);
let currentManifest = manifest;
let rejectDirectTurn!: (reason?: unknown) => void;
const directTurn = new Promise<string>((_resolve, reject) => {
rejectDirectTurn = reject;
});
const runtimeHarness = createProjectSupervisorRuntimeHarness({
projectPath,
initialSessionExists: false,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'create_automatic_local_game_project') {
return {
projectPath,
manifestPath: `${projectPath}\\.agent\\manifest.json`,
manifest,
};
}
if (command === 'get_local_game_manifest') {
expect(args).toEqual({ projectPath });
return currentManifest;
}
if (command === 'chat_with_game_creator_direct_codex') {
return directTurn;
}
return runtimeHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: runtimeHarness.listen },
};
renderLauncherAt('/?launcher', 'home', true);
const promptInput = screen.getByLabelText('创作想法');
nativeClipboardMock.text = '生成一个带三张美术素材的横版游戏';
fireEvent.paste(promptInput);
await waitFor(() => {
expect(promptInput.textContent).toContain(
'生成一个带三张美术素材的横版游戏',
);
});
fireEvent.click(screen.getByRole('button', { name: '开启创作' }));
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'chat_with_game_creator_direct_codex',
expect.objectContaining({ projectPath }),
);
expect(runtimeHarness.listen).toHaveBeenCalledWith(
'game-creator-manifest-invalidated',
expect.any(Function),
);
});
const committedAssets = [
{
id: 'direct-art-spec',
kind: 'icon-spec',
mediaType: 'image/png',
localPath: 'assets/art-spec.png',
source: {
kind: 'generated' as const,
taskId: 'direct-codex-art-art-spec',
},
},
{
id: 'direct-game-background',
kind: 'game-background',
mediaType: 'image/png',
localPath: 'assets/direct-game-background.png',
source: {
kind: 'generated' as const,
taskId: 'direct-codex-art-game-background',
},
},
{
id: 'direct-art-spritesheet',
kind: 'art-spritesheet',
mediaType: 'image/png',
localPath: 'assets/art-spritesheet.png',
source: {
kind: 'generated' as const,
taskId: 'direct-codex-art-art-spritesheet',
},
},
];
for (let index = 0; index < committedAssets.length; index += 1) {
const refreshCountBeforeEvent = invoke.mock.calls.filter(
([command]) => command === 'get_local_game_manifest',
).length;
currentManifest = {
...currentManifest,
assets: committedAssets.slice(0, index + 1),
};
act(() => {
runtimeHarness.emitManifestInvalidated('direct-codex-art');
});
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) => command === 'get_local_game_manifest',
).length,
).toBeGreaterThan(refreshCountBeforeEvent);
});
expect(
invoke.mock.calls.filter(
([command]) => command === 'chat_with_game_creator_direct_codex',
),
).toHaveLength(1);
}
const refreshCountBeforeFailure = invoke.mock.calls.filter(
([command]) => command === 'get_local_game_manifest',
).length;
await act(async () => {
rejectDirectTurn(new Error('代码生成等待超时'));
await directTurn.catch(() => undefined);
});
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) => command === 'get_local_game_manifest',
).length,
).toBeGreaterThan(refreshCountBeforeFailure);
});
expect(currentManifest.assets).toEqual(committedAssets);
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_local_game_preview',
),
).toHaveLength(0);
});
it('hydrates an existing project before sending a direct Codex turn', async () => {
const projectPath =
'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\existing-project';
@@ -14305,3 +14305,9 @@
- 依赖画板的导航范围由当前项目全部资源的权威世界 extent 决定,并在屏幕坐标外扩 96px 安全留白;搜索过滤、详情卡和临时可见性只影响展示,不缩小导航边界。
- 滚轮、指针平移、缩放、复位和容器 resize 统一经过 viewport constraint。最小缩放为全量资源边界 fit scale;内容小于视口时居中,内容较大时边缘不能越过安全留白。该边界是会话级导航约束,不持久化为第二份布局真相。
## 2026-08-21 Direct Codex 美术资源提交后实时刷新
- Direct Codex 的进度事件只表达加载文案,不作为资源事务真相;规范图、背景图、核心图集及已付费源图恢复只有在本地文件和 manifest 登记成功后,才发送现有 `game-creator-manifest-invalidated`,身份固定为 `direct-codex-art`
- App 继续复用按项目 single-flight 的 `refreshManifest`。单张资源提交事件负责生成中的即时投影;Direct Codex 命令无论成功、失败或超时 reject 都做一次最终 manifest 对账,失败路径不启动 preview,已提交资源不得被后续代码生成失败遮蔽。
- UI Editor 融合保留资源工作台 toolbar 和页面状态:UI 资源与普通资源一样先开详情卡,点击“编辑资源”后才进入 UI Editor;编辑器打开时使用单列全宽 stage,并禁止从父 toolbar 绕过未保存返回确认。
@@ -1073,6 +1073,7 @@ game-project/
- `.agent/manifest.json` 的存储写边界使用同目录持久文件锁跨线程、跨进程串行化;锁必须覆盖旧 manifest 读取、不可变版本前缀校验、临时文件安装和安装后回读一致性校验。锁文件拒绝符号链接、非普通文件和异常所有权 / 硬链接;Windows 使用不共享写句柄,Unix 使用 `O_NOFOLLOW + flock`。旧快照在新版本安装后只能被拒绝,不能覆盖已追加版本。
- 后台 Agent 的 manifest 变化以共用 Runtime 状态投影 / 终态 emitter 作为失效因果点:`game-creator-agent-runtime-update` 的 Rust / TypeScript DTO 固定携带 `manifestInvalidated`,且 App 必须在 Supervisor、selected agent、session 和 run 身份的任何 early return 之前处理失效。GUI 进程内 Runtime 直接发该事件;External Runner 是独立进程、没有 GUI `AppHandle`,因此 Runner 协议 v5 的 `runner.attach_gui_owner` 必须登记 GUI 创建的随机 loopback 端口和 64 位随机令牌,Runner 的同一 emitter 通过受令牌保护的短连接转发 `game-creator-manifest-invalidated`。两条路径都只传项目路径与 Agent 身份,不复制 manifest,也不靠轮询补偿。
- Direct Codex 不伪造普通 Agent Runtime state。每张平台美术在本地文件与 manifest 提交成功后,统一通过 standalone `game-creator-manifest-invalidated` 发送 `projectPath + direct-codex-art`;只读恢复的已付费源图同样在 `register_local_asset_at` 成功后发送,下载、解码、文件写入或登记失败时不得发送成功失效。前端仍把 `game-creator-agent-progress` 仅用于进度文案;Direct Codex 整体命令成功、失败或超时 reject 后都追加一次 manifest 最终对账,只有完整成功才启动本地预览。
- App 收到当前项目的 Runtime / relay 失效后重新调用 `get_local_game_manifest`。重读按项目 single-flight 合并事件风暴;读取中再到达失效只追加一轮串行重读,不并发提交同项目响应。应用结果同时校验组件仍挂载、当前项目路径和项目 scope version;项目切换、组件卸载或旧 scope 的迟到响应不得覆盖新项目。Project Supervisor 对外发布前以“revision 前读 -> manifest -> revision 后读”取得一致快照,再通过 `onManifestChange(projectPath, manifest, metadata)` 携带 `projectId + revision + source`;启动器按 `projectPath + projectId` 只接受更高 revision,同 revision 只接受内容一致的重复,旧轮询和同 revision 分叉都不得覆盖。资源列表、依赖图输入、任务状态、运行入口和正式版本卡必须在当前页面实时重投影,不要求关闭或重开项目。集成测试记录“事件未重新打开项目”的调用基线前,必须先等待项目写入最近列表后触发的只读目录状态刷新完成,不能把这项合法后台检查误算成失效事件副作用。
- `.agent/agent.db` 有界尾部读取报告截断时,审计 producer 映射失败关闭,不生成基于不完整审计的 producer、task flow 或对应任务环。前端收到截断 DTO 时只剔除 `producerAssignments``taskFlows` 与对应 `cyclicTaskIds`Rust 根据当前 manifest、精确资源引用和仍可信任务深度下限返回的 `dependencyDepths` 继续保留,前端只校验资源仍存在且深度为非负安全整数,不得自行重算或压平权威深度。精确引用边、reference connection index、`cyclicResourceIds` 与 unresolved references 同样继续保留。
-- 资源依赖 SVG 继续作为不可交互装饰层隐藏,但 dependency 画布通过 `aria-describedby` 提供当前可见精确引用和任务流的文本等价列表。中央资源聚焦按稳定 `resourceId` 驱动焦点状态:仅 `null -> id``idA -> idB` 聚焦详情 region,同一 ID 的 manifest 重投影不得抢走音频、视频、链接或关闭按钮焦点;显式收起和 Escape 恢复画布滚动并优先聚焦原触发卡片。聚焦资源被删除时清理 stale focused / selected ID,关闭详情并把焦点落到资源搜索框;项目切换或运行视图切换清除旧恢复意图,不得恢复旧项目卡片。橙色引用线及箭头使用对 `#fffdfa` 画布达到至少 `3:1` 的颜色。