接通实时清单更新并修复资源焦点竞态

通过统一 Runtime emitter 和受令牌保护的 Runner relay 触发 manifest 重读
为 manifest 重读增加事件合并、项目隔离和迟到响应保护
按稳定资源 ID 重构详情焦点恢复与资源删除 fallback
补充真实 App 事件链、乱序项目切换、媒体焦点和 Rust relay 回归测试
同步更新工作台技术方案、PRD 与共享项目记忆
This commit is contained in:
2026-08-05 12:48:42 +08:00
parent 96c66e619d
commit 4a3ff531fb
18 changed files with 1014 additions and 97 deletions
@@ -2,6 +2,9 @@ use super::*;
pub(super) static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock<tauri::AppHandle> =
OnceLock::new();
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK: OnceLock<
std::sync::Mutex<Option<GameCreatorManifestInvalidationEventSink>>,
> = OnceLock::new();
pub(super) static STATIC_DELEGATE_PARENT_WAKE_SINGLEFLIGHT: OnceLock<
std::sync::Mutex<std::collections::BTreeMap<String, bool>>,
> = OnceLock::new();
@@ -234,15 +237,21 @@ pub(in crate::agent) use recovery_scan::*;
pub(in crate::agent) use task_queue::*;
pub(in crate::agent) use task_start::*;
#[cfg(test)]
pub(crate) use entrypoints::clear_game_creator_manifest_invalidation_event_sink_for_test;
#[allow(unused_imports)]
pub(crate) use entrypoints::{
chat_with_game_creator_agent_at, chat_with_game_creator_role_agent_at,
chat_with_game_creator_role_agent_for_session_at, chat_with_game_creator_role_agent_runtime_at,
chat_with_game_creator_role_agent_runtime_for_session_at,
chat_with_game_creator_role_agent_stream_at,
chat_with_game_creator_role_agent_stream_for_session_at, 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,
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,
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)]
pub(crate) use finalization::resume_game_creator_agent_finalization_for_test_at;
@@ -1,30 +1,145 @@
use super::*;
const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024;
fn lock_game_creator_manifest_invalidation_event_sink(
) -> std::sync::MutexGuard<'static, Option<GameCreatorManifestInvalidationEventSink>> {
GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK
.get_or_init(|| Mutex::new(None))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHandle) {
let _ = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.set(app);
}
pub(in crate::agent) fn emit_game_creator_agent_runtime_update(root: &Path, agent_id: &str) {
pub(crate) fn start_game_creator_manifest_invalidation_event_sink(
app: tauri::AppHandle,
) -> Result<GameCreatorManifestInvalidationEventSink, String> {
let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
.map_err(|error| format!("绑定 manifest 失效事件接收端失败:{error}"))?;
let port = listener
.local_addr()
.map_err(|error| format!("读取 manifest 失效事件接收端失败:{error}"))?
.port();
let token = format!(
"{}{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
);
let expected_token = token.clone();
thread::Builder::new()
.name("manifest-invalidation-event-sink".to_string())
.spawn(move || {
for incoming in listener.incoming() {
let Ok(mut stream) = incoming else {
continue;
};
let _ = stream.set_read_timeout(Some(Duration::from_millis(250)));
let mut payload = Vec::new();
let mut limited =
(&mut stream).take(GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES + 1);
if limited.read_to_end(&mut payload).is_err()
|| payload.len() as u64 > GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES
{
continue;
}
let Ok(envelope) = serde_json::from_slice::<
GameCreatorManifestInvalidationRelayEnvelope,
>(&payload) else {
continue;
};
if envelope.token != expected_token {
continue;
}
let _ = app.emit("game-creator-manifest-invalidated", envelope.event);
}
})
.map_err(|error| format!("启动 manifest 失效事件接收端失败:{error}"))?;
Ok(GameCreatorManifestInvalidationEventSink { port, token })
}
pub(crate) fn configure_game_creator_manifest_invalidation_event_sink(
port: u16,
token: &str,
) -> Result<(), String> {
if port == 0 {
return Err("manifest 失效事件接收端口无效".to_string());
}
let token = token.trim();
if token.len() != 64 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err("manifest 失效事件接收令牌无效".to_string());
}
*lock_game_creator_manifest_invalidation_event_sink() =
Some(GameCreatorManifestInvalidationEventSink {
port,
token: token.to_string(),
});
Ok(())
}
#[cfg(test)]
pub(crate) fn clear_game_creator_manifest_invalidation_event_sink_for_test() {
*lock_game_creator_manifest_invalidation_event_sink() = None;
}
fn relay_game_creator_manifest_invalidation(root: &Path, agent_id: &str) -> Result<(), String> {
let sink = lock_game_creator_manifest_invalidation_event_sink().clone();
let Some(sink) = sink else {
return Ok(());
};
let envelope = GameCreatorManifestInvalidationRelayEnvelope {
token: sink.token,
event: GameCreatorManifestInvalidatedEvent {
project_path: root.to_string_lossy().into_owned(),
agent_id: agent_id.to_string(),
},
};
let payload = serde_json::to_vec(&envelope)
.map_err(|error| format!("序列化 manifest 失效事件失败:{error}"))?;
if payload.len() as u64 > GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES {
return Err("manifest 失效事件超过大小上限".to_string());
}
let address = std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, sink.port).into();
let mut stream = TcpStream::connect_timeout(&address, Duration::from_millis(100))
.map_err(|error| format!("连接 manifest 失效事件接收端失败:{error}"))?;
stream
.set_write_timeout(Some(Duration::from_millis(100)))
.map_err(|error| format!("配置 manifest 失效事件发送超时失败:{error}"))?;
stream
.write_all(&payload)
.map_err(|error| format!("发送 manifest 失效事件失败:{error}"))
}
pub(crate) fn game_creator_agent_runtime_update_event(
root: &Path,
runtime: AgentRuntimeResult,
) -> GameCreatorAgentRuntimeUpdateEvent {
GameCreatorAgentRuntimeUpdateEvent {
project_path: root.to_string_lossy().into_owned(),
agent_id: runtime.state.agent_id.clone(),
run_id: runtime.state.run_id.clone(),
status: runtime.state.status.clone(),
phase: runtime.state.phase.clone(),
manifest_invalidated: true,
runtime,
}
}
pub(crate) fn emit_game_creator_agent_runtime_update(root: &Path, agent_id: &str) {
if GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get().is_none() {
let _ = relay_game_creator_manifest_invalidation(root, agent_id);
}
let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else {
return;
};
let Ok(runtime) = read_game_creator_agent_runtime_at(root, agent_id) else {
return;
};
let agent_id = runtime.state.agent_id.clone();
let run_id = runtime.state.run_id.clone();
let status = runtime.state.status.clone();
let phase = runtime.state.phase.clone();
let _ = app.emit(
"game-creator-agent-runtime-update",
GameCreatorAgentRuntimeUpdateEvent {
project_path: root.to_string_lossy().into_owned(),
agent_id,
run_id,
status,
phase,
runtime,
},
game_creator_agent_runtime_update_event(root, runtime),
);
}
@@ -629,9 +629,30 @@ struct GameCreatorAgentRuntimeUpdateEvent {
run_id: String,
status: String,
phase: String,
manifest_invalidated: bool,
runtime: AgentRuntimeResult,
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorManifestInvalidatedEvent {
project_path: String,
agent_id: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorManifestInvalidationRelayEnvelope {
token: String,
event: GameCreatorManifestInvalidatedEvent,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct GameCreatorManifestInvalidationEventSink {
port: u16,
token: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorAgentProgressEvent {
@@ -2092,7 +2113,10 @@ fn main() {
format!("启动 Agent Runner 失败:{error}"),
)
})?;
attach_external_agent_runner_gui_owner()
set_game_creator_agent_runtime_update_app_handle(app.handle().clone());
let manifest_event_sink =
start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?;
attach_external_agent_runner_gui_owner(&manifest_event_sink)
.inspect_err(|error| {
if let Some(path) = setup_log.as_deref() {
let details =
@@ -2113,7 +2137,6 @@ fn main() {
if let Some(path) = setup_log.as_deref() {
let _ = append_bounded_diagnostic_line(path, "startup.runner.start.complete");
}
set_game_creator_agent_runtime_update_app_handle(app.handle().clone());
#[cfg(all(debug_assertions, not(test)))]
if game_chat_launch.is_none() {
open_developer_window(app.handle())?;
@@ -1,5 +1,8 @@
use super::{dispatch::*, endpoint::*, project_owner::*, protocol::*, state::*};
use crate::{AgentRuntimeContextCompactionResult, GameCreatorMcpCatalog};
use crate::{
AgentRuntimeContextCompactionResult, GameCreatorManifestInvalidationEventSink,
GameCreatorMcpCatalog,
};
use serde_json::Value;
use sha2::{Digest as _, Sha256};
use std::ffi::OsString;
@@ -911,7 +914,9 @@ pub(crate) fn shutdown_external_agent_runner() -> Result<(), String> {
shutdown_external_agent_runner_at(&config_dir)
}
pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> {
pub(crate) fn attach_external_agent_runner_gui_owner(
event_sink: &GameCreatorManifestInvalidationEventSink,
) -> Result<(), String> {
EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT
.store(true, std::sync::atomic::Ordering::Release);
let config_dir = external_agent_runner_config_dir()
@@ -920,12 +925,18 @@ pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> {
let result = send_external_agent_runner_request(
&endpoint,
"runner.attach_gui_owner",
ExternalAgentRunnerRequestParams::default(),
ExternalAgentRunnerRequestParams {
event_sink_port: Some(event_sink.port),
event_sink_token: Some(event_sink.token.clone()),
..ExternalAgentRunnerRequestParams::default()
},
)?;
if result.get("attached").and_then(Value::as_bool) == Some(true) {
if result.get("attached").and_then(Value::as_bool) == Some(true)
&& result.get("eventSinkAttached").and_then(Value::as_bool) == Some(true)
{
Ok(())
} else {
Err("Agent Runner attach_gui_owner 响应未确认 owner".to_string())
Err("Agent Runner attach_gui_owner 响应未确认 owner 与事件接收端".to_string())
}
}
@@ -1181,6 +1192,8 @@ pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity(
run_id: run_id.map(str::to_string),
action_id: action_id.map(str::to_string),
steer_id: steer_id.map(str::to_string),
event_sink_port: None,
event_sink_token: None,
};
match stable_identity {
Some(stable_identity) => {
@@ -1,4 +1,5 @@
use super::{endpoint::*, project_owner::*, protocol::*, state::*};
use crate::configure_game_creator_manifest_invalidation_event_sink;
use serde::Deserialize;
use serde_json::json;
use sha2::{Digest as _, Sha256};
@@ -587,10 +588,27 @@ pub(super) fn dispatch_external_agent_runner_runtime_request(
"runner.attach_gui_owner" => {
match external_agent_runner_gui_owner_is_locked(&state.gui_owner_lock_path) {
Ok(true) => {
let event_sink = request
.params
.event_sink_port
.zip(request.params.event_sink_token.as_deref())
.ok_or_else(|| {
"Agent Runner GUI owner 缺少 manifest 事件接收端".to_string()
})
.and_then(|(port, token)| {
configure_game_creator_manifest_invalidation_event_sink(port, token)
});
if let Err(error) = event_sink {
return ExternalAgentRunnerResponse::failure(
&request.request_id,
"event-sink-invalid",
redact_runner_secret(&error, &token),
);
}
state.gui_owner_attached.store(true, Ordering::Release);
ExternalAgentRunnerResponse::success(
&request.request_id,
json!({ "attached": true }),
json!({ "attached": true, "eventSinkAttached": true }),
)
}
Ok(false) => ExternalAgentRunnerResponse::failure(
@@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 4;
pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 5;
pub(super) const EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json";
pub(super) const EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock";
@@ -264,6 +264,10 @@ pub(super) struct ExternalAgentRunnerRequestParams {
pub(super) action_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) steer_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) event_sink_port: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) event_sink_token: Option<String>,
}
#[derive(Deserialize, Serialize)]
@@ -84,7 +84,7 @@ fn context_compaction_client_uses_long_response_timeout_without_widening_other_m
assert!(
external_agent_runner_client_read_timeout("mcp.status") > EXTERNAL_AGENT_RUNNER_IO_TIMEOUT
);
assert_eq!(EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, 4);
assert_eq!(EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, 5);
}
fn test_endpoint(token: &str, boot_id: &str, port: u16) -> ExternalAgentRunnerEndpoint {
@@ -583,7 +583,11 @@ fn attached_gui_owner_loss_forces_runner_shutdown() {
request_id: "gui-owner-attach-1".to_string(),
token: token.to_string(),
method: "runner.attach_gui_owner".to_string(),
params: ExternalAgentRunnerRequestParams::default(),
params: ExternalAgentRunnerRequestParams {
event_sink_port: Some(31_318),
event_sink_token: Some("b".repeat(64)),
..ExternalAgentRunnerRequestParams::default()
},
},
&state,
);
@@ -599,6 +603,7 @@ fn attached_gui_owner_loss_forces_runner_shutdown() {
assert!(state.draining.load(Ordering::Acquire));
assert!(state.force_shutdown_requested.load(Ordering::Acquire));
assert!(state.shutdown_requested.load(Ordering::Acquire));
crate::clear_game_creator_manifest_invalidation_event_sink_for_test();
}
#[test]
@@ -13,6 +13,49 @@ static TEST_PROJECT_COUNTER: AtomicU64 = AtomicU64::new(0);
static TEST_MOCK_PORT_COUNTER: AtomicU64 = AtomicU64::new(20_000);
static TEST_CONFIG_LOCK: StdMutex<()> = StdMutex::new(());
#[test]
fn non_supervisor_runtime_update_invalidates_manifest_on_the_wire_and_runner_relay() {
let root = unique_project_path();
init_local_game_project_at(&root, "runtime-event-contract", "Runtime 事件合同测试")
.expect("init runtime event contract project");
let runtime = read_game_creator_agent_runtime_at(&root, "art-asset-plan")
.expect("read non-Supervisor runtime");
let event = game_creator_agent_runtime_update_event(&root, runtime);
let serialized = serde_json::to_value(event).expect("serialize runtime update event");
assert_eq!(serialized["agentId"], "art-asset-plan");
assert_eq!(serialized["manifestInvalidated"], true);
let relay_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
.expect("bind manifest invalidation relay fixture");
let relay_port = relay_listener
.local_addr()
.expect("read manifest invalidation relay fixture address")
.port();
let relay_token = "a".repeat(64);
configure_game_creator_manifest_invalidation_event_sink(relay_port, &relay_token)
.expect("configure manifest invalidation relay fixture");
emit_game_creator_agent_runtime_update(&root, "art-asset-plan");
let (mut relay_stream, _) = relay_listener
.accept()
.expect("accept manifest invalidation relay");
relay_stream
.set_read_timeout(Some(Duration::from_secs(1)))
.expect("set manifest invalidation relay read timeout");
let mut relay_payload = Vec::new();
relay_stream
.read_to_end(&mut relay_payload)
.expect("read manifest invalidation relay");
clear_game_creator_manifest_invalidation_event_sink_for_test();
let relay: GameCreatorManifestInvalidationRelayEnvelope =
serde_json::from_slice(&relay_payload).expect("parse 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, "art-asset-plan");
fs::remove_dir_all(root).ok();
}
#[test]
fn gui_final_exit_is_the_only_run_event_that_requests_runner_shutdown() {
assert!(game_creator_gui_run_event_requests_runner_shutdown(
+118 -19
View File
@@ -53,6 +53,7 @@ import type {
GameCreatorAgentRuntimeUpdateEvent,
GameCreatorChatAgentReply,
GameCreatorLlmConfigStatus,
GameCreatorManifestInvalidatedEvent,
GameCreatorRoleAgentChatStreamEvent,
GenerateLocalGameDraftResult,
ImportCanvasExportResult,
@@ -612,6 +613,16 @@ export function App({
);
const localProjectPathRef = useRef<string | null>(null);
localProjectPathRef.current = localProject?.projectPath ?? null;
const manifestRefreshMountedRef = useRef(true);
const manifestRefreshStatesRef = useRef(
new Map<
string,
{
pending: boolean;
inFlight: Promise<void> | null;
}
>(),
);
const [manifest, setManifest] = useState<GameCreationAppManifest>(
initialProjectManifest ?? seedManifest,
);
@@ -818,6 +829,77 @@ export function App({
const projectSupervisorResponseStreamRef =
useRef<AgentRuntimeResponseStream | null>(null);
projectSupervisorResponseStreamRef.current = projectSupervisorResponseStream;
const refreshManifest = useCallback(
(
nextProjectPath = localProjectPathRef.current ?? '',
): Promise<void> => {
const invoke = resolveTauriInvoke();
if (!invoke || !nextProjectPath) {
return Promise.resolve();
}
const refreshStates = manifestRefreshStatesRef.current;
let refreshState = refreshStates.get(nextProjectPath);
if (!refreshState) {
refreshState = { pending: false, inFlight: null };
refreshStates.set(nextProjectPath, refreshState);
}
refreshState.pending = true;
if (refreshState.inFlight) {
return refreshState.inFlight;
}
const activeRefreshState = refreshState;
const refreshPromise = (async () => {
try {
while (activeRefreshState.pending) {
activeRefreshState.pending = false;
const projectScopeVersion = projectScopeVersionRef.current;
try {
const nextManifest = await invoke<GameCreationAppManifest>(
'get_local_game_manifest',
{ projectPath: nextProjectPath },
);
if (
manifestRefreshMountedRef.current &&
localProjectPathRef.current === nextProjectPath &&
projectScopeVersionRef.current === projectScopeVersion
) {
setManifest(nextManifest);
}
} catch {
// Dev-only convenience; command errors are surfaced by the action that triggered them.
}
if (
!manifestRefreshMountedRef.current ||
localProjectPathRef.current !== nextProjectPath ||
projectScopeVersionRef.current !== projectScopeVersion
) {
activeRefreshState.pending = false;
}
}
} finally {
activeRefreshState.inFlight = null;
if (!activeRefreshState.pending) {
refreshStates.delete(nextProjectPath);
}
}
})();
activeRefreshState.inFlight = refreshPromise;
return refreshPromise;
},
[],
);
useEffect(() => {
const refreshStates = manifestRefreshStatesRef.current;
manifestRefreshMountedRef.current = true;
return () => {
manifestRefreshMountedRef.current = false;
refreshStates.clear();
};
}, []);
const projectSupervisorRuntimeSyncingRef = useRef(new Set<string>());
const projectSupervisorRefreshConversationRef = useRef<
| ((
@@ -1306,6 +1388,9 @@ export function App({
if (payload.projectPath !== localProjectPathRef.current) {
return;
}
if (payload.manifestInvalidated) {
void refreshManifest(payload.projectPath);
}
const nextRuntime = agentRuntimeStateFromResult(payload.runtime);
if (gameChatOnly && payload.agentId !== PROJECT_SUPERVISOR_AGENT_ID) {
appendGameChatFinalReplyMessages(payload.projectPath, [
@@ -1395,10 +1480,43 @@ export function App({
}, [
appendGameChatFinalReplyMessages,
gameChatOnly,
refreshManifest,
updateProjectSupervisorResponseStream,
updateProjectSupervisorRuntime,
]);
useEffect(() => {
const listen = window.__TAURI__?.event?.listen;
if (!listen) {
return;
}
let cleanup: (() => void) | null = null;
let disposed = false;
void listen<GameCreatorManifestInvalidatedEvent>(
'game-creator-manifest-invalidated',
(event) => {
if (event.payload.projectPath !== localProjectPathRef.current) {
return;
}
void refreshManifest(event.payload.projectPath);
},
)
.then((unlisten) => {
if (disposed) {
unlisten();
return;
}
cleanup = unlisten;
})
.catch(() => {
// In-process Runtime events continue to carry the same invalidation signal.
});
return () => {
disposed = true;
cleanup?.();
};
}, [refreshManifest]);
useEffect(() => {
const invoke = resolveTauriInvoke();
const nextProjectPath = localProject?.projectPath ?? null;
@@ -9982,25 +10100,6 @@ export function App({
}
}
async function refreshManifest(
nextProjectPath = resolveChatProjectPath(localProject) ?? '',
) {
const invoke = resolveTauriInvoke();
if (!invoke || !nextProjectPath) {
return;
}
try {
const nextManifest = await invoke<GameCreationAppManifest>(
'get_local_game_manifest',
{ projectPath: nextProjectPath },
);
setManifest(nextManifest);
} catch {
// Dev-only convenience; command errors are surfaced by the action that triggered them.
}
}
async function loadAgentRunTraceFile(
relativePath: string,
nextProjectPath = resolveChatProjectPath(localProject) ?? '',
@@ -426,9 +426,15 @@ export interface GameCreatorAgentRuntimeUpdateEvent {
runId: string;
status: string;
phase: string;
manifestInvalidated: boolean;
runtime: AgentRuntimeResult;
}
export interface GameCreatorManifestInvalidatedEvent {
projectPath: string;
agentId: string;
}
export const gameCreatorLlmReasoningEfforts = [
'default',
'low',
@@ -440,8 +440,12 @@ export default function ProjectDevelopmentView({
});
const [mediaDuration, setMediaDuration] = useState<number | null>(null);
const resourceCanvasRef = useRef<HTMLDivElement>(null);
const resourceSearchRef = useRef<HTMLInputElement>(null);
const resourceFocusRef = useRef<HTMLElement>(null);
const resourceFocusTriggerIdRef = useRef<string | null>(null);
const previousFocusedResourceIdRef = useRef<string | null>(null);
const resourceFocusProjectPathRef = useRef(projectPath);
const suppressResourceFocusRestoreRef = useRef(false);
const resourceListScrollRef = useRef({ left: 0, top: 0 });
const restoreResourceListScrollRef = useRef(false);
const dependencyDescriptionId = useId();
@@ -731,6 +735,10 @@ export default function ProjectDevelopmentView({
resources.find((resource) => resource.id === selectedResourceId) ?? null;
const focusedResource =
resources.find((resource) => resource.id === focusedResourceId) ?? null;
const focusedResourcePath = focusedResource?.path ?? null;
const focusedResourceCategory = focusedResource?.category ?? null;
const focusedResourceContent = focusedResource?.content;
const focusedResourceMediaType = focusedResource?.mediaType ?? null;
const focusedResourceIsImage = Boolean(
focusedResource && isRasterImageResource(focusedResource),
);
@@ -793,12 +801,22 @@ export default function ProjectDevelopmentView({
useEffect(() => {
if (embeddedPreviewUrl) {
suppressResourceFocusRestoreRef.current = true;
restoreResourceListScrollRef.current = false;
resourceFocusTriggerIdRef.current = null;
setFocusedResourceId(null);
setMode('run');
}
}, [embeddedPreviewUrl]);
useEffect(() => {
useLayoutEffect(() => {
if (resourceFocusProjectPathRef.current === projectPath) {
return;
}
resourceFocusProjectPathRef.current = projectPath;
suppressResourceFocusRestoreRef.current = true;
previousFocusedResourceIdRef.current = null;
resourceFocusTriggerIdRef.current = null;
setSelectedResourceId(null);
setFocusedResourceId(null);
resourceListScrollRef.current = { left: 0, top: 0 };
@@ -820,7 +838,11 @@ export default function ProjectDevelopmentView({
}, [focusedResourceId]);
useEffect(() => {
if (!focusedResource || !focusedResourceIsImage) {
if (
!focusedResourceId ||
!focusedResourcePath ||
!focusedResourceIsImage
) {
setImagePreview({ status: 'idle', resourceId: null });
return undefined;
}
@@ -828,23 +850,27 @@ export default function ProjectDevelopmentView({
if (!invoke) {
setImagePreview({
status: 'failed',
resourceId: focusedResource.id,
resourceId: focusedResourceId,
error: '图片预览需要在客户端内打开',
});
return undefined;
}
let cancelled = false;
setImagePreview({ status: 'loading', resourceId: focusedResource.id });
setImagePreview((current) =>
current.resourceId === focusedResourceId
? current
: { status: 'loading', resourceId: focusedResourceId },
);
void invoke<LocalProjectImagePreview>('read_local_project_image_preview', {
projectPath,
relativePath: focusedResource.path,
relativePath: focusedResourcePath,
})
.then((preview) => {
if (!cancelled) {
setImagePreview({
status: 'loaded',
resourceId: focusedResource.id,
resourceId: focusedResourceId,
preview,
});
}
@@ -853,7 +879,7 @@ export default function ProjectDevelopmentView({
if (!cancelled) {
setImagePreview({
status: 'failed',
resourceId: focusedResource.id,
resourceId: focusedResourceId,
error: imagePreviewErrorMessage(error),
});
}
@@ -861,22 +887,31 @@ export default function ProjectDevelopmentView({
return () => {
cancelled = true;
};
}, [focusedResource, focusedResourceIsImage, projectPath]);
}, [
focusedResourceId,
focusedResourceIsImage,
focusedResourcePath,
projectPath,
]);
useEffect(() => {
if (!focusedResource || focusedResource.category !== 'document') {
if (
!focusedResourceId ||
!focusedResourcePath ||
focusedResourceCategory !== 'document'
) {
setTextPreview({ status: 'idle', resourceId: null });
return undefined;
}
if (focusedResource.content !== undefined) {
if (focusedResourceContent !== undefined) {
setTextPreview({
status: 'loaded',
resourceId: focusedResource.id,
resourceId: focusedResourceId,
preview: {
path: focusedResource.path,
mediaType: focusedResource.mediaType,
byteLen: new TextEncoder().encode(focusedResource.content).byteLength,
content: focusedResource.content,
path: focusedResourcePath,
mediaType: focusedResourceMediaType ?? 'text/plain',
byteLen: new TextEncoder().encode(focusedResourceContent).byteLength,
content: focusedResourceContent,
},
});
return undefined;
@@ -885,23 +920,27 @@ export default function ProjectDevelopmentView({
if (!invoke) {
setTextPreview({
status: 'failed',
resourceId: focusedResource.id,
resourceId: focusedResourceId,
error: '文档预览需要在客户端内打开',
});
return undefined;
}
let cancelled = false;
setTextPreview({ status: 'loading', resourceId: focusedResource.id });
setTextPreview((current) =>
current.resourceId === focusedResourceId
? current
: { status: 'loading', resourceId: focusedResourceId },
);
void invoke<LocalProjectTextPreview>('read_local_project_text_preview', {
projectPath,
relativePath: focusedResource.path,
relativePath: focusedResourcePath,
})
.then((preview) => {
if (!cancelled) {
setTextPreview({
status: 'loaded',
resourceId: focusedResource.id,
resourceId: focusedResourceId,
preview,
});
}
@@ -910,7 +949,7 @@ export default function ProjectDevelopmentView({
if (!cancelled) {
setTextPreview({
status: 'failed',
resourceId: focusedResource.id,
resourceId: focusedResourceId,
error: mediaPreviewErrorMessage(error),
});
}
@@ -918,11 +957,20 @@ export default function ProjectDevelopmentView({
return () => {
cancelled = true;
};
}, [focusedResource, projectPath]);
}, [
focusedResourceCategory,
focusedResourceContent,
focusedResourceId,
focusedResourceMediaType,
focusedResourcePath,
projectPath,
]);
useEffect(() => {
if (
!focusedResource ||
!focusedResourceId ||
!focusedResourcePath ||
!focusedResourceCategory ||
(!focusedResourceIsExtendedArtMedia && !focusedResourceIsAudio)
) {
setMediaPreview({ status: 'idle', resourceId: null });
@@ -933,7 +981,7 @@ export default function ProjectDevelopmentView({
if (!invoke) {
setMediaPreview({
status: 'failed',
resourceId: focusedResource.id,
resourceId: focusedResourceId,
error: '媒体预览需要在客户端内打开',
});
return undefined;
@@ -941,17 +989,21 @@ export default function ProjectDevelopmentView({
let cancelled = false;
setMediaDuration(null);
setMediaPreview({ status: 'loading', resourceId: focusedResource.id });
setMediaPreview((current) =>
current.resourceId === focusedResourceId
? current
: { status: 'loading', resourceId: focusedResourceId },
);
void invoke<LocalProjectMediaPreview>('read_local_project_media_preview', {
projectPath,
relativePath: focusedResource.path,
category: focusedResource.category,
relativePath: focusedResourcePath,
category: focusedResourceCategory,
})
.then((preview) => {
if (!cancelled) {
setMediaPreview({
status: 'loaded',
resourceId: focusedResource.id,
resourceId: focusedResourceId,
preview,
});
}
@@ -960,7 +1012,7 @@ export default function ProjectDevelopmentView({
if (!cancelled) {
setMediaPreview({
status: 'failed',
resourceId: focusedResource.id,
resourceId: focusedResourceId,
error: mediaPreviewErrorMessage(error),
});
}
@@ -969,17 +1021,39 @@ export default function ProjectDevelopmentView({
cancelled = true;
};
}, [
focusedResource,
focusedResourceCategory,
focusedResourceId,
focusedResourceIsAudio,
focusedResourceIsExtendedArtMedia,
focusedResourcePath,
projectPath,
]);
useLayoutEffect(() => {
if (focusedResource) {
resourceFocusRef.current?.focus({ preventScroll: true });
if (suppressResourceFocusRestoreRef.current) {
suppressResourceFocusRestoreRef.current = false;
previousFocusedResourceIdRef.current = focusedResourceId;
return;
}
if (focusedResourceId && !focusedResource) {
previousFocusedResourceIdRef.current = null;
resourceFocusTriggerIdRef.current = null;
restoreResourceListScrollRef.current = false;
setSelectedResourceId((current) =>
current === focusedResourceId ? null : current,
);
setFocusedResourceId(null);
resourceSearchRef.current?.focus({ preventScroll: true });
return;
}
if (focusedResourceId && focusedResource) {
if (previousFocusedResourceIdRef.current !== focusedResourceId) {
resourceFocusRef.current?.focus({ preventScroll: true });
}
previousFocusedResourceIdRef.current = focusedResourceId;
return;
}
previousFocusedResourceIdRef.current = null;
if (!restoreResourceListScrollRef.current) {
return;
}
@@ -988,16 +1062,22 @@ export default function ProjectDevelopmentView({
canvas.scrollLeft = resourceListScrollRef.current.left;
canvas.scrollTop = resourceListScrollRef.current.top;
const triggerResourceId = resourceFocusTriggerIdRef.current;
if (triggerResourceId) {
Array.from(
canvas.querySelectorAll<HTMLButtonElement>('[data-resource-id]'),
)
.find((card) => card.dataset.resourceId === triggerResourceId)
?.focus({ preventScroll: true });
const triggerCard = triggerResourceId
? Array.from(
canvas.querySelectorAll<HTMLButtonElement>('[data-resource-id]'),
).find((card) => card.dataset.resourceId === triggerResourceId)
: null;
if (triggerCard) {
triggerCard.focus({ preventScroll: true });
} else {
resourceSearchRef.current?.focus({ preventScroll: true });
}
} else {
resourceSearchRef.current?.focus({ preventScroll: true });
}
resourceFocusTriggerIdRef.current = null;
restoreResourceListScrollRef.current = false;
}, [focusedResource]);
}, [focusedResource, focusedResourceId]);
const handleResourceSelect = useCallback((resourceId: string) => {
const canvas = resourceCanvasRef.current;
@@ -1007,6 +1087,7 @@ export default function ProjectDevelopmentView({
top: canvas.scrollTop,
};
}
suppressResourceFocusRestoreRef.current = false;
resourceFocusTriggerIdRef.current = resourceId;
setSelectedResourceId(resourceId);
setFocusedResourceId(resourceId);
@@ -1021,6 +1102,9 @@ export default function ProjectDevelopmentView({
if (!runAvailable) {
return;
}
suppressResourceFocusRestoreRef.current = true;
restoreResourceListScrollRef.current = false;
resourceFocusTriggerIdRef.current = null;
setFocusedResourceId(null);
setMode('run');
}
@@ -1177,6 +1261,7 @@ export default function ProjectDevelopmentView({
<video
src={mediaPreview.preview.dataUrl}
controls
tabIndex={0}
preload="metadata"
aria-label={`${focusedResource.label} 视频预览`}
onError={() =>
@@ -1218,6 +1303,7 @@ export default function ProjectDevelopmentView({
<audio
src={mediaPreview.preview.dataUrl}
controls
tabIndex={0}
preload="metadata"
aria-label={`${focusedResource.label} 音频播放器`}
onLoadedMetadata={(event) =>
@@ -1354,6 +1440,7 @@ export default function ProjectDevelopmentView({
<label className="game-resource-search">
<Search size={16} aria-hidden="true" />
<input
ref={resourceSearchRef}
type="search"
aria-label="搜索项目资源"
value={searchText}
@@ -334,10 +334,19 @@ function createProjectSupervisorRuntimeHarness({
runId: string;
status: string;
phase: string;
manifestInvalidated: boolean;
runtime: Record<string, unknown>;
};
}) => void)
| null = null;
let manifestInvalidatedHandler:
| ((event: {
payload: {
projectPath: string;
agentId: string;
};
}) => void)
| null = null;
const conversationRecord = (
role: 'user' | 'assistant',
@@ -614,10 +623,16 @@ function createProjectSupervisorRuntimeHarness({
if (eventName === 'game-creator-agent-runtime-update') {
runtimeUpdateHandler = handler;
}
if (eventName === 'game-creator-manifest-invalidated') {
manifestInvalidatedHandler = handler as unknown as typeof manifestInvalidatedHandler;
}
return () => {
if (runtimeUpdateHandler === handler) {
runtimeUpdateHandler = null;
}
if (manifestInvalidatedHandler === handler) {
manifestInvalidatedHandler = null;
}
};
},
);
@@ -666,6 +681,7 @@ function createProjectSupervisorRuntimeHarness({
runId: String(state.runId),
status: String(state.status),
phase: String(state.phase),
manifestInvalidated: true,
runtime: runtimeResult(currentRuntime, currentResponseStream),
},
});
@@ -678,10 +694,19 @@ function createProjectSupervisorRuntimeHarness({
runId: String(state.runId ?? ''),
status: String(state.status ?? ''),
phase: String(state.phase ?? ''),
manifestInvalidated: true,
runtime: runtimeResult(state, null),
},
});
},
emitManifestInvalidated(agentId: string) {
manifestInvalidatedHandler?.({
payload: {
projectPath,
agentId,
},
});
},
};
}
@@ -2,6 +2,7 @@ import type { ProjectSupervisorComponentProps } from '../../src/features/app-she
import { WorkspaceLauncherShell } from '../../src/features/app-shell/WorkspaceLauncher';
import {
act,
App,
cleanup,
createGameCreationAppManifest,
createProjectSupervisorRuntimeHarness,
@@ -172,6 +173,322 @@ export function registerClientHomeTests() {
});
});
it('re-reads the live manifest from a non-Supervisor Runtime event without reopening the project', async () => {
const projectPath = '/tmp/live-runtime-manifest-workbench';
const initialManifest = createGameCreationAppManifest(
'live-runtime-manifest-project',
'真实事件清单项目',
);
const updatedManifest = {
...initialManifest,
tasks: initialManifest.tasks.map((task) =>
task.id === 'code-prototype'
? { ...task, status: 'completed' as const }
: task,
),
assets: [
{
id: 'runtime-live-hero',
kind: 'art-spritesheet',
mediaType: 'image/png',
localPath: 'assets/runtime-live-hero.png',
source: {
kind: 'canvas' as const,
taskId: 'art-asset-plan',
resourceId: 'canvas-runtime-live-hero',
},
},
],
versions: [
{
versionId: 'version-runtime-live-1',
parentVersionId: null,
projectRevision: 1,
resourceBindings: [
{ slotId: 'hero', resourceId: 'runtime-live-hero' },
],
createdReason: 'initial' as const,
createdAt: 1,
},
],
};
const runtimeHarness = createProjectSupervisorRuntimeHarness({
projectPath,
});
let manifestChanged = false;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: '真实事件清单项目',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return manifestChanged ? updatedManifest : initialManifest;
}
if (command === 'read_local_project_resource_graph') {
return {
resourceIds: (args?.resources as Array<{ resourceId: string }>).map(
(resource) => resource.resourceId,
),
referenceEdges: [],
taskFlows: [],
connectionIndex: [],
producerAssignments: [],
dependencyDepths: [],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
};
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: 'live-runtime-manifest-project',
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: 'live-runtime-manifest-project',
mode: args?.mode,
revision: 1,
positions: args?.positions,
updatedAt: 1,
},
};
}
return runtimeHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: runtimeHarness.listen },
};
render(
React.createElement(WorkspaceLauncherShell, {
currentUser: testAuthUser,
initialView: 'projects',
onLogout: vi.fn(),
ProjectSupervisor: App,
}),
);
fireEvent.change(screen.getByLabelText('项目目录'), {
target: { value: projectPath },
});
fireEvent.click(screen.getByRole('button', { name: '打开' }));
const runButton = await screen.findByRole('tab', { name: '运行' });
expect(runButton.getAttribute('data-unavailable')).toBe('true');
expect(
screen.queryByRole('button', { name: /runtime-live-hero\.png/ }),
).toBeNull();
const manifestReadsBeforeEvent = invoke.mock.calls.filter(
([command]) => command === 'get_local_game_manifest',
).length;
const inspectionsBeforeEvent = invoke.mock.calls.filter(
([command]) => command === 'inspect_local_project_directory',
).length;
manifestChanged = true;
act(() => {
runtimeHarness.emitManifestInvalidated('art-asset-plan');
});
expect(
await screen.findByRole('button', { name: /runtime-live-hero\.png/ }),
).not.toBeNull();
expect(screen.getByRole('button', { name: /版本 1/ })).not.toBeNull();
expect(runButton.getAttribute('data-unavailable')).toBeNull();
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) => command === 'get_local_game_manifest',
).length,
).toBeGreaterThan(manifestReadsBeforeEvent);
});
expect(
invoke.mock.calls.filter(
([command]) => command === 'inspect_local_project_directory',
).length,
).toBe(inspectionsBeforeEvent);
});
it('does not let a late manifest refresh from the previous project replace the active project', async () => {
const firstProjectPath = '/tmp/live-manifest-project-first';
const secondProjectPath = '/tmp/live-manifest-project-second';
const firstManifest = createGameCreationAppManifest(
'live-manifest-project-first',
'旧项目',
);
const staleFirstManifest = {
...firstManifest,
assets: [
{
id: 'stale-first-asset',
kind: 'art-spritesheet',
mediaType: 'image/png',
localPath: 'assets/stale-first.png',
source: { kind: 'generated' as const },
},
],
};
const secondManifest = createGameCreationAppManifest(
'live-manifest-project-second',
'新项目',
);
secondManifest.assets = [
{
id: 'second-asset',
kind: 'art-spritesheet',
mediaType: 'image/png',
localPath: 'assets/second.png',
source: { kind: 'generated' },
},
];
let resolveStaleRefresh!: (
manifest: typeof staleFirstManifest,
) => void;
const staleRefresh = new Promise<typeof staleFirstManifest>((resolve) => {
resolveStaleRefresh = resolve;
});
let holdFirstRefresh = false;
let signalFirstRefreshStarted!: () => void;
const firstRefreshStarted = new Promise<void>((resolve) => {
signalFirstRefreshStarted = resolve;
});
const runtimeHarness = createProjectSupervisorRuntimeHarness({
projectPath: firstProjectPath,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
const requestedPath = String(args?.projectPath ?? '');
if (command === 'inspect_local_project_directory') {
return {
projectPath: requestedPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName:
requestedPath === secondProjectPath ? '新项目' : '旧项目',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
if (requestedPath === secondProjectPath) {
return secondManifest;
}
if (holdFirstRefresh) {
signalFirstRefreshStarted();
return staleRefresh;
}
return firstManifest;
}
if (command === 'read_local_project_resource_graph') {
return {
resourceIds: (args?.resources as Array<{ resourceId: string }>).map(
(resource) => resource.resourceId,
),
referenceEdges: [],
taskFlows: [],
connectionIndex: [],
producerAssignments: [],
dependencyDepths: [],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
};
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId:
requestedPath === secondProjectPath
? secondManifest.projectId
: firstManifest.projectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: String(args?.expectedProjectId ?? ''),
mode: args?.mode,
revision: 1,
positions: args?.positions,
updatedAt: 1,
},
};
}
return runtimeHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: runtimeHarness.listen },
};
render(
React.createElement(WorkspaceLauncherShell, {
currentUser: testAuthUser,
initialView: 'projects',
onLogout: vi.fn(),
ProjectSupervisor: App,
}),
);
fireEvent.change(screen.getByLabelText('项目目录'), {
target: { value: firstProjectPath },
});
fireEvent.click(screen.getByRole('button', { name: '打开' }));
await screen.findByLabelText('项目开发工作台');
holdFirstRefresh = true;
act(() => {
runtimeHarness.emitManifestInvalidated('code-prototype');
});
await firstRefreshStarted;
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
fireEvent.change(screen.getByLabelText('项目目录'), {
target: { value: secondProjectPath },
});
fireEvent.click(
within(screen.getByLabelText('项目目录').closest('form')!).getByRole(
'button',
{ name: '打开' },
),
);
expect(
await screen.findByRole('button', { name: /second\.png/ }),
).not.toBeNull();
act(() => resolveStaleRefresh(staleFirstManifest));
await Promise.resolve();
expect(
screen.queryByRole('button', { name: /stale-first\.png/ }),
).toBeNull();
expect(screen.getByRole('button', { name: /second\.png/ })).not.toBeNull();
});
it('starts from the client home and opens a project in the same window', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
@@ -304,7 +304,11 @@ async function renderGameChatAutoPreviewDriver({
callIndex: number,
) => Promise<GameChatPreviewFixture>;
}) {
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
let backgroundRuntimes: Array<Record<string, unknown>> = [];
const harness = createProjectSupervisorRuntimeHarness({
projectPath,
runtimeMapLoader: async () => backgroundRuntimes,
});
const manifest = createGameCreationAppManifest(
projectPath.split(/[\\/]/u).filter(Boolean).at(-1) ?? 'game-chat-race',
'game-chat-race',
@@ -421,14 +425,14 @@ async function renderGameChatAutoPreviewDriver({
};
const emitValidation = async (revision: number, validatedAt: number) => {
harness.setProjectRevision(revision);
const runtime = gameChatPreviewPlaytestRuntime({
parentRunId,
revision,
updatedAt: validatedAt,
});
backgroundRuntimes = [runtime];
await act(async () => {
harness.emitAgentRuntime(
gameChatPreviewPlaytestRuntime({
parentRunId,
revision,
updatedAt: validatedAt,
}),
);
harness.emitAgentRuntime(runtime);
await Promise.resolve();
});
};
@@ -505,7 +509,7 @@ async function assertNewGameChatAuthorizationSupersedesDeferredAttempt(
: 'start_local_game_preview'),
);
expect(reachedDeferredStage).toBe(true);
});
}, { timeout: 2_500 });
const firstAuthorization = driver.readAuthorization();
expect(firstAuthorization?.authorizationId).toEqual(expect.any(String));
@@ -1148,6 +1152,130 @@ export function registerProjectWorkbenchFoundationTests() {
expect(screen.getByLabelText('子 Agent 状态栏')).not.toBeNull();
});
it('preserves internal media focus across same-resource manifest updates and falls back when the resource is deleted', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-focus-updates',
'资源焦点更新测试',
);
manifest.assets = [
{
id: 'focus-audio',
kind: 'background-music',
mediaType: 'audio/mpeg',
localPath: 'assets/focus.mp3',
source: { kind: 'generated' },
},
];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: 1,
positions: args?.positions,
updatedAt: 1,
},
};
}
if (command === 'read_local_project_media_preview') {
return {
path: 'assets/focus.mp3',
mediaType: 'audio/mpeg',
byteLen: 1024,
dataUrl: 'data:audio/mpeg;base64,SUQz',
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const viewProps = {
projectName: manifest.name,
projectPath: '/tmp/workbench-resource-focus-updates',
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
};
const rendered = render(
React.createElement(ProjectDevelopmentView, {
...viewProps,
manifest,
}),
);
fireEvent.click(
await screen.findByRole('button', { name: /focus\.mp3/ }),
);
const audio = (await screen.findByLabelText(
'focus.mp3 音频播放器',
)) as HTMLAudioElement;
audio.focus();
expect(document.activeElement).toBe(audio);
const updatedManifest = {
...manifest,
tasks: manifest.tasks.map((task) =>
task.id === 'audio-director'
? { ...task, status: 'completed' as const }
: task,
),
assets: manifest.assets.map((asset) => ({
...asset,
source: { ...asset.source, taskId: 'audio-director' },
})),
};
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...viewProps,
manifest: updatedManifest,
}),
);
expect(document.activeElement).toBe(audio);
expect(
screen.getByRole('region', { name: 'focus.mp3' }),
).not.toBe(document.activeElement);
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...viewProps,
manifest: { ...updatedManifest, assets: [] },
}),
);
await waitFor(() => {
expect(screen.queryByRole('region', { name: 'focus.mp3' })).toBeNull();
expect(document.activeElement).toBe(
screen.getByLabelText('搜索项目资源'),
);
});
expect(
screen
.queryAllByTitle('打开资源详情')
.some((card) => card.getAttribute('aria-pressed') === 'true'),
).toBe(false);
});
it('renders, filters, and destroys persistent resource dependency lines without cross-section task flows', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-graph',
@@ -1,6 +1,6 @@
# AI 游戏创作项目开发工作台 PRD
更新时间:`2026-08-04`依赖视图视觉口径调整
更新时间:`2026-08-05`实时 manifest 与资源焦点状态机收口
## 1. 产品定位
@@ -129,7 +129,8 @@ idle -> focused(document|art|audio|version) -> idle
- 版本:只展示 manifest 中正式、不可变的迭代版本记录;版本卡展示项目修订、创建原因与父版本,聚焦态同时展示直接子版本和资源绑定。点击版本卡后高亮仍存在于当前资源投影中的引用资源;缺失历史资源只保留绑定身份,不生成幽灵资源卡。资源替换仍留给后续切片。
- mentor 最新决定:资源聚焦不提供工具栏,也不提供工具侧边栏。
- 点击资源后,中央主视窗从 `resources.list` 切换为 `resources.focused.document / art / audio / version`,左侧平台导航、右侧 Supervisor 对话和底部 Agent 状态栏保持原位;聚焦容器只包含标题、资源主体、必要元数据与右上角收起按钮,不使用页面级浮层或可拖动标题栏。
- 退出聚焦后恢复进入前的搜索条件、dependency / type 布局模式、资源画布滚动位置和选中资源;这些只属于当前前端会话,不写入布局 sidecar
- 焦点转换以稳定资源 ID 为准。只有从资源列表进入详情或从一个资源 ID 切换到另一个 ID 时聚焦详情 region;同一资源 ID 因 manifest 更新而重新投影时,不得抢走详情内音频 / 视频控件、文档链接或收起按钮的当前焦点
- 显式收起或按 Escape 后恢复进入前的搜索条件、dependency / type 布局模式、资源画布滚动位置和选中资源,并优先把键盘焦点还给原触发资源卡;这些只属于当前前端会话,不写入布局 sidecar。若资源已经被后台删除,必须清理 stale focused / selected ID、关闭详情并把焦点落到“搜索项目资源”,不得落到 `body`。项目切换和进入运行视图必须取消旧项目的焦点恢复意图。
- 阶段四只新增上述受控读取与媒体展示;阶段六在同一聚焦容器内补齐正式版本只读展示和引用高亮,但不新增资源聚焦工具栏 / 工具侧边栏,不新增美术编辑、音频编辑 / 替换、资源重新生成、版本替换或运行模块。飞书原需求中“编辑并生成新资源”的条件项仍暂缓,不能只打开画板却缺少回写、`referenceResourceIds` 血缘登记、新资源自动选中与邻近布局的完整闭环。
### 4.4 历史成果与当前状态
@@ -416,6 +417,8 @@ type ProjectAgentMudPointAttribution = {
7. 未识别任务产物不进入“项目版本”,只有正式版本 read model 可以生成版本卡;资源显示名称变化不改变资源身份。
8. 点击任一资源后只替换中央主视窗,右侧对话与底部 Agent 状态栏保持原位;收起或按 Escape 退出后恢复原搜索、布局模式、滚动位置、选中资源和触发资源卡键盘焦点。
9. 当前 Supervisor 运行期间 manifest 新增资产、任务状态、预览状态和正式版本后,工作台无需重开项目即可同步更新资源列表、依赖图输入、运行入口和版本卡;旧项目迟到回调不得覆盖当前项目。
10. 实时 manifest 验收必须捕获真实 App Tauri listener,并让 `get_local_game_manifest` 在非 Supervisor Agent 的 Runtime / manifest 失效事件后返回新快照;测试不得直接调用 `onManifestChange` 冒充数据源。读取合并、旧项目迟到响应和项目切换隔离必须分别有回归证据。
11. 音频或视频控件获得焦点后,同一资源 ID 的 manifest 更新不得把焦点移回详情 region;当前资源被删除后详情关闭、focused / selected ID 清理且焦点落到资源搜索框。显式收起与 Escape 的原卡片焦点和滚动恢复继续成立。
### 7.2 P1 资源画布布局持久化验收
@@ -6031,6 +6031,13 @@
- UI 决策:Project Supervisor 持有运行中 manifest 状态并向外层启动器同步完整快照;外层项目上下文继续是工作台投影的唯一输入,只接受当前项目路径的更新,不另建资产、任务或版本平行状态。
- 依赖图决策:Agent DB 尾部读取一旦截断,审计 producer、task flow 与对应 `cyclicTaskIds` 失败关闭;独立 `dependencyDepths` 仍由 Rust 从当前 manifest、精确资源引用和仍可信的任务深度下限构建,前端只做资源存在性与非负安全整数校验后继续消费。manifest 精确资源引用、reference connection index、资源环和 unresolved reference 与审计生产者证据分离。SVG 保持装饰性,辅助技术消费画布关联的文本关系列表。
## 2026-08-05 AI 游戏项目实时 manifest 失效与资源焦点状态机
- 失效源决策:后台 `task.update``canvas.asset_generate`、任务起止 / 终态投影、正式版本追加和 autonomous manifest reset 都处于 Runtime 动作或生命周期内,并在写入后回到共用 Runtime emitter;因此以该 emitter 作为统一 manifest 失效因果点,不在 WorkspaceLauncher 新增平行回调,也不轮询 manifest。Rust / TypeScript 的 `game-creator-agent-runtime-update` 合同增加 `manifestInvalidated`App 在全部 Supervisor、selected agent、session / run early return 之前消费它。
- 跨进程决策:External Runner 没有 GUI `AppHandle`,不能假设普通 Tauri Runtime event 会跨进程到达。Runner IPC 协议升级为 v5GUI owner attach 同时登记 GUI 创建的 loopback 随机端口和 64 位随机十六进制令牌;Runner 内同一 Runtime emitter 发送最小 `projectPath + agentId` relayGUI 校验令牌后转成 `game-creator-manifest-invalidated`。GUI 内 Runtime 继续直接发送完整 Runtime update。两条路径汇合到同一个 App manifest 重读器。
- 重读决策:`get_local_game_manifest` 按项目 single-flight;同项目读取中再次失效只排队一轮后续读取,不启动并发请求。响应应用必须同时匹配 mounted、活动项目路径和 project scope version,旧项目、旧 scope 或卸载后的响应全部丢弃。重读后的 App state 继续沿既有 `onManifestChange -> currentProjectContext -> ProjectDevelopmentView` 单向投影,不复制资产 / 任务 / 版本状态。
- 焦点决策:资源详情焦点以稳定 `resourceId` 的转换而非重建后的资源对象决定。`null -> id``idA -> idB` 聚焦详情;`idA -> idA` 保留详情内部 active element。显式收起 / Escape 恢复滚动并优先返回触发卡片;资源已删除时清理 focused / matching selected ID 并聚焦资源搜索框;项目或运行视图切换清除旧 trigger 与 restore 标志,禁止跨项目恢复。
## 2026-08-04 图片画布素材类型采用资源默认值与布局覆盖双层模型
- 背景:画布复制逻辑曾为副本生成 `local-resource-copy-*`,导致同一媒体被伪装成未登记资源;随后改为复用 `resourceId`,但手动修改图层标签仍通过“按新 `assetKind` 查找 / 创建项目资源并换绑当前图层”实现。这会让单纯标签修改增加资源行、漂移 `resourceId`,并在异步回填与复制交错时形成“新类型 + 旧资源”的副本。
@@ -4115,6 +4115,20 @@
- 处理:调用方在未认证时不得启动受保护的钱包刷新;可取消的读取要为每轮分配 `AbortController`,新读取先失效并中止旧读取,组件卸载时同时推进 revision、abort 当前请求并清空句柄。所有 `then / catch / finally` 在更新状态前都要检查 signal 与 revision。
- 验证:定向测试覆盖卸载后请求 signal 已中止;同时复跑触发钱包刷新回调的画布生成集成测试和完整前端测试,不能以单文件偶然快速收束代替全量验证。
## 下游 manifest 回调测试不能冒充实时数据源(2026-08-05)
- 现象:工作台的资源、任务与版本重投影单测保持绿色,但后台 Agent 已更新 `.agent/manifest.json` 后,打开中的工作台仍长期显示旧快照,只有重开项目才更新。
- 原因:测试 Supervisor 直接调用 `onManifestChange`,只证明 `App manifest -> WorkspaceLauncher -> ProjectDevelopmentView` 的下游桥接;真实 Runtime event 没有失效字段,监听器也没有重读 manifest。External Runner 又与 GUI 分属不同进程,Runner 内无法使用 GUI `AppHandle`,只补普通 Tauri event 仍不能形成生产链路。
- 处理:后台 manifest mutation 收敛到共用 Runtime emitterGUI 内进程用带 `manifestInvalidated` 的 Runtime updateExternal Runner 通过 GUI owner attach 登记的受令牌保护 loopback sink 转发专用失效事件。App 对当前项目做 single-flight manifest 重读,并以 mounted、项目路径和 scope version 丢弃迟到结果;WorkspaceLauncher 继续只消费完整 manifest 快照,不新增平行状态或轮询。
- 验证:集成测试必须渲染真实 `App + WorkspaceLauncher`、捕获真实 Tauri listener,让 `get_local_game_manifest` 从旧快照切换到新快照,并由非 Supervisor Agent 事件驱动资产、completed 任务、运行入口和版本卡出现;另测项目切换时旧请求迟到。旧的直接 `onManifestChange` 测试只能标记为下游桥接证据。
## React 资源详情焦点不能依赖重建对象身份(2026-08-05)
- 现象:音频 / 视频播放器、文档链接或收起按钮正在获得焦点时,后台 manifest 更新会把焦点突然移回详情 region;若当前资源被删除,详情虽然消失,stale focused ID 和焦点可能残留到 `body`
- 原因:资源投影每次生成新对象,`useLayoutEffect([focusedResource])` 把同一资源的内容更新误判为重新进入详情;删除路径没有显式恢复状态和可聚焦 fallback,项目 / 运行视图切换也可能沿用旧 trigger。
- 处理:焦点状态机只比较稳定 `resourceId``null -> id``idA -> idB` 聚焦详情,`idA -> idA` 保持当前 active element。显式收起 / Escape 才恢复原卡片与滚动;后台删除清理 focused / matching selected ID 并聚焦搜索框;项目或运行视图切换清空 trigger / restore。媒体预览副作用依赖稳定 ID、路径和类别,不因同 ID 对象重建先卸载控件。
- 验证:媒体控件获得焦点后用同 ID 新 manifest 重渲染并断言 active element 不变;删除资源后断言详情关闭、选中清理且搜索框获得焦点;既有收起、Escape、项目切换和运行切换测试继续通过。
## 不要用自然语言精确 `.replace()` 维护 Runtime Prompt
- 现象:Prompt 文案稍作改写、增删空格或调整段落后,替换静默失效,代码中出现难以审阅的链式 `.replace()`
@@ -895,6 +895,7 @@ game-project/
## 2026-08-04 manifest 与工作台一致性收口
- `.agent/manifest.json` 的存储写边界使用同目录持久文件锁跨线程、跨进程串行化;锁必须覆盖旧 manifest 读取、不可变版本前缀校验、临时文件安装和安装后回读一致性校验。锁文件拒绝符号链接、非普通文件和异常所有权 / 硬链接;Windows 使用不共享写句柄,Unix 使用 `O_NOFOLLOW + flock`。旧快照在新版本安装后只能被拒绝,不能覆盖已追加版本。
- 嵌入项目工作台的 Project Supervisor 在本地 manifest 状态变化时向启动器外传完整 manifest,并携带来源项目路径。启动器只更新仍为同一路径的活动项目上下文;资源列表、依赖图输入、任务状态、运行入口和正式版本卡必须在当前页面实时重投影,不要求关闭或重开项目
- 后台 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,也不靠轮询补偿
- App 收到当前项目的 Runtime / relay 失效后重新调用 `get_local_game_manifest`。重读按项目 single-flight 合并事件风暴;读取中再到达失效只追加一轮串行重读,不并发提交同项目响应。应用结果同时校验组件仍挂载、当前项目路径和项目 scope version;项目切换、组件卸载或旧 scope 的迟到响应不得覆盖新项目。Project Supervisor 再通过既有 `onManifestChange(projectPath, manifest)` 向启动器外传完整快照,启动器只更新仍为同一路径的活动项目上下文;资源列表、依赖图输入、任务状态、运行入口和正式版本卡必须在当前页面实时重投影,不要求关闭或重开项目。
- `.agent/agent.db` 有界尾部读取报告截断时,审计 producer 映射失败关闭,不生成基于不完整审计的 producer 或 task flow。前端收到截断 DTO 时再次清空 producer、task flow、任务环和依赖深度派生结果;只依赖 manifest 唯一外部资源 ID 的精确引用关系继续保留。
- 资源依赖 SVG 继续作为不可交互装饰层隐藏,但 dependency 画布通过 `aria-describedby` 提供当前可见精确引用和任务流的文本等价列表。中央资源聚焦关闭或按 Escape 退出后恢复触发卡片焦点;橙色引用线及箭头使用对 `#fffdfa` 画布达到至少 `3:1` 的颜色。
- 资源依赖 SVG 继续作为不可交互装饰层隐藏,但 dependency 画布通过 `aria-describedby` 提供当前可见精确引用和任务流的文本等价列表。中央资源聚焦按稳定 `resourceId` 驱动焦点状态:仅 `null -> id``idA -> idB` 聚焦详情 region,同一 ID 的 manifest 重投影不得抢走音频、视频、链接或关闭按钮焦点;显式收起和 Escape 恢复画布滚动并优先聚焦原触发卡片。聚焦资源被删除时清理 stale focused / selected ID,关闭详情并把焦点落到资源搜索框;项目切换或运行视图切换清除旧恢复意图,不得恢复旧项目卡片。橙色引用线及箭头使用对 `#fffdfa` 画布达到至少 `3:1` 的颜色。