修复直连Codex美术资源实时刷新
美术资源与恢复结果提交后发送manifest失效事件。 直连回合失败或超时后仍对账已提交资源。 完善UI编辑器与资源工作台的返回和布局边界。 补充实时刷新回归测试与技术决策文档。
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user