修复AGC直连创作反馈与资源预览

直连运行时推送陶泥儿美术、代码与版本登记阶段进度

资源管理首屏预取可预览资源并补齐回归测试

同步直连运行时实施计划和团队决策记录
This commit is contained in:
kdletters
2026-08-17 13:17:14 +08:00
parent 91c333ea4a
commit 08bca001a2
11 changed files with 214 additions and 20 deletions
@@ -301,7 +301,9 @@ async fn ensure_direct_taonier_art_package_at(
root: &Path,
prompt: &str,
) -> Result<Vec<String>, String> {
emit_direct_game_creator_progress(root, "art.prepare", "正在检查陶泥儿美术包");
if direct_taonier_art_package_is_valid(root) {
emit_direct_game_creator_progress(root, "art.ready", "陶泥儿美术包已就绪");
return Ok(DIRECT_CODEX_ART_ASSET_PATHS
.iter()
.map(|path| (*path).to_string())
@@ -318,6 +320,7 @@ async fn ensure_direct_taonier_art_package_at(
) {
Some(identity) => identity,
None => {
emit_direct_game_creator_progress(root, "art.spec", "正在生成统一视觉规范图");
generate_direct_taonier_art_asset_at(
root,
prompt,
@@ -328,6 +331,7 @@ async fn ensure_direct_taonier_art_package_at(
false,
)
.await?;
emit_direct_game_creator_progress(root, "art.spec.ready", "统一视觉规范图已生成");
direct_taonier_art_asset_identity(
root,
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
@@ -351,6 +355,7 @@ async fn ensure_direct_taonier_art_package_at(
)
.is_none()
{
emit_direct_game_creator_progress(root, "art.background", "正在生成 16:9 游戏场景背景图");
generate_direct_taonier_art_asset_at(
root,
prompt,
@@ -361,13 +366,16 @@ async fn ensure_direct_taonier_art_package_at(
false,
)
.await?;
emit_direct_game_creator_progress(root, "art.background.ready", "游戏场景背景图已生成");
}
if direct_taonier_art_package_is_valid(root) {
emit_direct_game_creator_progress(root, "art.ready", "陶泥儿美术包已就绪");
return Ok(DIRECT_CODEX_ART_ASSET_PATHS
.iter()
.map(|path| (*path).to_string())
.collect());
}
emit_direct_game_creator_progress(root, "art.spritesheet", "正在生成核心图集并切分四类运行时素材");
generate_direct_taonier_art_asset_at(
root,
prompt,
@@ -381,6 +389,7 @@ async fn ensure_direct_taonier_art_package_at(
if !direct_taonier_art_package_is_valid(root) {
return Err("陶泥儿美术包不完整,已终止代码生成".to_string());
}
emit_direct_game_creator_progress(root, "art.ready", "陶泥儿美术包已就绪");
Ok(DIRECT_CODEX_ART_ASSET_PATHS
.iter()
.map(|path| (*path).to_string())
@@ -668,6 +677,7 @@ pub(crate) async fn run_direct_game_creator_turn_at(
if prompt.is_empty() {
return Err("聊天内容不能为空".to_string());
}
emit_direct_game_creator_progress(root, "request.accepted", "已收到需求,正在准备智能创作");
ensure_direct_taonier_art_package_at(root, prompt).await?;
let previous_output_fingerprint = direct_codex_output_fingerprint(root);
let mut system_prompt = build_direct_codex_system_prompt(root);
@@ -681,11 +691,14 @@ pub(crate) async fn run_direct_game_creator_turn_at(
DIRECT_CODEX_SPRITESHEET_SLICE_PATHS[2].1,
DIRECT_CODEX_SPRITESHEET_SLICE_PATHS[3].1,
));
emit_direct_game_creator_progress(root, "codex.start", "美术素材已准备,正在生成游戏代码");
let reply = direct_game_creator_codex_chat_at(root, system_prompt, prompt.to_string())
.await
.map_err(|error| format!("direct-codex-error:{error}"))?;
emit_direct_game_creator_progress(root, "codex.ready", "游戏代码已生成,正在登记项目版本");
sync_direct_codex_project_outputs_at(root, Some(&previous_output_fingerprint))
.map_err(|error| format!("Codex 已返回,但客户端登记生成产物失败:{error}"))?;
emit_direct_game_creator_progress(root, "project.ready", "项目版本已登记,正在刷新运行预览");
Ok(reply)
}
@@ -262,6 +262,7 @@ pub(crate) use entrypoints::{
chat_with_game_creator_role_agent_stream_for_session_at,
configure_game_creator_manifest_invalidation_event_sink,
emit_game_creator_agent_runtime_update, game_creator_agent_runtime_update_event,
emit_direct_game_creator_progress,
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,
@@ -14,6 +14,20 @@ pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHa
let _ = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.set(app);
}
pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, message: &str) {
let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else {
return;
};
let _ = app.emit(
"game-creator-agent-progress",
GameCreatorAgentProgressEvent {
project_path: root.to_string_lossy().into_owned(),
stage: stage.to_string(),
message: message.to_string(),
},
);
}
pub(crate) fn start_game_creator_manifest_invalidation_event_sink(
app: tauri::AppHandle,
) -> Result<GameCreatorManifestInvalidationEventSink, String> {
+37 -17
View File
@@ -695,6 +695,7 @@ export function App({
: '',
);
const [chatAgentBusy, setChatAgentBusy] = useState(false);
const [directCodexProgress, setDirectCodexProgress] = useState('');
const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState<
string | null
>(null);
@@ -1372,7 +1373,7 @@ export function App({
}, [gameChatOnly, initialProjectPath, projectSupervisorOnly]);
useEffect(() => {
if (projectSupervisorOnly) {
if (projectSupervisorOnly && !directCodexProductRuntime) {
return;
}
const listen = window.__TAURI__?.event?.listen;
@@ -1385,6 +1386,10 @@ export function App({
if (event.payload.projectPath !== localProjectPathRef.current) {
return;
}
if (directCodexProductRuntime) {
setDirectCodexProgress(event.payload.message);
return;
}
setMessages((current) => [
...current,
{ role: 'assistant', text: event.payload.message },
@@ -1411,7 +1416,7 @@ export function App({
disposed = true;
cleanup?.();
};
}, [projectSupervisorOnly]);
}, [directCodexProductRuntime, projectSupervisorOnly]);
useEffect(() => {
const listen = window.__TAURI__?.event?.listen;
@@ -2990,18 +2995,14 @@ export function App({
runtimeResults
.filter(
(runtimeResult) =>
runtimeResult.state.agentId ===
PROJECT_SUPERVISOR_AGENT_ID,
runtimeResult.state.agentId === PROJECT_SUPERVISOR_AGENT_ID,
)
.sort(
(left, right) =>
right.state.updatedAt - left.state.updatedAt,
(left, right) => right.state.updatedAt - left.state.updatedAt,
)[0] ?? null;
sessionId =
persistedSupervisorRuntimeResult?.state.sessionId || null;
sessionId = persistedSupervisorRuntimeResult?.state.sessionId || null;
} catch (error) {
runtimeError =
error instanceof Error ? error.message : String(error);
runtimeError = error instanceof Error ? error.message : String(error);
}
}
const projectConversation = await invoke<LocalConversationResult>(
@@ -3025,9 +3026,7 @@ export function App({
);
}
if (persistedSupervisorRuntimeResult) {
runtime = agentRuntimeStateFromResult(
persistedSupervisorRuntimeResult,
);
runtime = agentRuntimeStateFromResult(persistedSupervisorRuntimeResult);
runtimeResponseStream =
persistedSupervisorRuntimeResult.responseStream ?? null;
} else if (sessionId) {
@@ -5901,6 +5900,7 @@ export function App({
}
if (directProjectPath && directInvoke) {
setChatAgentBusy(true);
setDirectCodexProgress('已提交需求,正在准备智能创作');
setProjectSupervisorRuntimeError('');
try {
const reply = await directInvoke<string>(
@@ -5920,6 +5920,7 @@ export function App({
updatedAt: Date.now(),
},
]);
setDirectCodexProgress('项目已更新,正在刷新资源和运行预览');
await refreshDirectProjectSurface(directInvoke, directProjectPath);
}
} catch (error) {
@@ -5944,6 +5945,7 @@ export function App({
}
} finally {
setChatAgentBusy(false);
setDirectCodexProgress('');
}
return;
}
@@ -6185,7 +6187,13 @@ export function App({
},
]);
void executeChatAgentReplyRef.current(latch.prompt);
}, [chatAgentBusy, gameChatOnly, initialSupervisorMessage, localProject]);
}, [
chatAgentBusy,
directCodexProductRuntime,
gameChatOnly,
initialSupervisorMessage,
localProject,
]);
async function handleProjectSupervisorToolAction(
decision: 'confirm' | 'reject',
@@ -11218,7 +11226,11 @@ export function App({
manifest={manifest}
runtimeConfigOpen={runtimeConfigOpen}
runtimeError={projectSupervisorRuntimeError}
transientReply={projectSupervisorTransientReply}
transientReply={
directCodexProductRuntime
? directCodexProgress
: projectSupervisorTransientReply
}
transientReplyUpdatedAt={projectSupervisorResponseStream?.updatedAt}
hasConversationControls={projectSupervisorHasConversationControls}
hiddenConversationCount={hiddenConversationCount}
@@ -11256,7 +11268,11 @@ export function App({
runtime={projectSupervisorRuntime}
runtimeConfigOpen={runtimeConfigOpen}
runtimeError={projectSupervisorRuntimeError}
transientReply={projectSupervisorTransientReply}
transientReply={
directCodexProductRuntime
? directCodexProgress
: projectSupervisorTransientReply
}
transientReplyUpdatedAt={projectSupervisorResponseStream?.updatedAt}
hasConversationControls={projectSupervisorHasConversationControls}
hiddenConversationCount={hiddenConversationCount}
@@ -11286,7 +11302,11 @@ export function App({
pendingConfirmation={
directCodexProductRuntime ? null : pendingUiConfirmation
}
transientReply={projectSupervisorTransientReply}
transientReply={
directCodexProductRuntime
? directCodexProgress
: projectSupervisorTransientReply
}
visibleMessages={visibleMessages}
visibleProfessionalAgentCards={visibleProfessionalAgentCards}
showProfessionalCollaboration={orchestrationMode === 'professional-dag'}
@@ -1025,6 +1025,7 @@ export default function ProjectDevelopmentView({
mode: sortMode,
resources,
canvasRef: resourceCanvasRef,
eagerPreviewLimit: 12,
});
const resourceSectionHeights = useProjectResourceSectionHeights({
projectId: manifest.projectId,
@@ -3307,7 +3308,9 @@ export default function ProjectDevelopmentView({
{focusedResourceCardPreview.status === 'loaded' &&
focusedResourceCardPreview.preview.content !== undefined ? (
<pre>
<code>{focusedResourceCardPreview.preview.content}</code>
<code>
{focusedResourceCardPreview.preview.content}
</code>
</pre>
) : focusedResourceCardPreview.status === 'failed' ? (
<p role="alert">{focusedResourceCardPreview.error}</p>
@@ -18,6 +18,7 @@ import {
IDLE_PROJECT_RESOURCE_CARD_PREVIEW,
PROJECT_RESOURCE_CARD_PREVIEW_ACTIVE_QUEUE_RESERVE,
PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT,
PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT,
PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY,
PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT,
projectResourceCardPreviewEvictionIdentities,
@@ -191,6 +192,7 @@ export function useProjectResourceCardPreviews(input: {
mode: ProjectResourceCanvasLayoutMode;
resources: ProjectResource[];
canvasRef: React.RefObject<HTMLDivElement | null>;
eagerPreviewLimit?: number;
}) {
const scopeKey = JSON.stringify([
input.projectPath,
@@ -606,6 +608,38 @@ export function useProjectResourceCardPreviews(input: {
}
}, [disposeCachedPreview, identityByResourceId]);
useEffect(() => {
const eagerPreviewLimit = Math.max(
0,
Math.min(
PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT,
Math.floor(input.eagerPreviewLimit ?? 0),
),
);
let requested = 0;
for (const resource of input.resources) {
if (requested >= eagerPreviewLimit) {
break;
}
const identity = identityByResourceId.get(resource.id);
const kind = projectResourceCardPreviewKind(resource);
if (
identity &&
kind !== 'version' &&
kind !== 'placeholder' &&
kind !== 'audio'
) {
requestPreview(resource, identity, 'visible');
requested += 1;
}
}
}, [
identityByResourceId,
input.eagerPreviewLimit,
input.resources,
requestPreview,
]);
useEffect(
() => () => {
cancelLocalProjectResourcePreviewScope(scopeIdRef.current);
@@ -387,6 +387,15 @@ function createProjectSupervisorRuntimeHarness({
};
}) => void)
| null = null;
let progressHandler:
| ((event: {
payload: {
projectPath: string;
stage: string;
message: string;
};
}) => void)
| null = null;
const conversationRecord = (
role: 'user' | 'assistant',
@@ -666,6 +675,9 @@ function createProjectSupervisorRuntimeHarness({
manifestInvalidatedHandler =
handler as unknown as typeof manifestInvalidatedHandler;
}
if (eventName === 'game-creator-agent-progress') {
progressHandler = handler as unknown as typeof progressHandler;
}
return () => {
if (runtimeUpdateHandler === handler) {
runtimeUpdateHandler = null;
@@ -673,6 +685,9 @@ function createProjectSupervisorRuntimeHarness({
if (manifestInvalidatedHandler === handler) {
manifestInvalidatedHandler = null;
}
if (progressHandler === handler) {
progressHandler = null;
}
};
},
);
@@ -747,6 +762,15 @@ function createProjectSupervisorRuntimeHarness({
},
});
},
emitProgress(stage: string, message: string) {
progressHandler?.({
payload: {
projectPath,
stage,
message,
},
});
},
};
}
@@ -4095,7 +4095,9 @@ export function registerProjectSupervisorSurfaceTests() {
expect(
within(supervisorSurface).queryByText('项目总控 Agent · 待核对'),
).toBeNull();
expect(within(supervisorSurface).queryByText('tool-plan-unknown')).toBeNull();
expect(
within(supervisorSurface).queryByText('tool-plan-unknown'),
).toBeNull();
expect(invoke).toHaveBeenCalledWith('read_game_creator_agent_runtimes', {
projectPath,
});
@@ -9072,7 +9074,7 @@ export function registerProjectSupervisorSurfaceTests() {
expect(screen.queryByRole('button', { name: /审批配置/ })).toBeNull();
expect(screen.queryByLabelText('选择 Agent')).toBeNull();
expect(screen.queryByRole('dialog', { name: 'Agent 对话' })).toBeNull();
expect(supervisorHarness.listen).not.toHaveBeenCalledWith(
expect(supervisorHarness.listen).toHaveBeenCalledWith(
'game-creator-agent-progress',
expect.any(Function),
);
@@ -9080,12 +9082,46 @@ export function registerProjectSupervisorSurfaceTests() {
([command]) => command === 'read_project_permission_policy',
).length;
const directReply = createDeferred<string>();
invoke.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'chat_with_game_creator_direct_codex') {
return directReply.promise;
}
if (command === 'get_local_game_preview_status') {
return { status: 'stopped', url: null, port: null, root: null };
}
if (command === 'start_local_game_preview') {
return {
url: 'http://127.0.0.1:43124/game/index.html',
port: 43124,
root: projectPath,
};
}
return supervisorHarness.invoke(command, args);
},
);
fireEvent.change(screen.getByLabelText('项目需求'), {
target: { value: '先完成正式客户端玩法拆解' },
});
fireEvent.click(
within(supervisorSurface).getByRole('button', { name: '发送' }),
);
await waitFor(() =>
expect(
within(supervisorSurface).getByText('已提交需求,正在准备智能创作'),
).not.toBeNull(),
);
await act(async () => {
supervisorHarness.emitProgress('art.spec', '正在生成统一视觉规范图');
});
expect(
within(supervisorSurface).getByText('正在生成统一视觉规范图'),
).not.toBeNull();
await act(async () => {
directReply.resolve('DIRECT_REPLY:先完成正式客户端玩法拆解');
});
expect(
await within(supervisorSurface).findByText(
'DIRECT_REPLY:先完成正式客户端玩法拆解',
@@ -100,6 +100,36 @@ afterEach(() => {
});
describe('useProjectResourceCardPreviews', () => {
it('prefetches initial previewable resources without requiring a detail click', async () => {
const art = resource('initial-art');
const invoke = vi.fn(
async (_command: string, args?: Record<string, unknown>) =>
preview(String(args?.relativePath ?? art.path)),
);
window.__TAURI__ = { core: { invoke } };
const canvasRef = { current: document.createElement('div') };
const { result } = renderHook(() =>
useProjectResourceCardPreviews({
projectPath: '/tmp/preview-prefetch',
projectId: 'preview-prefetch',
mode: 'dependency',
resources: [art],
canvasRef,
eagerPreviewLimit: 12,
}),
);
const identity = result.current.identityByResourceId.get(art.id)!;
await waitFor(() =>
expect(result.current.previews.get(identity)?.status).toBe('loaded'),
);
expect(previewReadCalls(invoke)).toHaveLength(1);
expect(previewReadCalls(invoke)[0]?.[1]).toMatchObject({
relativePath: art.path,
});
});
it('only treats the exact native cancellation category as cancellation', () => {
const cancellation = 'project-resource-preview-scope-cancelled';
expect(isProjectResourcePreviewCancellation(cancellation)).toBe(true);
@@ -120,3 +120,16 @@
- 连续两次直连回合必须保留同一批游戏代码资产 ID,project revision 单调推进,并形成 `initial-* -> agent-*` 的父子版本链;外层资源管理在新 revision 到达后显示游戏代码和项目版本,不能停留在生成前快照。
- 已有项目修改测试必须证明系统提示词包含有界当前游戏源码、敏感行被过滤,基于该上下文的 file-change 可以命中;Codex 明确未修改文件时,revision 与版本数量保持不变。
- 真实客户端验收必须在新建空项目中看到规范图、背景图和标准核心图集三张 `source.kind=canvas` PNG 位于“美术资源”,四类切片及其合同真实落盘,代码文件位于“游戏代码”,并在运行画面中实际加载背景图和四类切片。
## 9. 直连创作阶段反馈与资源首屏预览(2026-08-17)
### 问题
- 直连回合把陶泥儿美术生成、Codex 文件修改、manifest/版本登记放在一个 Tauri command 内。前端只能在 command resolve 后得到最终回复,用户会在数分钟内只看到笼统等待状态,随后突然出现运行画面。
- 资源卡原本主要等待嵌套资源画布的 `IntersectionObserver` 通知。该嵌套滚动容器在首次进入时不稳定,失败后的可重试读取又不会因可见性回调自动重试,导致用户必须先进入详情才能看到卡片预览。
### 现行契约
1. direct Runtime 在不输出内部路径、Provider 或工具细节的前提下,复用 `game-creator-agent-progress` 发送安全的公开阶段:需求已接收、检查陶泥儿美术包、规范图、背景图、图集与切片、代码生成、项目版本登记和预览刷新。普通直连工作台按当前项目路径接收并即时显示该阶段;命令失败仍沿用现有安全错误映射,不把阶段进度伪装成已完成。
2. 资源管理首次进入时主动请求最多 12 个可预览的非音频、非版本、非占位资源。请求仍服从三并发、96 项队列、48 项/64 MiB 缓存和原有身份失效规则;后续资源继续通过 `IntersectionObserver` 按滚动加载,详情与播放请求可提升优先级。首屏预取不改变权限策略,读取被拒绝时卡片必须保留安全错误状态。
3. 验收必须同时证明:提交直连需求后立即可见“已提交需求,正在准备智能创作”,收到阶段事件后显示对应用户文案;真实普通 AGC 工作台首次打开既有项目时,无需进入详情即可显示游戏代码摘要以及陶泥儿规范图、场景背景图和核心图集的缩略图。
@@ -14199,3 +14199,9 @@
- 决策:每次直连 Codex 回合只有在代码文件存在、完整陶泥儿美术包有效且五类素材进入实际 Canvas 渲染后,才在项目写锁内推进一次 durable project revision,并以新 revision 创建首个 `initial-*` 或后续 `agent-*` 正式版本。后续版本绑定上一正式版本为父版本,继续复用原游戏代码资产 ID,禁止通过重复登记制造平行资产。
- 已有项目编辑:direct app-server 继续禁用 shell / unified exec,但系统提示词必须在仓库长文档之前带入当前三个游戏代码文件的有界脱敏快照,供原生 file-change 精确匹配。回合前后绑定三个文件的内容指纹;没有真实文件变化且 manifest 已同步时,不推进 revision、不追加版本,不能把“无法读取所以未修改”的回复登记成正式修订。
- 验收:连续两次产物同步必须得到单调 revision、`initial-* -> agent-*` 父子版本链、稳定的 3 个代码资产 ID;真实客户端外层资源管理必须随新 revision 显示游戏代码和项目版本。
## 2026-08-17 AGC 直连阶段反馈与资源预览首屏预取
- 直连 Runtime 的美术生成、代码生成和版本登记仍保持单次原子回合,不拆回 Supervisor 或 harness。为避免用户等待数分钟只看到“思考中”,Runtime 在每个用户可理解的安全阶段复用 `game-creator-agent-progress`:需求接收、陶泥儿美术包检查、规范图、背景图、图集及切片、代码生成、版本登记和预览刷新;普通直连工作台仅接受当前项目的事件并显示 `message`,不把内部执行协议、路径或凭据暴露到聊天。
- 资源画布不能把 `IntersectionObserver` 作为首屏唯一预览触发器。工作台进入资源管理时主动预取最多 12 个可预览的非音频、非版本、非占位资源;保留现有按可见性懒加载、队列优先级、并发上限、缓存上限、权限失败提示与详情/播放升级机制。这样不会一次读取大项目全部资源,但陶泥儿标准三张 PNG 和游戏代码能在正常首屏项目中无需打开详情直接显示。
- 验收:定向 hook 测试证明预取会直接调用本地 preview command 并进入 loadedAppSurface 证明 direct 提交即时显示接收文案并消费阶段事件;2026-08-17 在当前 checkout 的普通 AGC 客户端打开 `gameagent-3291e57c`,未打开任何资源详情即观察到三份游戏代码摘要和三张陶泥儿 PNG(规范图、16:9 背景、核心图集)缩略图。