接通实时清单更新并修复资源焦点竞态
通过统一 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',
|
||||
|
||||
@@ -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 协议升级为 v5,GUI owner attach 同时登记 GUI 创建的 loopback 随机端口和 64 位随机十六进制令牌;Runner 内同一 Runtime emitter 发送最小 `projectPath + agentId` relay,GUI 校验令牌后转成 `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 emitter;GUI 内进程用带 `manifestInvalidated` 的 Runtime update,External 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` 的颜色。
|
||||
|
||||
Reference in New Issue
Block a user