接通实时清单更新并修复资源焦点竞态
通过统一 Runtime emitter 和受令牌保护的 Runner relay 触发 manifest 重读 为 manifest 重读增加事件合并、项目隔离和迟到响应保护 按稳定资源 ID 重构详情焦点恢复与资源删除 fallback 补充真实 App 事件链、乱序项目切换、媒体焦点和 Rust relay 回归测试 同步更新工作台技术方案、PRD 与共享项目记忆
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user