完成资源管理的功能修复 #154
+1
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@lexical/react": "^0.47.0",
|
||||
"@lexical/utils": "^0.47.0",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
|
||||
"@tauri-apps/plugin-http": "^2.5.9",
|
||||
"@tauri-apps/plugin-opener": "~2",
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"dependencies": {
|
||||
"@lexical/react": "^0.47.0",
|
||||
"@lexical/utils": "^0.47.0",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
|
||||
"@tauri-apps/plugin-http": "^2.5.9",
|
||||
"@tauri-apps/plugin-opener": "~2",
|
||||
|
||||
+4
-37
@@ -725,16 +725,6 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -758,7 +748,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation",
|
||||
"core-graphics-types",
|
||||
"foreign-types 0.5.0",
|
||||
"libc",
|
||||
@@ -771,7 +761,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -2091,11 +2081,9 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4421,7 +4409,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
@@ -4977,27 +4965,6 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
@@ -5019,7 +4986,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"block2",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation",
|
||||
"core-graphics",
|
||||
"crossbeam-channel",
|
||||
"dbus",
|
||||
|
||||
@@ -42,7 +42,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "mul
|
||||
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
|
||||
tauri = { version = "2.11.2", features = [] }
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
tauri-plugin-http = "2.5.9"
|
||||
tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tempfile = "3"
|
||||
tokio = { version = "1", features = ["io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time"] }
|
||||
|
||||
@@ -5721,6 +5721,9 @@ mod canvas_generation_tests {
|
||||
};
|
||||
|
||||
fn read_test_http_request(stream: &mut std::net::TcpStream) -> String {
|
||||
stream
|
||||
.set_nonblocking(false)
|
||||
.expect("set request stream blocking");
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(10)))
|
||||
.expect("set request read timeout");
|
||||
|
||||
@@ -254,11 +254,17 @@ fn persist_game_chat_main_asset_audit_and_route(
|
||||
.expect("persist game-chat main asset route");
|
||||
}
|
||||
|
||||
fn game_chat_main_art_child_fixture(
|
||||
fn game_chat_main_art_child_fixture_with_lane(
|
||||
root: &Path,
|
||||
target_agent_id: &str,
|
||||
missing_slots: &[&str],
|
||||
) -> (AgentRuntimeState, AgentRuntimeState, String) {
|
||||
hold_child_lane: bool,
|
||||
) -> (
|
||||
AgentRuntimeState,
|
||||
AgentRuntimeState,
|
||||
String,
|
||||
Option<AgentRuntimeTaskLock>,
|
||||
) {
|
||||
init_local_game_project_at(root, "game-chat-art-child", "单主美术委派")
|
||||
.expect("init game-chat art child project");
|
||||
let root_run_id = "game-chat-main-art-root";
|
||||
@@ -310,6 +316,11 @@ fn game_chat_main_art_child_fixture(
|
||||
)
|
||||
.expect("persist game-chat main route");
|
||||
|
||||
let child_lane = hold_child_lane.then(|| {
|
||||
try_acquire_game_creator_agent_runtime_task_lock(root, target_agent_id)
|
||||
.expect("acquire delegated art child lane")
|
||||
.expect("delegated art child lane available")
|
||||
});
|
||||
let action_id = format!("game-chat-main-delegate-{target_agent_id}");
|
||||
let observation = observe_agent_runtime_agent_delegate(
|
||||
root,
|
||||
@@ -335,11 +346,27 @@ fn game_chat_main_art_child_fixture(
|
||||
)
|
||||
.expect("read delegated art child")
|
||||
.expect("delegated art child exists");
|
||||
(
|
||||
main,
|
||||
agent_runtime_state_from_task_record(&child),
|
||||
delegation_id,
|
||||
)
|
||||
let mut child = agent_runtime_state_from_task_record(&child);
|
||||
if child_lane.is_some() {
|
||||
child.status = "running".to_string();
|
||||
child.phase = "planning".to_string();
|
||||
child.current_action = "执行受限美术子任务".to_string();
|
||||
append_game_creator_agent_runtime_task(root, &child)
|
||||
.expect("persist running delegated art child");
|
||||
write_game_creator_agent_runtime_state(root, &child)
|
||||
.expect("persist running delegated art runtime");
|
||||
}
|
||||
(main, child, delegation_id, child_lane)
|
||||
}
|
||||
|
||||
fn game_chat_main_art_child_fixture(
|
||||
root: &Path,
|
||||
target_agent_id: &str,
|
||||
missing_slots: &[&str],
|
||||
) -> (AgentRuntimeState, AgentRuntimeState, String) {
|
||||
let (main, child, delegation_id, _child_lane) =
|
||||
game_chat_main_art_child_fixture_with_lane(root, target_agent_id, missing_slots, false);
|
||||
(main, child, delegation_id)
|
||||
}
|
||||
|
||||
fn game_chat_main_without_asset_audit_fixture(root: &Path) -> String {
|
||||
@@ -360,8 +387,12 @@ fn game_chat_main_without_asset_audit_fixture(root: &Path) -> String {
|
||||
async fn game_chat_main_agent_delegates_only_real_missing_art_and_limits_children_to_assets() {
|
||||
let temporary = tempfile::tempdir().expect("create game-chat art child root");
|
||||
let root = temporary.path().join("project");
|
||||
let (_main, mut child, _delegation_id) =
|
||||
game_chat_main_art_child_fixture(&root, "art-asset-plan", &["core-spritesheet"]);
|
||||
let (_main, mut child, _delegation_id, _child_lane) = game_chat_main_art_child_fixture_with_lane(
|
||||
&root,
|
||||
"art-asset-plan",
|
||||
&["core-spritesheet"],
|
||||
true,
|
||||
);
|
||||
assert_eq!(child.agent_id, "art-asset-plan");
|
||||
assert_eq!(child.source, "agent-delegate");
|
||||
assert_eq!(child.parent_agent_id.as_deref(), Some("code-prototype"));
|
||||
@@ -684,12 +715,25 @@ fn game_chat_main_agent_rejects_unneeded_or_wrong_art_delegation() {
|
||||
assert!(out_of_order.summary.contains("先完成并认领 art-director"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_chat_art_receipt_wakes_the_same_code_prototype_run() {
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn game_chat_art_receipt_wakes_the_same_code_prototype_run() {
|
||||
let temporary = tempfile::tempdir().expect("create game-chat receipt wake root");
|
||||
let root = temporary.path().join("project");
|
||||
let (mut main, mut child, delegation_id) =
|
||||
game_chat_main_art_child_fixture(&root, "art-asset-plan", &["core-spritesheet"]);
|
||||
let (mut main, mut child, delegation_id, child_lane) =
|
||||
game_chat_main_art_child_fixture_with_lane(
|
||||
&root,
|
||||
"art-asset-plan",
|
||||
&["core-spritesheet"],
|
||||
true,
|
||||
);
|
||||
let child_lane = child_lane.expect("delegated art child lane is held");
|
||||
main.status = "running".to_string();
|
||||
main.phase = "waiting-for-delegate-receipts".to_string();
|
||||
main.current_action = "等待临时美术 Agent 回执".to_string();
|
||||
main.waiting_on = "art-asset-plan 完成并回执".to_string();
|
||||
append_game_creator_agent_runtime_task(&root, &main).expect("persist waiting main task");
|
||||
write_game_creator_agent_runtime_state(&root, &main).expect("persist waiting main runtime");
|
||||
|
||||
child.status = "completed".to_string();
|
||||
child.phase = "completed".to_string();
|
||||
child.current_action = "已写入临时美术资产".to_string();
|
||||
@@ -702,6 +746,9 @@ fn game_chat_art_receipt_wakes_the_same_code_prototype_run() {
|
||||
)
|
||||
.expect("read completed art child")
|
||||
.expect("completed art child exists");
|
||||
let parent_lane = try_acquire_game_creator_agent_runtime_task_lock(&root, "code-prototype")
|
||||
.expect("acquire waiting main-agent execution lane")
|
||||
.expect("waiting main-agent execution lane is free");
|
||||
publish_game_creator_agent_delegate_result(
|
||||
&root,
|
||||
&child_task,
|
||||
@@ -756,12 +803,8 @@ fn game_chat_art_receipt_wakes_the_same_code_prototype_run() {
|
||||
assert_eq!(duplicate.status, "failed", "{duplicate:?}");
|
||||
assert!(duplicate.summary.contains("最多委派一次"));
|
||||
|
||||
main.status = "running".to_string();
|
||||
main.phase = "waiting-for-delegate-receipts".to_string();
|
||||
main.current_action = "等待临时美术 Agent 回执".to_string();
|
||||
main.waiting_on = "art-asset-plan 完成并回执".to_string();
|
||||
append_game_creator_agent_runtime_task(&root, &main).expect("persist waiting main task");
|
||||
write_game_creator_agent_runtime_state(&root, &main).expect("persist waiting main runtime");
|
||||
drop(child_lane);
|
||||
drop(parent_lane);
|
||||
let waiting = read_latest_game_creator_agent_runtime_task_by_run_id(
|
||||
&root,
|
||||
"code-prototype",
|
||||
@@ -771,6 +814,21 @@ fn game_chat_art_receipt_wakes_the_same_code_prototype_run() {
|
||||
.expect("waiting main task exists");
|
||||
let wake_started = wake_waiting_static_delegate_parent_run_at(&root, &waiting)
|
||||
.expect("wake same main run after art receipt");
|
||||
cancel_game_creator_agent_runtime_task_at(&root, "code-prototype", &main.run_id)
|
||||
.expect("cancel resumed fixture run");
|
||||
let release_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
if game_creator_agent_runtime_task_lock_is_available(&root, "code-prototype")
|
||||
.expect("probe resumed main-agent lane release")
|
||||
{
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < release_deadline,
|
||||
"resumed main-agent lane did not release before fixture teardown"
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
}
|
||||
let resumed = read_all_game_creator_agent_runtime_tasks(&game_creator_agent_runtime_task_path(
|
||||
&root,
|
||||
"code-prototype",
|
||||
@@ -786,8 +844,17 @@ fn game_chat_art_receipt_wakes_the_same_code_prototype_run() {
|
||||
|
||||
#[test]
|
||||
fn autonomous_parent_waits_for_active_child_while_registered_derived_visuals_need_repair() {
|
||||
let temporary = crate::tests::canonical_test_tempdir("main-loop-legacy-");
|
||||
let root = temporary.path().join("project");
|
||||
let temporary_root = std::env::temp_dir()
|
||||
.canonicalize()
|
||||
.expect("canonicalize system temporary directory");
|
||||
let root = temporary_root.join(format!(
|
||||
"genarrative-agent-main-loop-legacy-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("system clock")
|
||||
.as_nanos()
|
||||
));
|
||||
init_local_game_project_at(&root, "legacy-derived-visuals", "旧派生视觉返工门禁")
|
||||
.expect("project init");
|
||||
assert!(!autonomous_registered_derived_visuals_need_repair_at(&root));
|
||||
|
||||
+1
-1
@@ -1099,7 +1099,7 @@ async fn game_chat_pure_continue_inherits_failed_root_semantics_and_manifest_pro
|
||||
let _ = crate::tests::wait_for_agent_runtime_manifest_projection_async(
|
||||
&root,
|
||||
"code-prototype",
|
||||
&scheduled[0].state.run_id,
|
||||
&autonomous_manifest_ready_task_run_id(&continuation.run_id, "code-prototype"),
|
||||
"failed",
|
||||
"budget-exhausted",
|
||||
GameCreationAppTaskStatus::Failed,
|
||||
|
||||
@@ -1474,14 +1474,32 @@ pub(crate) fn read_local_project_file(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_local_project_image_preview(
|
||||
pub(crate) async fn read_local_project_image_preview(
|
||||
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
|
||||
project_path: String,
|
||||
relative_path: String,
|
||||
scope_id: String,
|
||||
request_id: String,
|
||||
) -> Result<LocalProjectImagePreview, String> {
|
||||
preview_manager
|
||||
.run(&scope_id, &request_id, move |cancellation| {
|
||||
read_local_project_image_preview_at(&project_path, &relative_path, cancellation)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn read_local_project_image_preview_at(
|
||||
project_path: &str,
|
||||
relative_path: &str,
|
||||
cancellation: &ProjectResourcePreviewScopeCancellation,
|
||||
) -> Result<LocalProjectImagePreview, String> {
|
||||
cancellation.check()?;
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_auto_permission_policy(root, "file.read")?;
|
||||
cancellation.check()?;
|
||||
let normalized_path = normalize_relative_path(relative_path.trim())?;
|
||||
let manifest = read_manifest(&root.join(".agent/manifest.json"))?;
|
||||
cancellation.check()?;
|
||||
let is_registered_asset = manifest
|
||||
.assets
|
||||
.iter()
|
||||
@@ -1493,18 +1511,37 @@ pub(crate) fn read_local_project_image_preview(
|
||||
if !is_registered_asset && !is_completed_task_artifact {
|
||||
return Err("只能预览已登记资源或已完成任务的图片产物".to_string());
|
||||
}
|
||||
load_local_project_image_preview(root, &normalized_path)
|
||||
cancellation.check()?;
|
||||
load_local_project_image_preview_with_cancellation(root, &normalized_path, cancellation)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_local_project_text_preview(
|
||||
pub(crate) async fn read_local_project_text_preview(
|
||||
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
|
||||
project_path: String,
|
||||
relative_path: String,
|
||||
scope_id: String,
|
||||
request_id: String,
|
||||
) -> Result<LocalProjectTextPreview, String> {
|
||||
preview_manager
|
||||
.run(&scope_id, &request_id, move |cancellation| {
|
||||
read_local_project_text_preview_at(&project_path, &relative_path, cancellation)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn read_local_project_text_preview_at(
|
||||
project_path: &str,
|
||||
relative_path: &str,
|
||||
cancellation: &ProjectResourcePreviewScopeCancellation,
|
||||
) -> Result<LocalProjectTextPreview, String> {
|
||||
cancellation.check()?;
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_auto_permission_policy(root, "file.read")?;
|
||||
cancellation.check()?;
|
||||
let normalized_path = normalize_relative_path(relative_path.trim())?;
|
||||
let manifest = read_manifest(&root.join(".agent/manifest.json"))?;
|
||||
cancellation.check()?;
|
||||
let is_registered_document = manifest.assets.iter().any(|asset| {
|
||||
asset.local_path == normalized_path
|
||||
&& is_supported_project_text_resource(&asset.local_path, &asset.media_type)
|
||||
@@ -1516,19 +1553,44 @@ pub(crate) fn read_local_project_text_preview(
|
||||
if !is_registered_document {
|
||||
return Err("只能读取当前项目已登记的文档资源".to_string());
|
||||
}
|
||||
load_local_project_text_preview(root, &normalized_path)
|
||||
cancellation.check()?;
|
||||
load_local_project_text_preview_with_cancellation(root, &normalized_path, cancellation)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_local_project_media_preview(
|
||||
pub(crate) async fn read_local_project_media_preview(
|
||||
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
|
||||
project_path: String,
|
||||
relative_path: String,
|
||||
category: String,
|
||||
scope_id: String,
|
||||
request_id: String,
|
||||
) -> Result<LocalProjectMediaPreview, String> {
|
||||
preview_manager
|
||||
.run(&scope_id, &request_id, move |cancellation| {
|
||||
read_local_project_media_preview_at(
|
||||
&project_path,
|
||||
&relative_path,
|
||||
&category,
|
||||
cancellation,
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn read_local_project_media_preview_at(
|
||||
project_path: &str,
|
||||
relative_path: &str,
|
||||
category: &str,
|
||||
cancellation: &ProjectResourcePreviewScopeCancellation,
|
||||
) -> Result<LocalProjectMediaPreview, String> {
|
||||
cancellation.check()?;
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_auto_permission_policy(root, "file.read")?;
|
||||
cancellation.check()?;
|
||||
let normalized_path = normalize_relative_path(relative_path.trim())?;
|
||||
let manifest = read_manifest(&root.join(".agent/manifest.json"))?;
|
||||
cancellation.check()?;
|
||||
let kind = match category.trim() {
|
||||
"art" => ProjectMediaPreviewKind::Art,
|
||||
"audio" => ProjectMediaPreviewKind::Audio,
|
||||
@@ -1559,7 +1621,16 @@ pub(crate) fn read_local_project_media_preview(
|
||||
if !is_registered_media {
|
||||
return Err("只能预览当前项目已登记的媒体资源".to_string());
|
||||
}
|
||||
load_local_project_media_preview(root, &normalized_path, kind)
|
||||
cancellation.check()?;
|
||||
load_local_project_media_preview_with_cancellation(root, &normalized_path, kind, cancellation)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn cancel_local_project_resource_preview_scope(
|
||||
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
|
||||
scope_id: String,
|
||||
) -> Result<(), String> {
|
||||
preview_manager.cancel_scope(&scope_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::project::{
|
||||
normalize_relative_path, open_project_snapshot_regular_file,
|
||||
reject_sensitive_project_file_read, resolve_local_project_path,
|
||||
};
|
||||
use crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation;
|
||||
use base64::Engine as _;
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -15,6 +16,7 @@ pub(crate) const AGENT_RUNTIME_IMAGE_INSPECT_MAX_FILE_BYTES: u64 = 8 * 1024 * 10
|
||||
pub(crate) const AGENT_RUNTIME_IMAGE_INSPECT_MAX_TOTAL_BYTES: u64 = 12 * 1024 * 1024;
|
||||
const PROJECT_IMAGE_PREVIEW_MAX_DIMENSION: u32 = 8_192;
|
||||
const PROJECT_IMAGE_PREVIEW_MAX_PIXELS: u64 = 32 * 1024 * 1024;
|
||||
const PROJECT_IMAGE_PREVIEW_READ_CHUNK_BYTES: usize = 64 * 1024;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -41,12 +43,34 @@ impl AgentRuntimeInspectionImage {
|
||||
base64::engine::general_purpose::STANDARD.encode(&self.bytes)
|
||||
)
|
||||
}
|
||||
|
||||
fn data_url_with_cancellation(
|
||||
&self,
|
||||
cancellation: &ProjectResourcePreviewScopeCancellation,
|
||||
) -> Result<String, String> {
|
||||
cancellation.check()?;
|
||||
Ok(self.data_url())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn load_local_project_image_preview(
|
||||
root: &Path,
|
||||
relative_path: &str,
|
||||
) -> Result<LocalProjectImagePreview, String> {
|
||||
load_local_project_image_preview_with_cancellation(
|
||||
root,
|
||||
relative_path,
|
||||
&ProjectResourcePreviewScopeCancellation::uncancelled(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn load_local_project_image_preview_with_cancellation(
|
||||
root: &Path,
|
||||
relative_path: &str,
|
||||
cancellation: &ProjectResourcePreviewScopeCancellation,
|
||||
) -> Result<LocalProjectImagePreview, String> {
|
||||
cancellation.check()?;
|
||||
let normalized = normalize_relative_path(relative_path.trim())?;
|
||||
if !normalized.starts_with("assets/") && !normalized.starts_with("game/") {
|
||||
return Err("图片预览只允许读取 assets/ 或 game/ 下的项目图片".to_string());
|
||||
@@ -54,12 +78,15 @@ pub(crate) fn load_local_project_image_preview(
|
||||
reject_sensitive_project_file_read(&normalized)?;
|
||||
let absolute = resolve_local_project_path(root, &normalized)?;
|
||||
validate_agent_runtime_inspection_ancestors(root, &absolute)?;
|
||||
let image = read_agent_runtime_inspection_image(&absolute, normalized)?;
|
||||
cancellation.check()?;
|
||||
let image =
|
||||
read_agent_runtime_inspection_image_with_cancellation(&absolute, normalized, cancellation)?;
|
||||
cancellation.check()?;
|
||||
Ok(LocalProjectImagePreview {
|
||||
path: image.relative_path.clone(),
|
||||
media_type: image.media_type.to_string(),
|
||||
byte_len: image.byte_len,
|
||||
data_url: image.data_url(),
|
||||
data_url: image.data_url_with_cancellation(cancellation)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -236,7 +263,21 @@ fn read_agent_runtime_inspection_image(
|
||||
path: &Path,
|
||||
relative_path: String,
|
||||
) -> Result<AgentRuntimeInspectionImage, String> {
|
||||
read_agent_runtime_inspection_image_with_cancellation(
|
||||
path,
|
||||
relative_path,
|
||||
&ProjectResourcePreviewScopeCancellation::uncancelled(),
|
||||
)
|
||||
}
|
||||
|
||||
fn read_agent_runtime_inspection_image_with_cancellation(
|
||||
path: &Path,
|
||||
relative_path: String,
|
||||
cancellation: &ProjectResourcePreviewScopeCancellation,
|
||||
) -> Result<AgentRuntimeInspectionImage, String> {
|
||||
cancellation.check()?;
|
||||
let (mut file, initial_metadata) = open_project_snapshot_regular_file(path, "视觉检查图片")?;
|
||||
cancellation.check()?;
|
||||
if initial_metadata.len() == 0 {
|
||||
return Err(format!("image.inspect 图片不能为空:{relative_path}"));
|
||||
}
|
||||
@@ -248,10 +289,26 @@ fn read_agent_runtime_inspection_image(
|
||||
}
|
||||
|
||||
let mut bytes = Vec::with_capacity(initial_metadata.len() as usize);
|
||||
file.by_ref()
|
||||
.take(AGENT_RUNTIME_IMAGE_INSPECT_MAX_FILE_BYTES + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|error| format!("读取 image.inspect 图片失败:{relative_path}: {error}"))?;
|
||||
let mut chunk = [0_u8; PROJECT_IMAGE_PREVIEW_READ_CHUNK_BYTES];
|
||||
loop {
|
||||
cancellation.check()?;
|
||||
let remaining =
|
||||
(AGENT_RUNTIME_IMAGE_INSPECT_MAX_FILE_BYTES + 1).saturating_sub(bytes.len() as u64);
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
let read_len = usize::try_from(remaining)
|
||||
.unwrap_or(usize::MAX)
|
||||
.min(chunk.len());
|
||||
let count = file
|
||||
.read(&mut chunk[..read_len])
|
||||
.map_err(|error| format!("读取 image.inspect 图片失败:{relative_path}: {error}"))?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
bytes.extend_from_slice(&chunk[..count]);
|
||||
}
|
||||
cancellation.check()?;
|
||||
if bytes.len() as u64 > AGENT_RUNTIME_IMAGE_INSPECT_MAX_FILE_BYTES {
|
||||
return Err(format!(
|
||||
"image.inspect 单张图片不能超过 {} MiB:{relative_path}",
|
||||
@@ -270,14 +327,17 @@ fn read_agent_runtime_inspection_image(
|
||||
));
|
||||
}
|
||||
|
||||
cancellation.check()?;
|
||||
let (reopened, reopened_metadata) = open_project_snapshot_regular_file(path, "视觉检查图片")?;
|
||||
if !same_open_file_identity(&file, &initial_metadata, &reopened, &reopened_metadata)? {
|
||||
return Err(format!(
|
||||
"image.inspect 图片路径读取期间发生替换:{relative_path}"
|
||||
));
|
||||
}
|
||||
cancellation.check()?;
|
||||
let media_type = detect_agent_runtime_image_media_type(&bytes)
|
||||
.ok_or_else(|| format!("image.inspect 只支持 PNG、JPEG 或 WEBP:{relative_path}"))?;
|
||||
cancellation.check()?;
|
||||
let (width, height) = detect_raster_image_dimensions(&bytes, media_type)
|
||||
.ok_or_else(|| format!("image.inspect 图片结构无效:{relative_path}"))?;
|
||||
let pixels = u64::from(width)
|
||||
@@ -293,6 +353,7 @@ fn read_agent_runtime_inspection_image(
|
||||
"image.inspect 图片尺寸过大:{width}x{height},最大边长 {PROJECT_IMAGE_PREVIEW_MAX_DIMENSION},最大像素 {PROJECT_IMAGE_PREVIEW_MAX_PIXELS}:{relative_path}"
|
||||
));
|
||||
}
|
||||
cancellation.check()?;
|
||||
let sha256 = format!("{:x}", Sha256::digest(&bytes));
|
||||
Ok(AgentRuntimeInspectionImage {
|
||||
relative_path,
|
||||
@@ -588,6 +649,28 @@ mod tests {
|
||||
assert!(preview.data_url.starts_with("data:image/png;base64,"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_image_preview_does_not_enter_base64_encoding() {
|
||||
let root = tempfile::tempdir().expect("temp root");
|
||||
fs::create_dir_all(root.path().join("assets/ui")).expect("asset dir");
|
||||
fs::write(root.path().join("assets/ui/prototype.png"), png_bytes()).expect("image");
|
||||
let image = read_agent_runtime_inspection_image(
|
||||
&root.path().join("assets/ui/prototype.png"),
|
||||
"assets/ui/prototype.png".to_string(),
|
||||
)
|
||||
.expect("read project image");
|
||||
let cancellation = ProjectResourcePreviewScopeCancellation::uncancelled();
|
||||
cancellation.cancel();
|
||||
|
||||
assert_eq!(
|
||||
image.data_url_with_cancellation(&cancellation),
|
||||
Err(
|
||||
crate::resource_preview_scheduler::PROJECT_RESOURCE_PREVIEW_CANCELLED_ERROR
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_project_image_preview_rejects_non_project_asset_paths() {
|
||||
let root = tempfile::tempdir().expect("temp root");
|
||||
|
||||
@@ -74,6 +74,7 @@ mod provider_handoff;
|
||||
mod provider_retry;
|
||||
mod repository_context;
|
||||
mod resource_inspect;
|
||||
mod resource_preview_scheduler;
|
||||
mod runner;
|
||||
mod swarm_cli;
|
||||
mod tool_plan_handoff;
|
||||
@@ -104,6 +105,7 @@ use process_session::*;
|
||||
use project::*;
|
||||
use repository_context::*;
|
||||
use resource_inspect::*;
|
||||
use resource_preview_scheduler::*;
|
||||
use runner::*;
|
||||
use swarm_cli::*;
|
||||
use user_input::*;
|
||||
@@ -2040,6 +2042,7 @@ fn main() {
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.manage(game_creator_preview_registry())
|
||||
.manage(ProjectResourcePreviewReadManager::default())
|
||||
.setup(move |app| {
|
||||
if let Some(path) = setup_log.as_deref() {
|
||||
let _ = append_bounded_diagnostic_line(path, "startup.setup.begin");
|
||||
@@ -2230,6 +2233,7 @@ fn main() {
|
||||
read_local_project_image_preview,
|
||||
read_local_project_text_preview,
|
||||
read_local_project_media_preview,
|
||||
cancel_local_project_resource_preview_scope,
|
||||
write_local_project_file,
|
||||
delete_local_project_file,
|
||||
read_local_game_memory,
|
||||
|
||||
@@ -862,11 +862,27 @@ mod tests {
|
||||
.expect_err("unknown schema must fail");
|
||||
assert!(schema_error.contains("schema"));
|
||||
|
||||
let coordinate_error = update_project_resource_canvas_layout_at(
|
||||
let boundary = update_project_resource_canvas_layout_at(
|
||||
&root,
|
||||
ProjectResourceCanvasLayoutMode::Type,
|
||||
"layout-invalid",
|
||||
0,
|
||||
vec![layout_position(
|
||||
"asset-coordinate-boundary",
|
||||
RESOURCE_LAYOUT_MAX_COORDINATE,
|
||||
)],
|
||||
)
|
||||
.expect("boundary coordinate must remain valid");
|
||||
assert_eq!(
|
||||
boundary.layout.positions[0].x,
|
||||
RESOURCE_LAYOUT_MAX_COORDINATE
|
||||
);
|
||||
|
||||
let coordinate_error = update_project_resource_canvas_layout_at(
|
||||
&root,
|
||||
ProjectResourceCanvasLayoutMode::Type,
|
||||
"layout-invalid",
|
||||
boundary.layout.revision,
|
||||
vec![layout_position(
|
||||
"asset-coordinate",
|
||||
RESOURCE_LAYOUT_MAX_COORDINATE + 1,
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::project::{
|
||||
normalize_relative_path, open_project_snapshot_regular_file,
|
||||
reject_sensitive_project_file_read, resolve_local_project_path,
|
||||
};
|
||||
use crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation;
|
||||
use base64::Engine as _;
|
||||
use serde::Serialize;
|
||||
use std::io::Read;
|
||||
@@ -12,6 +13,7 @@ use std::path::Path;
|
||||
|
||||
const PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
|
||||
const PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES: u64 = 32 * 1024 * 1024;
|
||||
const PROJECT_RESOURCE_PREVIEW_READ_CHUNK_BYTES: usize = 64 * 1024;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -118,10 +120,24 @@ pub(crate) fn is_supported_project_audio_resource(path: &str, media_type: &str)
|
||||
) || media_type.starts_with("audio/")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn load_local_project_text_preview(
|
||||
root: &Path,
|
||||
relative_path: &str,
|
||||
) -> Result<LocalProjectTextPreview, String> {
|
||||
load_local_project_text_preview_with_cancellation(
|
||||
root,
|
||||
relative_path,
|
||||
&ProjectResourcePreviewScopeCancellation::uncancelled(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn load_local_project_text_preview_with_cancellation(
|
||||
root: &Path,
|
||||
relative_path: &str,
|
||||
cancellation: &ProjectResourcePreviewScopeCancellation,
|
||||
) -> Result<LocalProjectTextPreview, String> {
|
||||
cancellation.check()?;
|
||||
let normalized = normalize_relative_path(relative_path.trim())?;
|
||||
reject_sensitive_project_file_read(&normalized)?;
|
||||
let media_type = project_text_media_type(&normalized)
|
||||
@@ -131,7 +147,9 @@ pub(crate) fn load_local_project_text_preview(
|
||||
&normalized,
|
||||
PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES,
|
||||
"项目文档",
|
||||
cancellation,
|
||||
)?;
|
||||
cancellation.check()?;
|
||||
let content =
|
||||
String::from_utf8(bytes).map_err(|_| "文档预览只支持 UTF-8 编码的文本文件".to_string())?;
|
||||
Ok(LocalProjectTextPreview {
|
||||
@@ -142,11 +160,27 @@ pub(crate) fn load_local_project_text_preview(
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn load_local_project_media_preview(
|
||||
root: &Path,
|
||||
relative_path: &str,
|
||||
kind: ProjectMediaPreviewKind,
|
||||
) -> Result<LocalProjectMediaPreview, String> {
|
||||
load_local_project_media_preview_with_cancellation(
|
||||
root,
|
||||
relative_path,
|
||||
kind,
|
||||
&ProjectResourcePreviewScopeCancellation::uncancelled(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn load_local_project_media_preview_with_cancellation(
|
||||
root: &Path,
|
||||
relative_path: &str,
|
||||
kind: ProjectMediaPreviewKind,
|
||||
cancellation: &ProjectResourcePreviewScopeCancellation,
|
||||
) -> Result<LocalProjectMediaPreview, String> {
|
||||
cancellation.check()?;
|
||||
let normalized = normalize_relative_path(relative_path.trim())?;
|
||||
reject_sensitive_project_file_read(&normalized)?;
|
||||
let bytes = read_stable_project_resource(
|
||||
@@ -154,39 +188,70 @@ pub(crate) fn load_local_project_media_preview(
|
||||
&normalized,
|
||||
PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES,
|
||||
"项目媒体资源",
|
||||
cancellation,
|
||||
)?;
|
||||
if bytes.is_empty() {
|
||||
return Err("媒体文件为空,无法预览".to_string());
|
||||
}
|
||||
cancellation.check()?;
|
||||
let media_type = detect_project_media_type(&normalized, &bytes, kind)?;
|
||||
cancellation.check()?;
|
||||
Ok(LocalProjectMediaPreview {
|
||||
path: normalized,
|
||||
media_type: media_type.to_string(),
|
||||
byte_len: bytes.len() as u64,
|
||||
data_url: format!(
|
||||
"data:{media_type};base64,{}",
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
),
|
||||
data_url: encode_project_resource_preview_data_url(media_type, &bytes, cancellation)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn encode_project_resource_preview_data_url(
|
||||
media_type: &str,
|
||||
bytes: &[u8],
|
||||
cancellation: &ProjectResourcePreviewScopeCancellation,
|
||||
) -> Result<String, String> {
|
||||
cancellation.check()?;
|
||||
Ok(format!(
|
||||
"data:{media_type};base64,{}",
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
))
|
||||
}
|
||||
|
||||
fn read_stable_project_resource(
|
||||
root: &Path,
|
||||
normalized: &str,
|
||||
max_bytes: u64,
|
||||
label: &str,
|
||||
cancellation: &ProjectResourcePreviewScopeCancellation,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
cancellation.check()?;
|
||||
let absolute = resolve_local_project_path(root, normalized)?;
|
||||
validate_agent_runtime_inspection_ancestors(root, &absolute)?;
|
||||
cancellation.check()?;
|
||||
let (mut file, initial_metadata) = open_project_snapshot_regular_file(&absolute, label)?;
|
||||
cancellation.check()?;
|
||||
if initial_metadata.len() > max_bytes {
|
||||
return Err(format!("{label}不能超过 {} MiB", max_bytes / 1024 / 1024));
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(initial_metadata.len() as usize);
|
||||
file.by_ref()
|
||||
.take(max_bytes + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|error| format!("读取{label}失败:{normalized}: {error}"))?;
|
||||
let mut chunk = [0_u8; PROJECT_RESOURCE_PREVIEW_READ_CHUNK_BYTES];
|
||||
loop {
|
||||
cancellation.check()?;
|
||||
let remaining = (max_bytes + 1).saturating_sub(bytes.len() as u64);
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
let read_len = usize::try_from(remaining)
|
||||
.unwrap_or(usize::MAX)
|
||||
.min(chunk.len());
|
||||
let count = file
|
||||
.read(&mut chunk[..read_len])
|
||||
.map_err(|error| format!("读取{label}失败:{normalized}: {error}"))?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
bytes.extend_from_slice(&chunk[..count]);
|
||||
}
|
||||
cancellation.check()?;
|
||||
if bytes.len() as u64 > max_bytes {
|
||||
return Err(format!("{label}不能超过 {} MiB", max_bytes / 1024 / 1024));
|
||||
}
|
||||
@@ -199,10 +264,12 @@ fn read_stable_project_resource(
|
||||
{
|
||||
return Err(format!("{label}读取期间发生漂移:{normalized}"));
|
||||
}
|
||||
cancellation.check()?;
|
||||
let (reopened, reopened_metadata) = open_project_snapshot_regular_file(&absolute, label)?;
|
||||
if !same_open_file_identity(&file, &initial_metadata, &reopened, &reopened_metadata)? {
|
||||
return Err(format!("{label}路径读取期间发生替换:{normalized}"));
|
||||
}
|
||||
cancellation.check()?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
@@ -503,4 +570,17 @@ mod tests {
|
||||
assert!(load_local_project_text_preview(root.path(), "docs/link.md").is_err());
|
||||
assert!(load_local_project_text_preview(root.path(), "docs/hard.md").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_media_preview_does_not_enter_base64_encoding() {
|
||||
let cancellation = ProjectResourcePreviewScopeCancellation::uncancelled();
|
||||
cancellation.cancel();
|
||||
assert_eq!(
|
||||
encode_project_resource_preview_data_url("audio/mpeg", b"ID3", &cancellation),
|
||||
Err(
|
||||
crate::resource_preview_scheduler::PROJECT_RESOURCE_PREVIEW_CANCELLED_ERROR
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6004,6 +6004,8 @@ fn project_checkpoint_excludes_and_preserves_sensitive_local_files() {
|
||||
#[test]
|
||||
fn local_project_image_preview_obeys_auto_file_read_policy() {
|
||||
let root = unique_project_path();
|
||||
let project_path = root.to_string_lossy().into_owned();
|
||||
let cancellation = ProjectResourcePreviewScopeCancellation::uncancelled();
|
||||
init_local_game_project_at(&root, "image-preview-policy", "图片预览策略项目")
|
||||
.expect("project init");
|
||||
fs::create_dir_all(root.join("assets")).expect("asset dir");
|
||||
@@ -6041,11 +6043,9 @@ fn local_project_image_preview_obeys_auto_file_read_policy() {
|
||||
},
|
||||
)
|
||||
.expect("confirm policy");
|
||||
let confirm_error = read_local_project_image_preview(
|
||||
root.to_string_lossy().into_owned(),
|
||||
"assets/preview.png".to_string(),
|
||||
)
|
||||
.expect_err("confirm policy blocks automatic preview");
|
||||
let confirm_error =
|
||||
read_local_project_image_preview_at(&project_path, "assets/preview.png", &cancellation)
|
||||
.expect_err("confirm policy blocks automatic preview");
|
||||
assert!(confirm_error.contains("要求用户确认:file.read"));
|
||||
|
||||
write_project_permission_policy_at(
|
||||
@@ -6057,11 +6057,9 @@ fn local_project_image_preview_obeys_auto_file_read_policy() {
|
||||
},
|
||||
)
|
||||
.expect("deny policy");
|
||||
let deny_error = read_local_project_image_preview(
|
||||
root.to_string_lossy().into_owned(),
|
||||
"assets/preview.png".to_string(),
|
||||
)
|
||||
.expect_err("deny policy blocks preview");
|
||||
let deny_error =
|
||||
read_local_project_image_preview_at(&project_path, "assets/preview.png", &cancellation)
|
||||
.expect_err("deny policy blocks preview");
|
||||
assert!(deny_error.contains("拒绝执行:file.read"));
|
||||
|
||||
write_project_permission_policy_at(
|
||||
@@ -6073,17 +6071,16 @@ fn local_project_image_preview_obeys_auto_file_read_policy() {
|
||||
},
|
||||
)
|
||||
.expect("auto policy");
|
||||
let preview = read_local_project_image_preview(
|
||||
root.to_string_lossy().into_owned(),
|
||||
"assets/preview.png".to_string(),
|
||||
)
|
||||
.expect("auto preview");
|
||||
let preview =
|
||||
read_local_project_image_preview_at(&project_path, "assets/preview.png", &cancellation)
|
||||
.expect("auto preview");
|
||||
assert_eq!(preview.media_type, "image/png");
|
||||
|
||||
fs::write(root.join("assets/unregistered.png"), &preview_bytes).expect("unregistered image");
|
||||
let unregistered_error = read_local_project_image_preview(
|
||||
root.to_string_lossy().into_owned(),
|
||||
"assets/unregistered.png".to_string(),
|
||||
let unregistered_error = read_local_project_image_preview_at(
|
||||
&project_path,
|
||||
"assets/unregistered.png",
|
||||
&cancellation,
|
||||
)
|
||||
.expect_err("unregistered image rejected");
|
||||
assert!(unregistered_error.contains("只能预览已登记资源"));
|
||||
@@ -6094,6 +6091,8 @@ fn local_project_image_preview_obeys_auto_file_read_policy() {
|
||||
#[test]
|
||||
fn local_project_resource_previews_require_registered_safe_resources() {
|
||||
let root = unique_project_path();
|
||||
let project_path = root.to_string_lossy().into_owned();
|
||||
let cancellation = ProjectResourcePreviewScopeCancellation::uncancelled();
|
||||
init_local_game_project_at(&root, "resource-preview-policy", "资源预览策略项目")
|
||||
.expect("project init");
|
||||
fs::create_dir_all(root.join("assets")).expect("asset dir");
|
||||
@@ -6151,44 +6150,37 @@ fn local_project_resource_previews_require_registered_safe_resources() {
|
||||
)
|
||||
.expect("register audio");
|
||||
|
||||
let document = read_local_project_text_preview(
|
||||
root.to_string_lossy().into_owned(),
|
||||
"game/design.md".to_string(),
|
||||
)
|
||||
.expect("read registered document");
|
||||
let document =
|
||||
read_local_project_text_preview_at(&project_path, "game/design.md", &cancellation)
|
||||
.expect("read registered document");
|
||||
assert_eq!(document.media_type, "text/markdown");
|
||||
assert!(document.content.contains("安全正文"));
|
||||
|
||||
let svg = read_local_project_media_preview(
|
||||
root.to_string_lossy().into_owned(),
|
||||
"assets/icon.svg".to_string(),
|
||||
"art".to_string(),
|
||||
)
|
||||
.expect("read registered svg");
|
||||
let svg =
|
||||
read_local_project_media_preview_at(&project_path, "assets/icon.svg", "art", &cancellation)
|
||||
.expect("read registered svg");
|
||||
assert_eq!(svg.media_type, "image/svg+xml");
|
||||
let audio = read_local_project_media_preview(
|
||||
root.to_string_lossy().into_owned(),
|
||||
"assets/bgm.mp3".to_string(),
|
||||
"audio".to_string(),
|
||||
let audio = read_local_project_media_preview_at(
|
||||
&project_path,
|
||||
"assets/bgm.mp3",
|
||||
"audio",
|
||||
&cancellation,
|
||||
)
|
||||
.expect("read registered audio");
|
||||
assert_eq!(audio.media_type, "audio/mpeg");
|
||||
|
||||
let unregistered_error = read_local_project_text_preview(
|
||||
root.to_string_lossy().into_owned(),
|
||||
"game/unregistered.md".to_string(),
|
||||
)
|
||||
.expect_err("unregistered document rejected");
|
||||
let unregistered_error =
|
||||
read_local_project_text_preview_at(&project_path, "game/unregistered.md", &cancellation)
|
||||
.expect_err("unregistered document rejected");
|
||||
assert!(unregistered_error.contains("已登记的文档资源"));
|
||||
assert!(read_local_project_text_preview(
|
||||
root.to_string_lossy().into_owned(),
|
||||
"../outside.md".to_string(),
|
||||
)
|
||||
.is_err());
|
||||
assert!(read_local_project_media_preview(
|
||||
root.to_string_lossy().into_owned(),
|
||||
"assets/bgm.mp3".to_string(),
|
||||
"art".to_string(),
|
||||
assert!(
|
||||
read_local_project_text_preview_at(&project_path, "../outside.md", &cancellation,).is_err()
|
||||
);
|
||||
assert!(read_local_project_media_preview_at(
|
||||
&project_path,
|
||||
"assets/bgm.mp3",
|
||||
"art",
|
||||
&cancellation,
|
||||
)
|
||||
.is_err());
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@ import type {
|
||||
PendingCommand,
|
||||
PendingUiConfirmation,
|
||||
} from '../../app/types';
|
||||
import {
|
||||
cancelLocalProjectResourcePreviewScope,
|
||||
createProjectResourcePreviewRequestId,
|
||||
createProjectResourcePreviewScopeId,
|
||||
} from '../../services/projectResourcePreviewTransport';
|
||||
import {
|
||||
formatAgentRuntimeEvent,
|
||||
isAgentRuntimeTerminalState,
|
||||
@@ -1185,6 +1190,7 @@ export function SupervisorChatOnlyView({
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
const scopeId = createProjectResourcePreviewScopeId();
|
||||
let cancelled = false;
|
||||
setSelectedResultImage(null);
|
||||
setResultImagePreviews(
|
||||
@@ -1202,6 +1208,8 @@ export function SupervisorChatOnlyView({
|
||||
{
|
||||
projectPath,
|
||||
relativePath: image.path,
|
||||
scopeId,
|
||||
requestId: createProjectResourcePreviewRequestId(),
|
||||
},
|
||||
);
|
||||
if (
|
||||
@@ -1223,6 +1231,7 @@ export function SupervisorChatOnlyView({
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelLocalProjectResourcePreviewScope(scopeId);
|
||||
};
|
||||
// resultImageKey is the stable semantic identity for the derived image list.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
const PROJECT_RESOURCE_PREVIEW_CANCELLED_ERROR =
|
||||
'project-resource-preview-scope-cancelled';
|
||||
|
||||
let projectResourcePreviewOpaqueIdSequence = 0;
|
||||
|
||||
function createProjectResourcePreviewOpaqueId(prefix: 'scope' | 'request') {
|
||||
projectResourcePreviewOpaqueIdSequence += 1;
|
||||
const cryptoApi = globalThis.crypto;
|
||||
const randomId = cryptoApi?.randomUUID?.();
|
||||
if (randomId) {
|
||||
return `${prefix}:${randomId}`;
|
||||
}
|
||||
if (cryptoApi?.getRandomValues) {
|
||||
const randomWords = cryptoApi.getRandomValues(new Uint32Array(4));
|
||||
return `${prefix}:${Array.from(randomWords, (word) => word.toString(36)).join('-')}`;
|
||||
}
|
||||
return `${prefix}:${Date.now().toString(36)}:${Math.random().toString(36).slice(2)}:${projectResourcePreviewOpaqueIdSequence.toString(36)}`;
|
||||
}
|
||||
|
||||
export function createProjectResourcePreviewScopeId() {
|
||||
return createProjectResourcePreviewOpaqueId('scope');
|
||||
}
|
||||
|
||||
export function createProjectResourcePreviewRequestId() {
|
||||
return createProjectResourcePreviewOpaqueId('request');
|
||||
}
|
||||
|
||||
export function isProjectResourcePreviewCancellation(error: unknown) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === 'string'
|
||||
? error
|
||||
: null;
|
||||
return message === PROJECT_RESOURCE_PREVIEW_CANCELLED_ERROR;
|
||||
}
|
||||
|
||||
export function cancelLocalProjectResourcePreviewScope(scopeId: string) {
|
||||
const invoke = window.__TAURI__?.core?.invoke;
|
||||
if (!invoke) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
void Promise.resolve(
|
||||
invoke('cancel_local_project_resource_preview_scope', { scopeId }),
|
||||
).catch(() => undefined);
|
||||
} catch {
|
||||
// The epoch/owner fence remains authoritative if the WebView is already
|
||||
// tearing down and can no longer deliver the best-effort cancellation.
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+457
-450
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+680
-29
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,167 @@
|
||||
import type { ProjectResourceCanvasLayoutMode } from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import type { ProjectResource } from './resourceProjectionModel';
|
||||
|
||||
export const PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY = 3;
|
||||
export const PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT = 48;
|
||||
|
|
||||
export const PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT = 64 * 1024 * 1024;
|
||||
export const PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT = 96;
|
||||
export const PROJECT_RESOURCE_CARD_PREVIEW_ACTIVE_QUEUE_RESERVE =
|
||||
PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY;
|
||||
|
||||
export type ProjectResourceCardPreviewKind =
|
||||
| 'raster-image'
|
||||
| 'media-image'
|
||||
| 'video'
|
||||
| 'audio'
|
||||
| 'document'
|
||||
| 'version'
|
||||
| 'placeholder';
|
||||
|
||||
export type ProjectResourceCardPreviewPayload = {
|
||||
path: string;
|
||||
mediaType: string;
|
||||
byteLen: number;
|
||||
sourceUrl?: string;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
export type ProjectResourceCardPreviewTransportPayload = Omit<
|
||||
ProjectResourceCardPreviewPayload,
|
||||
'sourceUrl'
|
||||
> & {
|
||||
dataUrl?: string;
|
||||
};
|
||||
|
||||
export type ProjectResourceCardPreviewCacheEntry = {
|
||||
identity: string;
|
||||
retainedBytes: number;
|
||||
};
|
||||
|
||||
export type ProjectResourceCardPreviewState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading' }
|
||||
| {
|
||||
status: 'loaded';
|
||||
preview: ProjectResourceCardPreviewPayload;
|
||||
}
|
||||
| { status: 'failed'; error: string; retryable: boolean };
|
||||
|
||||
export const IDLE_PROJECT_RESOURCE_CARD_PREVIEW = {
|
||||
status: 'idle',
|
||||
} as const satisfies ProjectResourceCardPreviewState;
|
||||
|
||||
function normalizedProjectResourceCardPreviewRetainedBytes(
|
||||
retainedBytes: number,
|
||||
) {
|
||||
return Number.isSafeInteger(retainedBytes) && retainedBytes >= 0
|
||||
? retainedBytes
|
||||
: PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT + 1;
|
||||
}
|
||||
|
||||
export function projectResourceCardPreviewEvictionIdentities(
|
||||
entries: readonly ProjectResourceCardPreviewCacheEntry[],
|
||||
protectedIdentity: string | null,
|
||||
): string[] {
|
||||
let retainedCount = entries.length;
|
||||
let retainedBytes = entries.reduce(
|
||||
(total, entry) =>
|
||||
total +
|
||||
normalizedProjectResourceCardPreviewRetainedBytes(entry.retainedBytes),
|
||||
0,
|
||||
);
|
||||
const evicted = new Set<string>();
|
||||
const overBudget = () =>
|
||||
retainedCount > PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT ||
|
||||
retainedBytes > PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!overBudget()) {
|
||||
break;
|
||||
}
|
||||
if (entry.identity === protectedIdentity) {
|
||||
continue;
|
||||
}
|
||||
evicted.add(entry.identity);
|
||||
retainedCount -= 1;
|
||||
retainedBytes -= normalizedProjectResourceCardPreviewRetainedBytes(
|
||||
entry.retainedBytes,
|
||||
);
|
||||
}
|
||||
|
||||
if (overBudget() && protectedIdentity) {
|
||||
const protectedEntry = entries.find(
|
||||
(entry) => entry.identity === protectedIdentity,
|
||||
);
|
||||
if (protectedEntry && !evicted.has(protectedEntry.identity)) {
|
||||
evicted.add(protectedEntry.identity);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(evicted);
|
||||
}
|
||||
|
||||
const rasterImageExtension = /\.(png|jpe?g|webp)$/iu;
|
||||
const extendedImageExtension = /\.(gif|svg|avif|bmp)$/iu;
|
||||
const videoExtension = /\.(mp4|webm|mov)$/iu;
|
||||
|
||||
export function projectResourceCardPreviewKind(
|
||||
resource: ProjectResource,
|
||||
): ProjectResourceCardPreviewKind {
|
||||
if (resource.category === 'version') {
|
||||
return 'version';
|
||||
}
|
||||
if (resource.category === 'document') {
|
||||
return 'document';
|
||||
}
|
||||
if (resource.category === 'audio') {
|
||||
return 'audio';
|
||||
}
|
||||
const mediaType = resource.mediaType.trim().toLowerCase();
|
||||
if (mediaType.startsWith('video/') || videoExtension.test(resource.path)) {
|
||||
return 'video';
|
||||
}
|
||||
if (
|
||||
['image/png', 'image/jpeg', 'image/jpg', 'image/webp'].includes(
|
||||
mediaType,
|
||||
) ||
|
||||
rasterImageExtension.test(resource.path)
|
||||
) {
|
||||
return 'raster-image';
|
||||
}
|
||||
if (
|
||||
mediaType.startsWith('image/') ||
|
||||
extendedImageExtension.test(resource.path)
|
||||
) {
|
||||
return 'media-image';
|
||||
}
|
||||
return 'placeholder';
|
||||
}
|
||||
|
||||
export function projectResourceCardPreviewIdentity(input: {
|
||||
projectPath: string;
|
||||
projectId: string;
|
||||
mode: ProjectResourceCanvasLayoutMode;
|
||||
resource: ProjectResource;
|
||||
}) {
|
||||
return JSON.stringify([
|
||||
input.projectPath,
|
||||
input.projectId,
|
||||
input.mode,
|
||||
input.resource.id,
|
||||
input.resource.category,
|
||||
input.resource.path,
|
||||
input.resource.mediaType,
|
||||
]);
|
||||
}
|
||||
|
||||
export function summarizeProjectResourceDocument(content: string) {
|
||||
return content
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/gu, ' ')
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/gu, '$1')
|
||||
.replace(/<[^>]*>/gu, ' ')
|
||||
.replace(/[`*_~>#|{}[\]]/gu, ' ')
|
||||
.replace(/^\s*[-+]\s+/gmu, '')
|
||||
.replace(/\s+/gu, ' ')
|
||||
.trim()
|
||||
.slice(0, 180);
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import type {
|
||||
ProjectResourceCanvasLayoutMode,
|
||||
ProjectResourceCanvasSection,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { RESOURCE_CANVAS_CARD_HEIGHT } from './resourceCanvasLayoutModel';
|
||||
|
||||
export const RESOURCE_SECTION_HEIGHT_STEP = 72;
|
||||
export const RESOURCE_SECTION_MIN_HEIGHT = RESOURCE_CANVAS_CARD_HEIGHT + 88;
|
||||
export const RESOURCE_SECTION_DEFAULT_HEIGHT = 340;
|
||||
export const RESOURCE_SECTION_FALLBACK_MAX_HEIGHT = 640;
|
||||
export const RESOURCE_CANVAS_VERTICAL_INSET = 24;
|
||||
export const RESOURCE_SECTION_ZOOM_MIN = 0.5;
|
||||
export const RESOURCE_SECTION_ZOOM_MAX = 2;
|
||||
export const RESOURCE_SECTION_ZOOM_DEFAULT = 1;
|
||||
export const RESOURCE_SECTION_ZOOM_STEP = 0.1;
|
||||
|
||||
export type ProjectResourceSectionHeightBounds = {
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
|
||||
export type ProjectResourceSectionHeightAction =
|
||||
| 'decrease'
|
||||
| 'increase'
|
||||
| 'reset';
|
||||
|
||||
export type ProjectResourceSectionZoomAction =
|
||||
| 'decrease'
|
||||
| 'increase'
|
||||
| 'reset';
|
||||
|
||||
export function projectResourceSectionHeightKey(input: {
|
||||
projectId: string;
|
||||
mode: ProjectResourceCanvasLayoutMode;
|
||||
section: ProjectResourceCanvasSection;
|
||||
}) {
|
||||
return `${input.projectId}\n${input.mode}\n${input.section}`;
|
||||
}
|
||||
|
||||
export function projectResourceSectionHeightBounds(
|
||||
canvasClientHeight: number,
|
||||
): ProjectResourceSectionHeightBounds {
|
||||
const availableHeight =
|
||||
Number.isFinite(canvasClientHeight) && canvasClientHeight > 0
|
||||
? Math.floor(canvasClientHeight) - RESOURCE_CANVAS_VERTICAL_INSET
|
||||
: RESOURCE_SECTION_FALLBACK_MAX_HEIGHT;
|
||||
return {
|
||||
min: RESOURCE_SECTION_MIN_HEIGHT,
|
||||
max: Math.max(RESOURCE_SECTION_MIN_HEIGHT, availableHeight),
|
||||
};
|
||||
}
|
||||
|
||||
export function clampProjectResourceSectionHeight(
|
||||
height: number,
|
||||
bounds: ProjectResourceSectionHeightBounds,
|
||||
) {
|
||||
const normalized = Number.isFinite(height)
|
||||
? Math.round(height)
|
||||
: RESOURCE_SECTION_DEFAULT_HEIGHT;
|
||||
return Math.min(bounds.max, Math.max(bounds.min, normalized));
|
||||
}
|
||||
|
||||
export function defaultProjectResourceSectionHeight(
|
||||
bounds: ProjectResourceSectionHeightBounds,
|
||||
) {
|
||||
return clampProjectResourceSectionHeight(
|
||||
RESOURCE_SECTION_DEFAULT_HEIGHT,
|
||||
bounds,
|
||||
);
|
||||
}
|
||||
|
||||
export function updateProjectResourceSectionHeight(
|
||||
currentHeight: number,
|
||||
action: ProjectResourceSectionHeightAction,
|
||||
bounds: ProjectResourceSectionHeightBounds,
|
||||
) {
|
||||
if (action === 'reset') {
|
||||
return defaultProjectResourceSectionHeight(bounds);
|
||||
}
|
||||
const delta =
|
||||
action === 'increase'
|
||||
? RESOURCE_SECTION_HEIGHT_STEP
|
||||
: -RESOURCE_SECTION_HEIGHT_STEP;
|
||||
return clampProjectResourceSectionHeight(currentHeight + delta, bounds);
|
||||
}
|
||||
|
||||
export function clampProjectResourceSectionZoom(zoom: number) {
|
||||
const normalized = Number.isFinite(zoom)
|
||||
? Math.round(zoom * 100) / 100
|
||||
: RESOURCE_SECTION_ZOOM_DEFAULT;
|
||||
return Math.min(
|
||||
RESOURCE_SECTION_ZOOM_MAX,
|
||||
Math.max(RESOURCE_SECTION_ZOOM_MIN, normalized),
|
||||
);
|
||||
}
|
||||
|
||||
export function updateProjectResourceSectionZoom(
|
||||
currentZoom: number,
|
||||
action: ProjectResourceSectionZoomAction,
|
||||
) {
|
||||
if (action === 'reset') {
|
||||
return RESOURCE_SECTION_ZOOM_DEFAULT;
|
||||
}
|
||||
return clampProjectResourceSectionZoom(
|
||||
currentZoom +
|
||||
(action === 'increase'
|
||||
? RESOURCE_SECTION_ZOOM_STEP
|
||||
: -RESOURCE_SECTION_ZOOM_STEP),
|
||||
);
|
||||
}
|
||||
|
||||
export function projectResourceSectionZoomFromWheel(
|
||||
currentZoom: number,
|
||||
deltaY: number,
|
||||
) {
|
||||
if (!Number.isFinite(deltaY) || deltaY === 0) {
|
||||
return clampProjectResourceSectionZoom(currentZoom);
|
||||
}
|
||||
return clampProjectResourceSectionZoom(
|
||||
currentZoom * Math.exp(-deltaY * 0.0025),
|
||||
);
|
||||
}
|
||||
+130
-12
@@ -6,12 +6,16 @@ import type {
|
||||
ProjectResourceCanvasSection,
|
||||
UpdateProjectResourceCanvasLayoutResult,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { isSafeProjectResourceCanvasLayoutRevision } from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
isSafeProjectResourceCanvasCoordinate,
|
||||
isSafeProjectResourceCanvasLayoutRevision,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
createEmptyResourceCanvasLayout,
|
||||
moveResourceCanvasPosition,
|
||||
reconcileResourceCanvasLayout,
|
||||
type ResourceCanvasItem,
|
||||
type ResourceCanvasLayoutTopology,
|
||||
} from './resourceCanvasLayoutModel';
|
||||
|
||||
type LayoutNotice =
|
||||
@@ -76,6 +80,68 @@ export function createResourceSignature(resources: ResourceCanvasItem[]) {
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* A bounded, identity-only input to dependency automatic placement. Display
|
||||
* labels deliberately stay out: the resource list's coordination signature
|
||||
* already covers them, while topology changes must be detectable even when
|
||||
* every depth and resource count is unchanged.
|
||||
*/
|
||||
export function createResourceTopologySignature(
|
||||
topology: ResourceCanvasLayoutTopology | undefined,
|
||||
) {
|
||||
if (!topology) {
|
||||
return '';
|
||||
}
|
||||
const entries = [
|
||||
...topology.referenceEdges
|
||||
.map(
|
||||
(edge) =>
|
||||
`r:${JSON.stringify([edge.sourceResourceId, edge.targetResourceId])}`,
|
||||
)
|
||||
.sort(compareStableText),
|
||||
...topology.taskFlows
|
||||
.map(
|
||||
(flow) =>
|
||||
`f:${JSON.stringify([
|
||||
[...new Set(flow.sourceResourceIds)].sort(compareStableText),
|
||||
[...new Set(flow.targetResourceIds)].sort(compareStableText),
|
||||
])}`,
|
||||
)
|
||||
.sort(compareStableText),
|
||||
];
|
||||
return stableTopologyDigest([...new Set(entries)].sort(compareStableText));
|
||||
}
|
||||
|
||||
function compareStableText(left: string, right: string) {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the sidecar coordination input fixed-size even for a large graph.
|
||||
* The canonical entries retain all stable endpoint and flow-member identities
|
||||
* while being digested; labels and browser geometry never participate.
|
||||
*/
|
||||
function stableTopologyDigest(entries: readonly string[]) {
|
||||
let forward = 0x811c9dc5;
|
||||
let reverse = 0x9e3779b9;
|
||||
for (const entry of entries) {
|
||||
for (let index = 0; index < entry.length; index += 1) {
|
||||
forward = Math.imul(forward ^ entry.charCodeAt(index), 0x01000193);
|
||||
reverse = Math.imul(
|
||||
reverse ^ entry.charCodeAt(entry.length - index - 1),
|
||||
0x85ebca6b,
|
||||
);
|
||||
}
|
||||
forward = Math.imul(forward ^ 10, 0x01000193);
|
||||
reverse = Math.imul(reverse ^ 10, 0x85ebca6b);
|
||||
}
|
||||
return `${(entries.length >>> 0).toString(16).padStart(8, '0')}:${(
|
||||
forward >>> 0
|
||||
)
|
||||
.toString(16)
|
||||
.padStart(8, '0')}:${(reverse >>> 0).toString(16).padStart(8, '0')}`;
|
||||
}
|
||||
|
||||
function layoutMatchesScope(
|
||||
layout: ProjectResourceCanvasLayout,
|
||||
scope: LayoutScope,
|
||||
@@ -102,23 +168,46 @@ function positionsEqual(
|
||||
);
|
||||
}
|
||||
|
||||
function layoutCoordinatesAreSafe(layout: ProjectResourceCanvasLayout) {
|
||||
return layout.positions.every(
|
||||
(position) =>
|
||||
isSafeProjectResourceCanvasCoordinate(position.x) &&
|
||||
isSafeProjectResourceCanvasCoordinate(position.y),
|
||||
);
|
||||
}
|
||||
|
||||
function reconcileLayout(
|
||||
source: ProjectResourceCanvasLayout,
|
||||
resources: ResourceCanvasItem[],
|
||||
rederiveAutomaticPositions: boolean,
|
||||
topology: ResourceCanvasLayoutTopology | undefined,
|
||||
) {
|
||||
if (!rederiveAutomaticPositions) {
|
||||
return reconcileResourceCanvasLayout(source, resources);
|
||||
const reconciled = reconcileResourceCanvasLayout(
|
||||
source,
|
||||
resources,
|
||||
topology,
|
||||
);
|
||||
return layoutCoordinatesAreSafe(reconciled.layout)
|
||||
? reconciled
|
||||
: { layout: source, changed: false };
|
||||
}
|
||||
const manualSource = {
|
||||
...source,
|
||||
positions: source.positions.filter((position) => position.manuallyPlaced),
|
||||
};
|
||||
const reconciled = reconcileResourceCanvasLayout(manualSource, resources);
|
||||
return {
|
||||
const reconciled = reconcileResourceCanvasLayout(
|
||||
manualSource,
|
||||
resources,
|
||||
topology,
|
||||
);
|
||||
const result = {
|
||||
layout: reconciled.layout,
|
||||
changed: !positionsEqual(source.positions, reconciled.layout.positions),
|
||||
};
|
||||
return layoutCoordinatesAreSafe(result.layout)
|
||||
? result
|
||||
: { layout: source, changed: false };
|
||||
}
|
||||
|
||||
export function useProjectResourceCanvasLayout({
|
||||
@@ -126,6 +215,7 @@ export function useProjectResourceCanvasLayout({
|
||||
projectId,
|
||||
mode,
|
||||
resources,
|
||||
topology,
|
||||
initializationReady = true,
|
||||
renderFallbackWhileBlocked = false,
|
||||
rederiveAutomaticPositions = false,
|
||||
@@ -134,13 +224,18 @@ export function useProjectResourceCanvasLayout({
|
||||
projectId: string;
|
||||
mode: ProjectResourceCanvasLayoutMode;
|
||||
resources: ResourceCanvasItem[];
|
||||
topology?: ResourceCanvasLayoutTopology;
|
||||
initializationReady?: boolean;
|
||||
renderFallbackWhileBlocked?: boolean;
|
||||
rederiveAutomaticPositions?: boolean;
|
||||
}) {
|
||||
const topologySignature = useMemo(
|
||||
() => createResourceTopologySignature(topology),
|
||||
[topology],
|
||||
);
|
||||
const resourceSignature = useMemo(
|
||||
() => createResourceSignature(resources),
|
||||
[resources],
|
||||
() => [createResourceSignature(resources), topologySignature].join('\n'),
|
||||
[resources, topologySignature],
|
||||
);
|
||||
const scopeKey = createScopeKey(
|
||||
projectPath,
|
||||
@@ -151,7 +246,8 @@ export function useProjectResourceCanvasLayout({
|
||||
const fallback = useMemo(() => {
|
||||
const empty = createEmptyResourceCanvasLayout(projectId, mode);
|
||||
return initializationReady || renderFallbackWhileBlocked
|
||||
? reconcileLayout(empty, resources, rederiveAutomaticPositions).layout
|
||||
? reconcileLayout(empty, resources, rederiveAutomaticPositions, topology)
|
||||
.layout
|
||||
: empty;
|
||||
}, [
|
||||
initializationReady,
|
||||
@@ -160,6 +256,7 @@ export function useProjectResourceCanvasLayout({
|
||||
rederiveAutomaticPositions,
|
||||
renderFallbackWhileBlocked,
|
||||
resources,
|
||||
topology,
|
||||
]);
|
||||
const [layout, setLayout] = useState<ProjectResourceCanvasLayout>(fallback);
|
||||
const [notice, setNotice] = useState<LayoutNotice>('');
|
||||
@@ -169,6 +266,7 @@ export function useProjectResourceCanvasLayout({
|
||||
const layoutRef = useRef<ProjectResourceCanvasLayout>(fallback);
|
||||
const persistedLayoutRef = useRef<ProjectResourceCanvasLayout>(fallback);
|
||||
const resourcesRef = useRef(resources);
|
||||
const topologyRef = useRef(topology);
|
||||
const resourceSignatureRef = useRef(resourceSignature);
|
||||
const initializedScopeEpochRef = useRef<number | null>(null);
|
||||
const scopeRef = useRef<LayoutScope>({
|
||||
@@ -188,6 +286,7 @@ export function useProjectResourceCanvasLayout({
|
||||
>(() => undefined);
|
||||
|
||||
resourcesRef.current = resources;
|
||||
topologyRef.current = topology;
|
||||
resourceSignatureRef.current = resourceSignature;
|
||||
|
||||
const applyLayout = useCallback((next: ProjectResourceCanvasLayout) => {
|
||||
@@ -213,6 +312,7 @@ export function useProjectResourceCanvasLayout({
|
||||
persistedLayoutRef.current,
|
||||
resourcesRef.current,
|
||||
rederiveAutomaticPositions,
|
||||
topologyRef.current,
|
||||
).layout;
|
||||
for (const intent of writeQueueRef.current) {
|
||||
if (intent.scopeEpoch === scopeEpoch && intent.kind === 'manual') {
|
||||
@@ -292,6 +392,7 @@ export function useProjectResourceCanvasLayout({
|
||||
persistedLayoutRef.current,
|
||||
resourcesRef.current,
|
||||
rederiveAutomaticPositions,
|
||||
topologyRef.current,
|
||||
);
|
||||
if (intent.kind === 'resources' && !reconciled.changed) {
|
||||
removeWriteIntent(intent);
|
||||
@@ -340,7 +441,10 @@ export function useProjectResourceCanvasLayout({
|
||||
}
|
||||
|
||||
const expectedRevision = persistedLayoutRef.current.revision;
|
||||
if (!isSafeProjectResourceCanvasLayoutRevision(expectedRevision)) {
|
||||
if (
|
||||
!isSafeProjectResourceCanvasLayoutRevision(expectedRevision) ||
|
||||
!layoutCoordinatesAreSafe(candidate)
|
||||
) {
|
||||
removeWriteIntent(intent);
|
||||
rebuildOptimisticLayout(scope.epoch);
|
||||
if (intent.kind === 'manual') {
|
||||
@@ -375,9 +479,12 @@ export function useProjectResourceCanvasLayout({
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!isSafeProjectResourceCanvasLayoutRevision(result.layout.revision)
|
||||
!isSafeProjectResourceCanvasLayoutRevision(result.layout.revision) ||
|
||||
!layoutCoordinatesAreSafe(result.layout)
|
||||
) {
|
||||
throw new Error('layout response revision is not a safe integer');
|
||||
throw new Error(
|
||||
'layout response revision or coordinates are invalid',
|
||||
);
|
||||
}
|
||||
if (!layoutMatchesScope(result.layout, currentScope)) {
|
||||
throw new Error('layout response scope mismatch');
|
||||
@@ -394,6 +501,7 @@ export function useProjectResourceCanvasLayout({
|
||||
result.layout,
|
||||
resourcesRef.current,
|
||||
rederiveAutomaticPositions,
|
||||
topologyRef.current,
|
||||
).changed
|
||||
) {
|
||||
enqueueResourceSyncRef.current(currentScope.epoch);
|
||||
@@ -413,6 +521,7 @@ export function useProjectResourceCanvasLayout({
|
||||
result.layout,
|
||||
resourcesRef.current,
|
||||
rederiveAutomaticPositions,
|
||||
topologyRef.current,
|
||||
).changed;
|
||||
const nextRetry =
|
||||
intent.kind === 'resources' ? intent.conflictRetries + 1 : 0;
|
||||
@@ -513,6 +622,7 @@ export function useProjectResourceCanvasLayout({
|
||||
emptyLayout,
|
||||
resourcesRef.current,
|
||||
rederiveAutomaticPositions,
|
||||
topologyRef.current,
|
||||
).layout;
|
||||
persistedLayoutRef.current = initialFallback;
|
||||
applyLayout(initialFallback);
|
||||
@@ -536,8 +646,13 @@ export function useProjectResourceCanvasLayout({
|
||||
if (cancelled || scopeRef.current.epoch !== epoch) {
|
||||
return;
|
||||
}
|
||||
if (!isSafeProjectResourceCanvasLayoutRevision(loaded.revision)) {
|
||||
throw new Error('layout response revision is not a safe integer');
|
||||
if (
|
||||
!isSafeProjectResourceCanvasLayoutRevision(loaded.revision) ||
|
||||
!layoutCoordinatesAreSafe(loaded)
|
||||
) {
|
||||
throw new Error(
|
||||
'layout response revision or coordinates are invalid',
|
||||
);
|
||||
}
|
||||
if (!layoutMatchesScope(loaded, scope)) {
|
||||
return;
|
||||
@@ -550,6 +665,7 @@ export function useProjectResourceCanvasLayout({
|
||||
loaded,
|
||||
resourcesRef.current,
|
||||
rederiveAutomaticPositions,
|
||||
topologyRef.current,
|
||||
).changed
|
||||
) {
|
||||
enqueueResourceSyncRef.current(epoch);
|
||||
@@ -596,6 +712,7 @@ export function useProjectResourceCanvasLayout({
|
||||
layoutRef.current,
|
||||
resourcesRef.current,
|
||||
rederiveAutomaticPositions,
|
||||
topologyRef.current,
|
||||
);
|
||||
applyLayout(reconciledCurrent.layout);
|
||||
if (
|
||||
@@ -604,6 +721,7 @@ export function useProjectResourceCanvasLayout({
|
||||
persistedLayoutRef.current,
|
||||
resourcesRef.current,
|
||||
rederiveAutomaticPositions,
|
||||
topologyRef.current,
|
||||
).changed
|
||||
) {
|
||||
enqueueResourceSyncRef.current(scope.epoch);
|
||||
|
||||
+659
File diff suppressed because it is too large
Load Diff
+239
@@ -0,0 +1,239 @@
|
||||
import {
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type {
|
||||
ProjectResourceCanvasLayoutMode,
|
||||
ProjectResourceCanvasSection,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
clampProjectResourceSectionHeight,
|
||||
clampProjectResourceSectionZoom,
|
||||
defaultProjectResourceSectionHeight,
|
||||
type ProjectResourceSectionHeightAction,
|
||||
projectResourceSectionHeightBounds,
|
||||
projectResourceSectionHeightKey,
|
||||
type ProjectResourceSectionZoomAction,
|
||||
RESOURCE_SECTION_ZOOM_DEFAULT,
|
||||
RESOURCE_SECTION_ZOOM_MAX,
|
||||
RESOURCE_SECTION_ZOOM_MIN,
|
||||
updateProjectResourceSectionHeight,
|
||||
updateProjectResourceSectionZoom,
|
||||
} from './resourceSectionHeightModel';
|
||||
|
||||
const RESOURCE_LAYOUT_MODES: readonly ProjectResourceCanvasLayoutMode[] = [
|
||||
'dependency',
|
||||
'type',
|
||||
];
|
||||
const RESOURCE_SECTIONS: readonly ProjectResourceCanvasSection[] = [
|
||||
'document',
|
||||
'version',
|
||||
'art',
|
||||
'audio',
|
||||
];
|
||||
|
||||
export type ProjectResourceSectionHeightState = {
|
||||
height: number;
|
||||
min: number;
|
||||
max: number;
|
||||
defaultHeight: number;
|
||||
atMin: boolean;
|
||||
atMax: boolean;
|
||||
atDefault: boolean;
|
||||
zoom: number;
|
||||
atMinZoom: boolean;
|
||||
atMaxZoom: boolean;
|
||||
atDefaultZoom: boolean;
|
||||
};
|
||||
|
||||
export function useProjectResourceSectionHeights(input: {
|
||||
projectId: string;
|
||||
mode: ProjectResourceCanvasLayoutMode;
|
||||
canvasRef: RefObject<HTMLDivElement | null>;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const { canvasRef, enabled, mode, projectId } = input;
|
||||
const [bounds, setBounds] = useState(() =>
|
||||
projectResourceSectionHeightBounds(0),
|
||||
);
|
||||
const [sessionHeights, setSessionHeights] = useState<Map<string, number>>(
|
||||
() => new Map(),
|
||||
);
|
||||
const [sessionZooms, setSessionZooms] = useState<Map<string, number>>(
|
||||
() => new Map(),
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled) {
|
||||
return undefined;
|
||||
}
|
||||
let frameId: number | null = null;
|
||||
const measure = () => {
|
||||
frameId = null;
|
||||
const canvasHeight = canvasRef.current?.clientHeight ?? 0;
|
||||
if (canvasHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
const nextBounds = projectResourceSectionHeightBounds(canvasHeight);
|
||||
setBounds((current) =>
|
||||
current.min === nextBounds.min && current.max === nextBounds.max
|
||||
? current
|
||||
: nextBounds,
|
||||
);
|
||||
setSessionHeights((current) => {
|
||||
let next: Map<string, number> | null = null;
|
||||
for (const layoutMode of RESOURCE_LAYOUT_MODES) {
|
||||
for (const section of RESOURCE_SECTIONS) {
|
||||
const key = projectResourceSectionHeightKey({
|
||||
projectId,
|
||||
mode: layoutMode,
|
||||
section,
|
||||
});
|
||||
const height = current.get(key);
|
||||
if (height === undefined) {
|
||||
continue;
|
||||
}
|
||||
const clamped = clampProjectResourceSectionHeight(
|
||||
height,
|
||||
nextBounds,
|
||||
);
|
||||
if (clamped !== height) {
|
||||
next ??= new Map(current);
|
||||
next.set(key, clamped);
|
||||
}
|
||||
}
|
||||
}
|
||||
return next ?? current;
|
||||
});
|
||||
};
|
||||
const scheduleMeasure = () => {
|
||||
if (frameId !== null) {
|
||||
return;
|
||||
}
|
||||
frameId = window.requestAnimationFrame(measure);
|
||||
};
|
||||
measure();
|
||||
window.addEventListener('resize', scheduleMeasure);
|
||||
return () => {
|
||||
if (frameId !== null) {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
}
|
||||
window.removeEventListener('resize', scheduleMeasure);
|
||||
};
|
||||
}, [canvasRef, enabled, projectId]);
|
||||
|
||||
const sectionStates = useMemo(() => {
|
||||
const defaultHeight = defaultProjectResourceSectionHeight(bounds);
|
||||
return new Map(
|
||||
RESOURCE_SECTIONS.map((section) => {
|
||||
const key = projectResourceSectionHeightKey({
|
||||
projectId,
|
||||
mode,
|
||||
section,
|
||||
});
|
||||
const height = clampProjectResourceSectionHeight(
|
||||
sessionHeights.get(key) ?? defaultHeight,
|
||||
bounds,
|
||||
);
|
||||
const zoom = clampProjectResourceSectionZoom(
|
||||
sessionZooms.get(key) ?? RESOURCE_SECTION_ZOOM_DEFAULT,
|
||||
);
|
||||
return [
|
||||
section,
|
||||
{
|
||||
height,
|
||||
min: bounds.min,
|
||||
max: bounds.max,
|
||||
defaultHeight,
|
||||
atMin: height === bounds.min,
|
||||
atMax: height === bounds.max,
|
||||
atDefault: height === defaultHeight,
|
||||
zoom,
|
||||
atMinZoom: zoom === RESOURCE_SECTION_ZOOM_MIN,
|
||||
atMaxZoom: zoom === RESOURCE_SECTION_ZOOM_MAX,
|
||||
atDefaultZoom: zoom === RESOURCE_SECTION_ZOOM_DEFAULT,
|
||||
} satisfies ProjectResourceSectionHeightState,
|
||||
] as const;
|
||||
}),
|
||||
);
|
||||
}, [bounds, mode, projectId, sessionHeights, sessionZooms]);
|
||||
|
||||
const updateSectionHeight = useCallback(
|
||||
(
|
||||
section: ProjectResourceCanvasSection,
|
||||
action: ProjectResourceSectionHeightAction,
|
||||
) => {
|
||||
const key = projectResourceSectionHeightKey({ projectId, mode, section });
|
||||
setSessionHeights((current) => {
|
||||
const currentHeight = clampProjectResourceSectionHeight(
|
||||
current.get(key) ?? defaultProjectResourceSectionHeight(bounds),
|
||||
bounds,
|
||||
);
|
||||
const nextHeight = updateProjectResourceSectionHeight(
|
||||
currentHeight,
|
||||
action,
|
||||
bounds,
|
||||
);
|
||||
if (nextHeight === currentHeight) {
|
||||
return current;
|
||||
}
|
||||
const next = new Map(current);
|
||||
next.set(key, nextHeight);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[bounds, mode, projectId],
|
||||
);
|
||||
|
||||
const setSectionZoom = useCallback(
|
||||
(section: ProjectResourceCanvasSection, zoom: number) => {
|
||||
const key = projectResourceSectionHeightKey({ projectId, mode, section });
|
||||
const nextZoom = clampProjectResourceSectionZoom(zoom);
|
||||
setSessionZooms((current) => {
|
||||
const currentZoom = clampProjectResourceSectionZoom(
|
||||
current.get(key) ?? RESOURCE_SECTION_ZOOM_DEFAULT,
|
||||
);
|
||||
if (nextZoom === currentZoom) {
|
||||
return current;
|
||||
}
|
||||
const next = new Map(current);
|
||||
next.set(key, nextZoom);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[mode, projectId],
|
||||
);
|
||||
|
||||
const updateSectionZoom = useCallback(
|
||||
(
|
||||
section: ProjectResourceCanvasSection,
|
||||
action: ProjectResourceSectionZoomAction,
|
||||
) => {
|
||||
const key = projectResourceSectionHeightKey({ projectId, mode, section });
|
||||
setSessionZooms((current) => {
|
||||
const currentZoom = clampProjectResourceSectionZoom(
|
||||
current.get(key) ?? RESOURCE_SECTION_ZOOM_DEFAULT,
|
||||
);
|
||||
const nextZoom = updateProjectResourceSectionZoom(currentZoom, action);
|
||||
if (nextZoom === currentZoom) {
|
||||
return current;
|
||||
}
|
||||
const next = new Map(current);
|
||||
next.set(key, nextZoom);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[mode, projectId],
|
||||
);
|
||||
|
||||
return {
|
||||
sectionStates,
|
||||
setSectionZoom,
|
||||
updateSectionHeight,
|
||||
updateSectionZoom,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user
[P1] 为预览缓存增加总字节上限
这里仅按 48 项限制缓存数量,但 Rust 允许单个媒体预览达到 32 MiB,并将其编码为约 42.7 MiB 的 base64 data URL。连续浏览 48 个大媒体时,仅缓存字符串就可能接近 2 GiB,尚未计算解码缓冲和媒体 DOM,WebView 存在 OOM 风险。请增加明确的总字节预算,或改用能够及时 revoke 的 Blob URL 等有界方案,并补连续浏览大媒体的回归。