game agent无限画布开发 #136
@@ -114,9 +114,22 @@ const rustSharedContractSource = fs.readFileSync(
|
||||
'utf8',
|
||||
);
|
||||
const allowedUncalledTauriCommands = [
|
||||
'archive_failed_local_project_resource_edit',
|
||||
'chat_with_game_creator_agent',
|
||||
'commit_local_project_asset',
|
||||
'confirm_local_project_asset_canvas_generation_service_identity',
|
||||
'create_local_project_asset_canvas_draft',
|
||||
'discard_local_project_asset_canvas_draft',
|
||||
'generate_local_project_asset_canvas_image',
|
||||
'open_game_creator_launcher_window',
|
||||
'open_game_creator_workspace_window',
|
||||
'read_local_project_asset_canvas_draft',
|
||||
'read_local_project_asset_canvas_media',
|
||||
'recover_local_project_asset_canvas_transactions',
|
||||
'recover_local_project_asset_canvas_generations',
|
||||
'stage_local_project_asset_canvas_image',
|
||||
'store_local_project_asset_canvas_media',
|
||||
'update_local_project_asset_canvas_draft',
|
||||
];
|
||||
const sourceExtensions = new Set([
|
||||
'.json',
|
||||
@@ -1675,7 +1688,8 @@ for (const snippet of [
|
||||
"'write_game_creator_app_config'",
|
||||
'aria-label="运行时配置"',
|
||||
'LLM API Key',
|
||||
'画板 API Key',
|
||||
'showDeveloperEditorApi',
|
||||
'开发者 External Editor API Key',
|
||||
'runtime_config.save',
|
||||
"'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'",
|
||||
"'activate_local_game_preview'",
|
||||
|
||||
+31
@@ -1644,6 +1644,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chromiumoxide",
|
||||
"futures",
|
||||
"getrandom 0.3.4",
|
||||
"http",
|
||||
"image",
|
||||
"jsonschema",
|
||||
@@ -2248,10 +2249,23 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"image-webp",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png 0.18.1",
|
||||
"tiff",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2630,6 +2644,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -4102,6 +4126,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
@@ -5905,6 +5930,12 @@ dependencies = [
|
||||
"unic-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-general-category"
|
||||
version = "1.1.0"
|
||||
|
||||
@@ -19,8 +19,9 @@ agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" }
|
||||
base64 = "0.22"
|
||||
chromiumoxide = "0.9.1"
|
||||
futures = "0.3"
|
||||
getrandom = "0.3"
|
||||
http = "1"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||
jsonschema = { version = "0.49.3", default-features = false }
|
||||
oxc_allocator = "0.143.0"
|
||||
oxc_ast = "0.143.0"
|
||||
@@ -37,7 +38,7 @@ similar = "2.7"
|
||||
platform-llm = { path = "../../../server-rs/crates/platform-llm" }
|
||||
platform-agent = { path = "../../../server-rs/crates/platform-agent" }
|
||||
portable-pty = "0.9"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls"] }
|
||||
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
|
||||
tauri = { version = "2.11.2", features = [] }
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
|
||||
@@ -13,14 +13,28 @@ mod run_lifecycle;
|
||||
mod tests;
|
||||
mod trace;
|
||||
|
||||
pub(crate) use canvas_generation::{
|
||||
classify_external_generation_initial_response, external_canvas_placeholder,
|
||||
external_editor_json_request, external_editor_response_data, external_generation_poll_after_ms,
|
||||
external_generation_result_has_download_reference,
|
||||
external_generation_submit_rejection_is_definitive,
|
||||
platform_art_generation_error_needs_reconciliation, prepare_external_canvas_generation_context,
|
||||
submit_external_generation_request, wait_for_external_generation_result,
|
||||
ExternalCanvasGenerationContext, ExternalGenerationInitialResponse,
|
||||
};
|
||||
pub(in crate::agent) use canvas_generation::{
|
||||
commit_prepared_platform_art_asset_at, commit_prepared_platform_art_asset_strict_slices_at,
|
||||
platform_art_generation_error_needs_reconciliation,
|
||||
platform_art_generation_error_result_unknown,
|
||||
request_platform_art_asset_with_runtime_options_at,
|
||||
validate_platform_art_png_bytes_with_limits,
|
||||
};
|
||||
pub(in crate::agent) use draft_validation::validate_closed_game_script_blocks;
|
||||
pub(crate) use external_generation_state::{
|
||||
classify_platform_art_generation_service_identity,
|
||||
platform_art_generation_external_service_fingerprint,
|
||||
platform_art_generation_external_service_origin, PlatformArtGenerationServiceIdentityMatch,
|
||||
PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME,
|
||||
};
|
||||
pub(in crate::agent) use external_generation_state::{
|
||||
game_creator_agent_runtime_external_generation_exists,
|
||||
platform_art_generation_runtime_context_from_pending,
|
||||
@@ -30,6 +44,8 @@ pub(in crate::agent) use external_generation_state::{
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use external_generation_state::{
|
||||
platform_art_generation_external_configuration_fingerprint,
|
||||
platform_art_generation_legacy_external_configuration_fingerprint,
|
||||
setup_platform_art_generation_runtime_accepted_for_recovery_test,
|
||||
write_platform_art_generation_runtime_accepted_for_test,
|
||||
write_platform_art_generation_runtime_prepared_for_test,
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
#[cfg(test)]
|
||||
use super::external_generation_state::platform_art_generation_external_configuration_fingerprint;
|
||||
use super::external_generation_state::{
|
||||
mark_platform_art_generation_runtime_accepted,
|
||||
mark_platform_art_generation_runtime_legacy_completed,
|
||||
platform_art_generation_external_configuration_fingerprint,
|
||||
migrate_platform_art_generation_external_configuration,
|
||||
platform_art_generation_external_service_fingerprint,
|
||||
platform_art_generation_runtime_idempotency_key, platform_art_generation_runtime_legacy_result,
|
||||
platform_art_generation_runtime_request_body_json,
|
||||
platform_art_generation_runtime_request_snapshot, platform_art_generation_runtime_status,
|
||||
platform_art_generation_runtime_submission_payload,
|
||||
prepare_platform_art_generation_runtime_state, read_platform_art_generation_runtime_state,
|
||||
validate_platform_art_generation_external_configuration, PlatformArtGenerationRuntimeState,
|
||||
PlatformArtGenerationRuntimeState,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
@@ -268,23 +271,25 @@ pub(crate) fn platform_art_asset_output_extension_matches(
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct ExternalCanvasGenerationContext {
|
||||
project_id: String,
|
||||
asset_folder_id: String,
|
||||
canvas_name: String,
|
||||
pub(crate) struct ExternalCanvasGenerationContext {
|
||||
pub(crate) project_id: String,
|
||||
pub(crate) asset_folder_id: String,
|
||||
pub(crate) canvas_name: String,
|
||||
}
|
||||
|
||||
fn external_editor_response_data(payload: &serde_json::Value) -> &serde_json::Value {
|
||||
pub(crate) fn external_editor_response_data(payload: &serde_json::Value) -> &serde_json::Value {
|
||||
payload.get("data").unwrap_or(payload)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
enum ExternalGenerationInitialResponse {
|
||||
pub(crate) enum ExternalGenerationInitialResponse {
|
||||
LegacyCompleted(serde_json::Value),
|
||||
AsyncSubmission(serde_json::Value),
|
||||
}
|
||||
|
||||
fn external_generation_result_has_download_reference(generated: &serde_json::Value) -> bool {
|
||||
pub(crate) fn external_generation_result_has_download_reference(
|
||||
generated: &serde_json::Value,
|
||||
) -> bool {
|
||||
let has_download_reference = |value: &serde_json::Value| {
|
||||
json_string_field(value, "objectKey").is_some()
|
||||
|| json_string_field(value, "imageSrc").is_some_and(|image_src| {
|
||||
@@ -307,7 +312,7 @@ fn external_generation_result_has_download_reference(generated: &serde_json::Val
|
||||
.is_some_and(has_download_reference)
|
||||
}
|
||||
|
||||
fn external_generation_download_source(
|
||||
pub(crate) fn external_generation_download_source(
|
||||
generated: &serde_json::Value,
|
||||
resource: &serde_json::Value,
|
||||
is_canonical_art_spritesheet: bool,
|
||||
@@ -371,7 +376,7 @@ fn consistent_canvas_task_id(
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
fn classify_external_generation_initial_response(
|
||||
pub(crate) fn classify_external_generation_initial_response(
|
||||
status: reqwest::StatusCode,
|
||||
payload: &serde_json::Value,
|
||||
) -> Result<ExternalGenerationInitialResponse, String> {
|
||||
@@ -405,7 +410,7 @@ fn classify_external_generation_initial_response(
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn platform_art_generation_error_needs_reconciliation(error: &str) -> bool {
|
||||
pub(crate) fn platform_art_generation_error_needs_reconciliation(error: &str) -> bool {
|
||||
error.starts_with(EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX)
|
||||
|| error.starts_with(EXTERNAL_GENERATION_SOURCE_PRESERVED_PREFIX)
|
||||
|| error.starts_with(PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX)
|
||||
@@ -415,7 +420,7 @@ pub(in crate::agent) fn platform_art_generation_error_result_unknown(error: &str
|
||||
error.starts_with(EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX)
|
||||
}
|
||||
|
||||
async fn external_editor_json_request(
|
||||
pub(crate) async fn external_editor_json_request(
|
||||
request: reqwest::RequestBuilder,
|
||||
action: &str,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
@@ -433,7 +438,7 @@ async fn external_editor_json_request(
|
||||
.map_err(|error| format!("解析{action}响应失败:{error}"))
|
||||
}
|
||||
|
||||
fn external_generation_poll_after_ms(payload: &serde_json::Value) -> u64 {
|
||||
pub(crate) fn external_generation_poll_after_ms(payload: &serde_json::Value) -> u64 {
|
||||
external_editor_response_data(payload)
|
||||
.get("pollAfterMs")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
@@ -442,7 +447,9 @@ fn external_generation_poll_after_ms(payload: &serde_json::Value) -> u64 {
|
||||
.min(EXTERNAL_GENERATION_MAX_POLL_AFTER_MS)
|
||||
}
|
||||
|
||||
fn external_generation_submit_rejection_is_definitive(status: reqwest::StatusCode) -> bool {
|
||||
pub(crate) fn external_generation_submit_rejection_is_definitive(
|
||||
status: reqwest::StatusCode,
|
||||
) -> bool {
|
||||
matches!(
|
||||
status,
|
||||
reqwest::StatusCode::BAD_REQUEST
|
||||
@@ -451,7 +458,7 @@ fn external_generation_submit_rejection_is_definitive(status: reqwest::StatusCod
|
||||
)
|
||||
}
|
||||
|
||||
async fn wait_for_external_generation_result(
|
||||
pub(crate) async fn wait_for_external_generation_result(
|
||||
client: &reqwest::Client,
|
||||
api_base_url: &str,
|
||||
api_key: &str,
|
||||
@@ -541,7 +548,7 @@ async fn wait_for_external_generation_result(
|
||||
}
|
||||
}
|
||||
|
||||
async fn submit_external_generation_request(
|
||||
pub(crate) async fn submit_external_generation_request(
|
||||
client: &reqwest::Client,
|
||||
api_base_url: &str,
|
||||
endpoint: &str,
|
||||
@@ -630,7 +637,7 @@ async fn resume_prepared_external_generation_at(
|
||||
}
|
||||
}
|
||||
|
||||
async fn prepare_external_canvas_generation_context(
|
||||
pub(crate) async fn prepare_external_canvas_generation_context(
|
||||
root: &Path,
|
||||
client: &reqwest::Client,
|
||||
api_base_url: &str,
|
||||
@@ -721,7 +728,7 @@ async fn prepare_external_canvas_generation_context(
|
||||
})
|
||||
}
|
||||
|
||||
fn external_canvas_placeholder(aspect_ratio: &str) -> serde_json::Value {
|
||||
pub(crate) fn external_canvas_placeholder(aspect_ratio: &str) -> serde_json::Value {
|
||||
let (width, height) = match aspect_ratio {
|
||||
"16:9" => (1024, 576),
|
||||
"9:16" => (576, 1024),
|
||||
@@ -1151,12 +1158,19 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
|
||||
};
|
||||
let api_base_url = resolve_canvas_sync_api_base_url(None)?;
|
||||
let api_key = resolve_canvas_sync_api_key(None)?;
|
||||
if let Some(state) = persisted_runtime_state.as_ref() {
|
||||
validate_platform_art_generation_external_configuration(state, &api_base_url, &api_key)
|
||||
.map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}"))?;
|
||||
}
|
||||
let persisted_runtime_state = persisted_runtime_state
|
||||
.map(|state| {
|
||||
migrate_platform_art_generation_external_configuration(
|
||||
root,
|
||||
state,
|
||||
&api_base_url,
|
||||
&api_key,
|
||||
)
|
||||
.map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}"))
|
||||
})
|
||||
.transpose()?;
|
||||
let external_configuration_fingerprint =
|
||||
platform_art_generation_external_configuration_fingerprint(&api_base_url, &api_key);
|
||||
platform_art_generation_external_service_fingerprint(&api_base_url);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(60))
|
||||
.build()
|
||||
@@ -6508,7 +6522,7 @@ mod canvas_generation_tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepted_runtime_generation_rejects_external_configuration_drift_before_get() {
|
||||
async fn accepted_runtime_generation_rejects_external_service_drift_before_get() {
|
||||
let temporary = tempfile::tempdir().expect("create configuration drift project");
|
||||
let root = temporary.path();
|
||||
init_local_game_project_at(root, "configuration-drift", "External Editor 配置漂移")
|
||||
@@ -6548,10 +6562,7 @@ mod canvas_generation_tests {
|
||||
action_fingerprint: "configuration-drift-fingerprint".to_string(),
|
||||
};
|
||||
let stale_configuration_fingerprint =
|
||||
platform_art_generation_external_configuration_fingerprint(
|
||||
"https://old-editor.example.test",
|
||||
"old-editor-key",
|
||||
);
|
||||
platform_art_generation_external_service_fingerprint("https://old-editor.example.test");
|
||||
let (state, _) = prepare_platform_art_generation_runtime_state(
|
||||
root,
|
||||
&runtime_context,
|
||||
@@ -6583,7 +6594,7 @@ mod canvas_generation_tests {
|
||||
Err(error) => error,
|
||||
Ok(_) => panic!("configuration drift must block GET-only recovery"),
|
||||
};
|
||||
assert!(error.contains("baseUrl/API Key"), "{error}");
|
||||
assert!(error.contains("服务地址身份"), "{error}");
|
||||
assert!(matches!(
|
||||
listener.accept(),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock
|
||||
|
||||
+235
-10
@@ -2,6 +2,7 @@ use super::*;
|
||||
|
||||
pub(in crate::agent) const PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION: &str =
|
||||
"agent-runtime-canvas-generation-request.v2";
|
||||
pub(crate) const PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME: &str = "service-origin-v1";
|
||||
const PLATFORM_ART_GENERATION_RUNTIME_MAX_BYTES: usize = 256 * 1024;
|
||||
const PLATFORM_ART_GENERATION_STATUS_PREPARED: &str = "prepared";
|
||||
const PLATFORM_ART_GENERATION_STATUS_ACCEPTED: &str = "accepted";
|
||||
@@ -67,6 +68,15 @@ pub(in crate::agent) enum PlatformArtGenerationRuntimeRecovery {
|
||||
ResumeLegacyCompleted,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum PlatformArtGenerationServiceIdentityMatch {
|
||||
Unbound,
|
||||
Current,
|
||||
LegacyVerified,
|
||||
LegacyUnverified,
|
||||
Changed,
|
||||
}
|
||||
|
||||
fn platform_art_generation_runtime_relative_path(agent_id: &str, run_id: &str) -> String {
|
||||
format!(
|
||||
".agent/runtime/canvas-generation-requests/{}/{}.json",
|
||||
@@ -122,7 +132,31 @@ fn request_body_json_and_sha256(
|
||||
Ok((request_body_json, request_body_sha256))
|
||||
}
|
||||
|
||||
pub(super) fn platform_art_generation_external_configuration_fingerprint(
|
||||
pub(crate) fn platform_art_generation_external_service_fingerprint(api_base_url: &str) -> String {
|
||||
let normalized_base_url = api_base_url.trim().trim_end_matches('/');
|
||||
format!("{:x}", Sha256::digest(normalized_base_url.as_bytes()))
|
||||
}
|
||||
|
||||
pub(crate) fn platform_art_generation_external_service_origin(
|
||||
api_base_url: &str,
|
||||
) -> Result<String, String> {
|
||||
let parsed = reqwest::Url::parse(api_base_url.trim())
|
||||
.map_err(|_| "External Editor 服务地址无效".to_string())?;
|
||||
if !matches!(parsed.scheme(), "http" | "https")
|
||||
|| parsed.host_str().is_none()
|
||||
|| !parsed.username().is_empty()
|
||||
|| parsed.password().is_some()
|
||||
{
|
||||
return Err("External Editor 服务地址无效".to_string());
|
||||
}
|
||||
let origin = parsed.origin().ascii_serialization();
|
||||
if origin == "null" {
|
||||
return Err("External Editor 服务 origin 无效".to_string());
|
||||
}
|
||||
Ok(origin)
|
||||
}
|
||||
|
||||
pub(crate) fn platform_art_generation_legacy_external_configuration_fingerprint(
|
||||
api_base_url: &str,
|
||||
api_key: &str,
|
||||
) -> String {
|
||||
@@ -134,18 +168,88 @@ pub(super) fn platform_art_generation_external_configuration_fingerprint(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn classify_platform_art_generation_service_identity(
|
||||
scheme: Option<&str>,
|
||||
fingerprint: Option<&str>,
|
||||
api_base_url: &str,
|
||||
api_key: &str,
|
||||
) -> PlatformArtGenerationServiceIdentityMatch {
|
||||
let Some(fingerprint) = fingerprint else {
|
||||
return if scheme.is_none() {
|
||||
PlatformArtGenerationServiceIdentityMatch::Unbound
|
||||
} else {
|
||||
PlatformArtGenerationServiceIdentityMatch::Changed
|
||||
};
|
||||
};
|
||||
let current = platform_art_generation_external_service_fingerprint(api_base_url);
|
||||
match scheme {
|
||||
Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME) => {
|
||||
if fingerprint == current {
|
||||
PlatformArtGenerationServiceIdentityMatch::Current
|
||||
} else {
|
||||
PlatformArtGenerationServiceIdentityMatch::Changed
|
||||
}
|
||||
}
|
||||
Some(_) => PlatformArtGenerationServiceIdentityMatch::Changed,
|
||||
None if fingerprint == current => PlatformArtGenerationServiceIdentityMatch::Current,
|
||||
None if fingerprint
|
||||
== platform_art_generation_legacy_external_configuration_fingerprint(
|
||||
api_base_url,
|
||||
api_key,
|
||||
) =>
|
||||
{
|
||||
PlatformArtGenerationServiceIdentityMatch::LegacyVerified
|
||||
}
|
||||
None => PlatformArtGenerationServiceIdentityMatch::LegacyUnverified,
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the historical helper for persisted-agent call sites while changing its
|
||||
// identity semantics: credentials authorize an operation but do not own it.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn platform_art_generation_external_configuration_fingerprint(
|
||||
api_base_url: &str,
|
||||
_api_key: &str,
|
||||
) -> String {
|
||||
platform_art_generation_external_service_fingerprint(api_base_url)
|
||||
}
|
||||
|
||||
pub(super) fn validate_platform_art_generation_external_configuration(
|
||||
state: &PlatformArtGenerationRuntimeState,
|
||||
api_base_url: &str,
|
||||
api_key: &str,
|
||||
) -> Result<(), String> {
|
||||
let current = platform_art_generation_external_configuration_fingerprint(api_base_url, api_key);
|
||||
if state.external_configuration_fingerprint != current {
|
||||
return Err("External Editor 生成账本与当前 baseUrl/API Key 身份不一致".to_string());
|
||||
if !matches!(
|
||||
classify_platform_art_generation_service_identity(
|
||||
None,
|
||||
Some(&state.external_configuration_fingerprint),
|
||||
api_base_url,
|
||||
api_key,
|
||||
),
|
||||
PlatformArtGenerationServiceIdentityMatch::Current
|
||||
| PlatformArtGenerationServiceIdentityMatch::LegacyVerified
|
||||
) {
|
||||
return Err("External Editor 生成账本与当前服务地址身份不一致".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn migrate_platform_art_generation_external_configuration(
|
||||
root: &Path,
|
||||
mut state: PlatformArtGenerationRuntimeState,
|
||||
api_base_url: &str,
|
||||
api_key: &str,
|
||||
) -> Result<PlatformArtGenerationRuntimeState, String> {
|
||||
validate_platform_art_generation_external_configuration(&state, api_base_url, api_key)?;
|
||||
let current = platform_art_generation_external_service_fingerprint(api_base_url);
|
||||
if state.external_configuration_fingerprint != current {
|
||||
state.external_configuration_fingerprint = current;
|
||||
state.updated_at = unix_timestamp();
|
||||
write_platform_art_generation_runtime_state(root, &state)?;
|
||||
}
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
fn validate_platform_art_generation_runtime_identity(
|
||||
root: &Path,
|
||||
state: &PlatformArtGenerationRuntimeState,
|
||||
@@ -667,9 +771,8 @@ pub(crate) fn write_platform_art_generation_runtime_accepted_for_test(
|
||||
let context = platform_art_generation_runtime_context_from_pending(pending);
|
||||
let api_base_url =
|
||||
resolve_canvas_sync_api_base_url(None).unwrap_or_else(|_| "http://127.0.0.1:1".to_string());
|
||||
let api_key = resolve_canvas_sync_api_key(None).unwrap_or_else(|_| "test-api-key".to_string());
|
||||
let external_configuration_fingerprint =
|
||||
platform_art_generation_external_configuration_fingerprint(&api_base_url, &api_key);
|
||||
platform_art_generation_external_service_fingerprint(&api_base_url);
|
||||
let (state, created) = prepare_platform_art_generation_runtime_state(
|
||||
root,
|
||||
&context,
|
||||
@@ -700,9 +803,8 @@ pub(crate) fn write_platform_art_generation_runtime_prepared_for_test(
|
||||
let context = platform_art_generation_runtime_context_from_pending(pending);
|
||||
let api_base_url =
|
||||
resolve_canvas_sync_api_base_url(None).unwrap_or_else(|_| "http://127.0.0.1:1".to_string());
|
||||
let api_key = resolve_canvas_sync_api_key(None).unwrap_or_else(|_| "test-api-key".to_string());
|
||||
let external_configuration_fingerprint =
|
||||
platform_art_generation_external_configuration_fingerprint(&api_base_url, &api_key);
|
||||
platform_art_generation_external_service_fingerprint(&api_base_url);
|
||||
let (_, created) = prepare_platform_art_generation_runtime_state(
|
||||
root,
|
||||
&context,
|
||||
@@ -786,6 +888,129 @@ pub(crate) fn setup_platform_art_generation_runtime_accepted_for_recovery_test(
|
||||
mod external_generation_state_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn external_service_fingerprint_ignores_key_rotation_and_normalizes_trailing_slash() {
|
||||
let original = platform_art_generation_external_configuration_fingerprint(
|
||||
"https://editor.example.test/",
|
||||
"original-key",
|
||||
);
|
||||
let rotated = platform_art_generation_external_configuration_fingerprint(
|
||||
"https://editor.example.test",
|
||||
"rotated-key",
|
||||
);
|
||||
let different_service = platform_art_generation_external_service_fingerprint(
|
||||
"https://other-editor.example.test",
|
||||
);
|
||||
|
||||
assert_eq!(original, rotated);
|
||||
assert_ne!(original, different_service);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versioned_service_identity_distinguishes_verified_and_unverified_legacy_ledgers() {
|
||||
let base_url = "https://editor.example.test/";
|
||||
let current = platform_art_generation_external_service_fingerprint(base_url);
|
||||
let legacy = platform_art_generation_legacy_external_configuration_fingerprint(
|
||||
base_url,
|
||||
"original-key",
|
||||
);
|
||||
assert_eq!(
|
||||
classify_platform_art_generation_service_identity(
|
||||
Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME),
|
||||
Some(¤t),
|
||||
base_url,
|
||||
"rotated-key",
|
||||
),
|
||||
PlatformArtGenerationServiceIdentityMatch::Current
|
||||
);
|
||||
assert_eq!(
|
||||
classify_platform_art_generation_service_identity(
|
||||
None,
|
||||
Some(&legacy),
|
||||
base_url,
|
||||
"original-key",
|
||||
),
|
||||
PlatformArtGenerationServiceIdentityMatch::LegacyVerified
|
||||
);
|
||||
assert_eq!(
|
||||
classify_platform_art_generation_service_identity(
|
||||
None,
|
||||
Some(&legacy),
|
||||
base_url,
|
||||
"rotated-key",
|
||||
),
|
||||
PlatformArtGenerationServiceIdentityMatch::LegacyUnverified
|
||||
);
|
||||
assert_eq!(
|
||||
platform_art_generation_external_service_origin(
|
||||
"https://editor.example.test/private/path"
|
||||
)
|
||||
.expect("derive public origin"),
|
||||
"https://editor.example.test"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_external_configuration_fingerprint_migrates_before_key_rotation() {
|
||||
let temporary = crate::tests::canonical_test_tempdir("legacy-fingerprint-");
|
||||
let root = temporary.path();
|
||||
init_local_game_project_at(root, "legacy-fingerprint", "旧配置指纹迁移")
|
||||
.expect("init project");
|
||||
let pending = pending_canvas_generation(root);
|
||||
let context = platform_art_generation_runtime_context_from_pending(&pending);
|
||||
let api_base_url = "https://editor.example.test/";
|
||||
let legacy_fingerprint = platform_art_generation_legacy_external_configuration_fingerprint(
|
||||
api_base_url,
|
||||
"original-key",
|
||||
);
|
||||
let (legacy_state, created) = prepare_platform_art_generation_runtime_state(
|
||||
root,
|
||||
&context,
|
||||
"/api/external/v1/editor/images/generations",
|
||||
"legacy-fingerprint-canvas",
|
||||
"恢复旧请求",
|
||||
&serde_json::json!({
|
||||
"prompt": "恢复旧请求",
|
||||
"kind": "spec",
|
||||
"projectId": "canvas-project",
|
||||
"assetFolderId": "asset-folder",
|
||||
"referenceImageSrcs": []
|
||||
}),
|
||||
&legacy_fingerprint,
|
||||
)
|
||||
.expect("prepare legacy generation ledger");
|
||||
assert!(created);
|
||||
|
||||
let migrated = migrate_platform_art_generation_external_configuration(
|
||||
root,
|
||||
legacy_state,
|
||||
api_base_url,
|
||||
"original-key",
|
||||
)
|
||||
.expect("migrate legacy fingerprint");
|
||||
assert_eq!(
|
||||
migrated.external_configuration_fingerprint,
|
||||
platform_art_generation_external_service_fingerprint(api_base_url)
|
||||
);
|
||||
validate_platform_art_generation_external_configuration(
|
||||
&migrated,
|
||||
"https://editor.example.test",
|
||||
"rotated-key",
|
||||
)
|
||||
.expect("rotated key must recover the migrated operation");
|
||||
assert!(validate_platform_art_generation_external_configuration(
|
||||
&migrated,
|
||||
"https://other-editor.example.test",
|
||||
"rotated-key",
|
||||
)
|
||||
.is_err());
|
||||
|
||||
let persisted = read_platform_art_generation_runtime_state(root, &context)
|
||||
.expect("read migrated ledger")
|
||||
.expect("migrated ledger exists");
|
||||
assert_eq!(persisted, migrated);
|
||||
}
|
||||
|
||||
fn pending_canvas_generation(root: &Path) -> AgentRuntimePendingToolAction {
|
||||
let mut runtime = start_game_creator_agent_runtime_task_at(
|
||||
root,
|
||||
@@ -915,12 +1140,12 @@ mod external_generation_state_tests {
|
||||
"test-api-key",
|
||||
)
|
||||
.is_err());
|
||||
assert!(validate_platform_art_generation_external_configuration(
|
||||
validate_platform_art_generation_external_configuration(
|
||||
&prepared,
|
||||
"https://editor.example.test",
|
||||
"different-api-key",
|
||||
)
|
||||
.is_err());
|
||||
.expect("rotated API Key must keep ownership of the accepted service operation");
|
||||
assert_eq!(
|
||||
platform_art_generation_runtime_recovery_at(root, &pending)
|
||||
.expect("read prepared recovery"),
|
||||
|
||||
@@ -360,7 +360,7 @@ 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, child, _delegation_id) =
|
||||
let (_main, mut child, _delegation_id) =
|
||||
game_chat_main_art_child_fixture(&root, "art-asset-plan", &["core-spritesheet"]);
|
||||
assert_eq!(child.agent_id, "art-asset-plan");
|
||||
assert_eq!(child.source, "agent-delegate");
|
||||
@@ -495,6 +495,10 @@ async fn game_chat_main_agent_delegates_only_real_missing_art_and_limits_childre
|
||||
manifest_before
|
||||
);
|
||||
|
||||
child.status = "running".to_string();
|
||||
child.phase = "planning".to_string();
|
||||
append_game_creator_agent_runtime_task(&root, &child)
|
||||
.expect("persist running art child before allowed write");
|
||||
let allowed = observe_agent_runtime_file_write(
|
||||
&root,
|
||||
&child.agent_id,
|
||||
@@ -686,13 +690,6 @@ fn game_chat_art_receipt_wakes_the_same_code_prototype_run() {
|
||||
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"]);
|
||||
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();
|
||||
@@ -759,6 +756,12 @@ 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");
|
||||
let waiting = read_latest_game_creator_agent_runtime_task_by_run_id(
|
||||
&root,
|
||||
"code-prototype",
|
||||
@@ -783,14 +786,8 @@ 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 root = std::env::temp_dir().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()
|
||||
));
|
||||
let temporary = crate::tests::canonical_test_tempdir("main-loop-legacy-");
|
||||
let root = temporary.path().join("project");
|
||||
init_local_game_project_at(&root, "legacy-derived-visuals", "旧派生视觉返工门禁")
|
||||
.expect("project init");
|
||||
assert!(!autonomous_registered_derived_visuals_need_repair_at(&root));
|
||||
@@ -826,8 +823,6 @@ fn autonomous_parent_waits_for_active_child_while_registered_derived_visuals_nee
|
||||
)
|
||||
.expect("persist parent wait despite derived visual repair");
|
||||
assert_eq!(parent_state.phase, "waiting-for-manifest-tasks");
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
fn prepare_autonomous_completion_evidence(
|
||||
|
||||
@@ -227,8 +227,8 @@ pub(crate) fn spawn_started_game_creator_agent_background_task_drain_with_lock(
|
||||
let (first_poll_sender, first_poll_receiver) = std::sync::mpsc::sync_channel(1);
|
||||
let (runtime_lock_sender, runtime_lock_receiver) = std::sync::mpsc::sync_channel(1);
|
||||
let worker_name = format!(
|
||||
"agent-ready-task-{}",
|
||||
sanitize_agent_runtime_text(&agent_id, 48)
|
||||
"agent-runtime-worker-{}",
|
||||
sanitize_agent_runtime_text(&agent_id, 44)
|
||||
);
|
||||
if let Err(error) = std::thread::Builder::new()
|
||||
.name(worker_name)
|
||||
@@ -252,7 +252,7 @@ pub(crate) fn spawn_started_game_creator_agent_background_task_drain_with_lock(
|
||||
})
|
||||
{
|
||||
return Err((
|
||||
format!("创建 Agent Runtime child execution worker 失败:{error}"),
|
||||
format!("创建 Agent Runtime 后台执行 worker 失败:{error}"),
|
||||
runtime_lock,
|
||||
));
|
||||
}
|
||||
@@ -261,13 +261,13 @@ pub(crate) fn spawn_started_game_creator_agent_background_task_drain_with_lock(
|
||||
.is_err()
|
||||
{
|
||||
return Err((
|
||||
"Agent Runtime child execution future 未在 2 秒内开始轮询".to_string(),
|
||||
"Agent Runtime 后台执行 future 未在 2 秒内开始轮询".to_string(),
|
||||
runtime_lock,
|
||||
));
|
||||
}
|
||||
if let Err(error) = runtime_lock_sender.send(runtime_lock) {
|
||||
return Err((
|
||||
"Agent Runtime child execution future 在接管执行锁前已退出".to_string(),
|
||||
"Agent Runtime 后台执行 future 在接管执行锁前已退出".to_string(),
|
||||
error.0,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -506,19 +506,20 @@ pub(in crate::agent) fn start_game_creator_agent_background_task_with_link_in_se
|
||||
|
||||
let result =
|
||||
read_game_creator_agent_runtime_for_session_at(root, &agent_id, Some(&session_id))?;
|
||||
let root = root.to_path_buf();
|
||||
let background_agent_id = agent_id.to_string();
|
||||
let background_task = next_task.task;
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let _runtime_lock = runtime_lock;
|
||||
drain_game_creator_agent_background_tasks(
|
||||
if let Err((error, _runtime_lock)) =
|
||||
spawn_started_game_creator_agent_background_task_drain_with_lock(
|
||||
root,
|
||||
background_agent_id,
|
||||
agent_id,
|
||||
background_task,
|
||||
state,
|
||||
state.clone(),
|
||||
runtime_lock,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
{
|
||||
let error = format!("Agent Runtime 后台执行 worker 启动失败:{error}");
|
||||
let _ = fail_game_creator_agent_runtime_turn_at(root, state, &error);
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
Ok((result, run_id))
|
||||
}
|
||||
|
||||
@@ -535,7 +535,7 @@ fn external_asset_resolved_addresses_are_safe(
|
||||
.all(|address| external_asset_ip_is_proxy_benchmark(address.ip()))
|
||||
}
|
||||
|
||||
fn validate_external_asset_download_url(
|
||||
pub(crate) fn validate_external_asset_download_url(
|
||||
value: &str,
|
||||
api_base_url: &str,
|
||||
_came_from_stable_reference: bool,
|
||||
@@ -558,7 +558,7 @@ fn validate_external_asset_download_url(
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
async fn build_external_asset_download_client(
|
||||
pub(crate) async fn build_external_asset_download_client(
|
||||
url: &url::Url,
|
||||
api_base_url: &str,
|
||||
came_from_stable_reference: bool,
|
||||
@@ -608,6 +608,25 @@ pub(crate) async fn resolve_canvas_resource_download_with_limit(
|
||||
api_key: &str,
|
||||
resource: &serde_json::Value,
|
||||
max_bytes: usize,
|
||||
) -> Result<Option<CanvasResourceDownload>, String> {
|
||||
resolve_canvas_resource_download_with_limit_and_route(
|
||||
_client,
|
||||
api_base_url,
|
||||
api_key,
|
||||
resource,
|
||||
max_bytes,
|
||||
"/api/external/v1/assets/read-url",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn resolve_canvas_resource_download_with_limit_and_route(
|
||||
_client: &reqwest::Client,
|
||||
api_base_url: &str,
|
||||
bearer_token: &str,
|
||||
resource: &serde_json::Value,
|
||||
max_bytes: usize,
|
||||
read_url_route: &str,
|
||||
) -> Result<Option<CanvasResourceDownload>, String> {
|
||||
if max_bytes == 0 {
|
||||
return Err("画板资产剩余下载预算为 0,已拒绝同步".to_string());
|
||||
@@ -623,23 +642,26 @@ pub(crate) async fn resolve_canvas_resource_download_with_limit(
|
||||
let source_hint = object_key.as_deref().or(image_src.as_deref());
|
||||
let (signed_url, came_from_stable_reference) = if let Some(object_key) = object_key.as_deref() {
|
||||
let read_url = format!(
|
||||
"{}/api/external/v1/assets/read-url?objectKey={}",
|
||||
"{}{read_url_route}?objectKey={}",
|
||||
api_base_url,
|
||||
percent_encode_query_component(object_key)
|
||||
);
|
||||
(
|
||||
Some(resolve_external_asset_signed_url(&secure_client, api_key, read_url).await?),
|
||||
Some(resolve_external_asset_signed_url(&secure_client, bearer_token, read_url).await?),
|
||||
true,
|
||||
)
|
||||
} else if let Some(image_src) = image_src.as_deref() {
|
||||
if image_src.starts_with('/') {
|
||||
let read_url = format!(
|
||||
"{}/api/external/v1/assets/read-url?legacyPublicPath={}",
|
||||
"{}{read_url_route}?legacyPublicPath={}",
|
||||
api_base_url,
|
||||
percent_encode_query_component(image_src)
|
||||
);
|
||||
(
|
||||
Some(resolve_external_asset_signed_url(&secure_client, api_key, read_url).await?),
|
||||
Some(
|
||||
resolve_external_asset_signed_url(&secure_client, bearer_token, read_url)
|
||||
.await?,
|
||||
),
|
||||
true,
|
||||
)
|
||||
} else if image_src.starts_with("http://") || image_src.starts_with("https://") {
|
||||
|
||||
@@ -255,6 +255,162 @@ pub(crate) fn update_local_project_resource_canvas_layout(
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn create_local_project_asset_canvas_draft(
|
||||
input: CreateAssetCanvasDraftInput,
|
||||
) -> Result<CreateAssetCanvasDraftResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
create_asset_canvas_draft_at(&root, &input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_local_project_asset_canvas_draft(
|
||||
input: ReadAssetCanvasDraftInput,
|
||||
) -> Result<ReadAssetCanvasDraftResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
read_asset_canvas_draft_at(&root, &input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn discover_local_project_asset_canvas_draft(
|
||||
input: DiscoverAssetCanvasDraftInput,
|
||||
) -> Result<DiscoverAssetCanvasDraftResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
discover_asset_canvas_draft_at(&root, &input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn update_local_project_asset_canvas_draft(
|
||||
input: UpdateAssetCanvasDraftInput,
|
||||
) -> Result<UpdateAssetCanvasDraftResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
update_asset_canvas_draft_at(&root, &input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn store_local_project_asset_canvas_media(
|
||||
input: StoreAssetCanvasMediaInput,
|
||||
) -> Result<StoreAssetCanvasMediaResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
store_asset_canvas_media_at(&root, &input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn stage_local_project_asset_canvas_image(
|
||||
input: StageAssetCanvasImageInput,
|
||||
) -> Result<StageAssetCanvasImageResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
stage_asset_canvas_image_at(&root, &input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn generate_local_project_asset_canvas_image(
|
||||
app: tauri::AppHandle,
|
||||
input: GenerateAssetCanvasImageInput,
|
||||
) -> Result<GenerateAssetCanvasImageResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
let progress_app = app.clone();
|
||||
let execution = generate_asset_canvas_image_at(&root, &input, move |payload| {
|
||||
let _ = progress_app.emit(ASSET_CANVAS_GENERATION_PROGRESS_EVENT, payload);
|
||||
})
|
||||
.await?;
|
||||
if let Some(event) = execution.event.as_ref() {
|
||||
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
|
||||
app.emit(
|
||||
"game-creator-local-asset-committed",
|
||||
asset_canvas_committed_public_event(payload),
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
});
|
||||
}
|
||||
Ok(execution.result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn recover_local_project_asset_canvas_generations(
|
||||
app: tauri::AppHandle,
|
||||
input: RecoverAssetCanvasGenerationsInput,
|
||||
) -> Result<RecoverAssetCanvasGenerationsResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
let progress_app = app.clone();
|
||||
let execution = recover_asset_canvas_generations_at(&root, &input, move |payload| {
|
||||
let _ = progress_app.emit(ASSET_CANVAS_GENERATION_PROGRESS_EVENT, payload);
|
||||
})
|
||||
.await?;
|
||||
for event in &execution.events {
|
||||
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
|
||||
app.emit(
|
||||
"game-creator-local-asset-committed",
|
||||
asset_canvas_committed_public_event(payload),
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
});
|
||||
}
|
||||
Ok(execution.result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn confirm_local_project_asset_canvas_generation_service_identity(
|
||||
input: ConfirmAssetCanvasGenerationServiceIdentityInput,
|
||||
) -> Result<ConfirmAssetCanvasGenerationServiceIdentityResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
confirm_asset_canvas_generation_service_identity_at(&root, &input).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_local_project_asset_canvas_media(
|
||||
input: ReadAssetCanvasMediaInput,
|
||||
) -> Result<ReadAssetCanvasMediaResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
read_asset_canvas_media_at(&root, &input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn discard_local_project_asset_canvas_draft(
|
||||
input: DiscardAssetCanvasDraftInput,
|
||||
) -> Result<DiscardAssetCanvasDraftResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
discard_asset_canvas_draft_at(&root, &input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn commit_local_project_asset(
|
||||
app: tauri::AppHandle,
|
||||
input: CommitAssetCanvasInput,
|
||||
) -> Result<CommitAssetCanvasResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
let execution = commit_asset_canvas_at(&root, &input)?;
|
||||
if let Some(event) = execution.event.as_ref() {
|
||||
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
|
||||
app.emit(
|
||||
"game-creator-local-asset-committed",
|
||||
asset_canvas_committed_public_event(payload),
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
});
|
||||
}
|
||||
Ok(execution.result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn recover_local_project_asset_canvas_transactions(
|
||||
app: tauri::AppHandle,
|
||||
input: RecoverAssetCanvasTransactionsInput,
|
||||
) -> Result<RecoverAssetCanvasTransactionsResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
let execution = recover_asset_canvas_transactions_at(&root, &input.expected_project_id)?;
|
||||
for event in &execution.events {
|
||||
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
|
||||
app.emit(
|
||||
"game-creator-local-asset-committed",
|
||||
asset_canvas_committed_public_event(payload),
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
});
|
||||
}
|
||||
Ok(execution.result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn control_agent_run(
|
||||
app: tauri::AppHandle,
|
||||
@@ -1096,6 +1252,69 @@ pub(crate) fn register_local_asset(
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn derive_local_project_resource(
|
||||
input: DeriveLocalProjectResourceInput,
|
||||
) -> Result<DeriveLocalProjectResourceResult, String> {
|
||||
let root = Path::new(input.project_path.trim());
|
||||
enforce_project_permission_policy(root, "asset.register")?;
|
||||
derive_local_project_resource_at(input).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn list_pending_local_project_resource_edits(
|
||||
input: ListPendingLocalProjectResourceEditsInput,
|
||||
) -> Result<Vec<PendingLocalProjectResourceEdit>, String> {
|
||||
let root = Path::new(input.project_path.trim());
|
||||
enforce_project_permission_policy(root, "file.list")?;
|
||||
list_pending_local_project_resource_edits_at(input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn resume_local_project_resource_edit(
|
||||
input: ResumeLocalProjectResourceEditInput,
|
||||
) -> Result<DeriveLocalProjectResourceResult, String> {
|
||||
let root = Path::new(input.project_path.trim());
|
||||
enforce_project_permission_policy(root, "asset.register")?;
|
||||
resume_local_project_resource_edit_at(input).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn request_local_project_resource_edit_service_identity_confirmation(
|
||||
input: RequestResourceEditServiceIdentityConfirmationInput,
|
||||
) -> Result<ResourceEditServiceIdentityConfirmation, String> {
|
||||
let root = Path::new(input.project_path.trim());
|
||||
enforce_project_permission_policy(root, "asset.register")?;
|
||||
request_resource_edit_service_identity_confirmation_at(input).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn confirm_local_project_resource_edit_service_identity(
|
||||
input: ConfirmResourceEditServiceIdentityInput,
|
||||
) -> Result<ConfirmResourceEditServiceIdentityResult, String> {
|
||||
let root = Path::new(input.project_path.trim());
|
||||
enforce_project_permission_policy(root, "asset.register")?;
|
||||
confirm_resource_edit_service_identity_at(input).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn archive_failed_local_project_resource_edit(
|
||||
input: ArchiveFailedLocalProjectResourceEditInput,
|
||||
) -> Result<ArchiveFailedLocalProjectResourceEditResult, String> {
|
||||
let root = Path::new(input.project_path.trim());
|
||||
enforce_project_permission_policy(root, "asset.register")?;
|
||||
archive_failed_local_project_resource_edit_at(input).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn normalize_local_project_raster_resource(
|
||||
input: NormalizeLocalProjectRasterResourceInput,
|
||||
) -> Result<NormalizeLocalProjectRasterResourceResult, String> {
|
||||
let root = Path::new(input.project_path.trim());
|
||||
enforce_project_permission_policy(root, "asset.register")?;
|
||||
normalize_local_project_raster_resource_at(input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn import_canvas_asset(
|
||||
project_path: String,
|
||||
@@ -1325,12 +1544,18 @@ pub(crate) fn read_local_project_media_preview(
|
||||
is_supported_project_audio_resource(&asset.local_path, &asset.media_type)
|
||||
}
|
||||
}
|
||||
}) || (kind == ProjectMediaPreviewKind::Art
|
||||
&& manifest.tasks.iter().any(|task| {
|
||||
task.status == GameCreationAppTaskStatus::Completed
|
||||
&& task.artifacts.iter().any(|path| path == &normalized_path)
|
||||
&& is_supported_project_art_media_resource(&normalized_path, "")
|
||||
}));
|
||||
}) || manifest.tasks.iter().any(|task| {
|
||||
task.status == GameCreationAppTaskStatus::Completed
|
||||
&& task.artifacts.iter().any(|path| path == &normalized_path)
|
||||
&& match kind {
|
||||
ProjectMediaPreviewKind::Art => {
|
||||
is_supported_project_art_media_resource(&normalized_path, "")
|
||||
}
|
||||
ProjectMediaPreviewKind::Audio => {
|
||||
is_supported_project_audio_resource(&normalized_path, "")
|
||||
}
|
||||
}
|
||||
});
|
||||
if !is_registered_media {
|
||||
return Err("只能预览当前项目已登记的媒体资源".to_string());
|
||||
}
|
||||
|
||||
@@ -2209,6 +2209,13 @@ fn main() {
|
||||
read_game_creator_mcp_catalog,
|
||||
upload_local_asset,
|
||||
register_local_asset,
|
||||
derive_local_project_resource,
|
||||
list_pending_local_project_resource_edits,
|
||||
resume_local_project_resource_edit,
|
||||
request_local_project_resource_edit_service_identity_confirmation,
|
||||
confirm_local_project_resource_edit_service_identity,
|
||||
archive_failed_local_project_resource_edit,
|
||||
normalize_local_project_raster_resource,
|
||||
import_canvas_asset,
|
||||
import_canvas_export,
|
||||
sync_canvas_project_assets,
|
||||
@@ -2256,6 +2263,19 @@ fn main() {
|
||||
read_local_project_resource_canvas_layout,
|
||||
read_local_project_resource_graph,
|
||||
update_local_project_resource_canvas_layout,
|
||||
create_local_project_asset_canvas_draft,
|
||||
read_local_project_asset_canvas_draft,
|
||||
discover_local_project_asset_canvas_draft,
|
||||
update_local_project_asset_canvas_draft,
|
||||
store_local_project_asset_canvas_media,
|
||||
stage_local_project_asset_canvas_image,
|
||||
generate_local_project_asset_canvas_image,
|
||||
recover_local_project_asset_canvas_generations,
|
||||
confirm_local_project_asset_canvas_generation_service_identity,
|
||||
read_local_project_asset_canvas_media,
|
||||
discard_local_project_asset_canvas_draft,
|
||||
recover_local_project_asset_canvas_transactions,
|
||||
commit_local_project_asset,
|
||||
get_local_game_project_revision,
|
||||
get_local_game_manifest
|
||||
])
|
||||
|
||||
@@ -4,6 +4,7 @@ use similar::TextDiff;
|
||||
use std::io::{Seek, SeekFrom};
|
||||
|
||||
mod agent_db;
|
||||
mod asset_canvas;
|
||||
mod checkpoint;
|
||||
mod conversation;
|
||||
mod export;
|
||||
@@ -11,10 +12,12 @@ mod filesystem;
|
||||
mod manifest;
|
||||
mod memory;
|
||||
mod resource_dependency_graph;
|
||||
mod resource_editor;
|
||||
mod resource_layout;
|
||||
mod verification;
|
||||
|
||||
pub(crate) use agent_db::*;
|
||||
pub(crate) use asset_canvas::*;
|
||||
pub(crate) use checkpoint::*;
|
||||
pub(crate) use conversation::*;
|
||||
pub(crate) use export::*;
|
||||
@@ -22,5 +25,6 @@ pub(crate) use filesystem::*;
|
||||
pub(crate) use manifest::*;
|
||||
pub(crate) use memory::*;
|
||||
pub(crate) use resource_dependency_graph::*;
|
||||
pub(crate) use resource_editor::*;
|
||||
pub(crate) use resource_layout::*;
|
||||
pub(crate) use verification::*;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -59,6 +59,7 @@ fn version_fixture(
|
||||
}],
|
||||
created_reason,
|
||||
created_at: project_revision,
|
||||
edit_prompt: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,8 +41,57 @@ pub(crate) fn is_supported_project_text_resource(path: &str, media_type: &str) -
|
||||
let media_type = media_type.trim().to_ascii_lowercase();
|
||||
matches!(
|
||||
path_extension(path).as_deref(),
|
||||
Some("md" | "markdown" | "mdx" | "txt" | "json" | "yaml" | "yml" | "toml")
|
||||
Some(
|
||||
"md" | "markdown"
|
||||
| "mdx"
|
||||
| "txt"
|
||||
| "json"
|
||||
| "yaml"
|
||||
| "yml"
|
||||
| "toml"
|
||||
| "html"
|
||||
| "htm"
|
||||
| "css"
|
||||
| "scss"
|
||||
| "less"
|
||||
| "js"
|
||||
| "jsx"
|
||||
| "mjs"
|
||||
| "cjs"
|
||||
| "ts"
|
||||
| "tsx"
|
||||
| "rs"
|
||||
| "py"
|
||||
| "go"
|
||||
| "java"
|
||||
| "kt"
|
||||
| "kts"
|
||||
| "c"
|
||||
| "cc"
|
||||
| "cpp"
|
||||
| "h"
|
||||
| "hpp"
|
||||
| "cs"
|
||||
| "swift"
|
||||
| "php"
|
||||
| "rb"
|
||||
| "lua"
|
||||
| "sh"
|
||||
| "bash"
|
||||
| "zsh"
|
||||
| "sql"
|
||||
| "graphql"
|
||||
| "gql"
|
||||
| "xml"
|
||||
| "csv"
|
||||
| "ini"
|
||||
| "conf"
|
||||
| "vue"
|
||||
| "svelte"
|
||||
)
|
||||
) && (media_type.is_empty()
|
||||
|| !media_type.contains('/')
|
||||
|| media_type == "application/octet-stream"
|
||||
|| media_type.starts_with("text/")
|
||||
|| media_type.contains("json")
|
||||
|| media_type.contains("yaml")
|
||||
@@ -76,7 +125,7 @@ pub(crate) fn load_local_project_text_preview(
|
||||
let normalized = normalize_relative_path(relative_path.trim())?;
|
||||
reject_sensitive_project_file_read(&normalized)?;
|
||||
let media_type = project_text_media_type(&normalized)
|
||||
.ok_or_else(|| "文档预览只支持 Markdown、文本、JSON、YAML 和 TOML".to_string())?;
|
||||
.ok_or_else(|| "文档预览只支持现役文本与代码扩展名".to_string())?;
|
||||
let bytes = read_stable_project_resource(
|
||||
root,
|
||||
&normalized,
|
||||
@@ -164,6 +213,16 @@ fn project_text_media_type(path: &str) -> Option<&'static str> {
|
||||
"json" => Some("application/json"),
|
||||
"yaml" | "yml" => Some("application/yaml"),
|
||||
"toml" => Some("application/toml"),
|
||||
"html" | "htm" => Some("text/html"),
|
||||
"css" | "scss" | "less" => Some("text/css"),
|
||||
"js" | "jsx" | "mjs" | "cjs" => Some("text/javascript"),
|
||||
"ts" | "tsx" => Some("text/typescript"),
|
||||
"rs" => Some("text/x-rust"),
|
||||
"py" => Some("text/x-python"),
|
||||
"go" => Some("text/x-go"),
|
||||
"java" | "kt" | "kts" | "c" | "cc" | "cpp" | "h" | "hpp" | "cs" | "swift" | "php"
|
||||
| "rb" | "lua" | "sh" | "bash" | "zsh" | "sql" | "graphql" | "gql" | "xml" | "csv"
|
||||
| "ini" | "conf" | "vue" | "svelte" => Some("text/plain"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -219,7 +278,7 @@ fn detect_project_media_type(
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_safe_svg(bytes: &[u8]) -> Result<(), String> {
|
||||
pub(crate) fn validate_safe_svg(bytes: &[u8]) -> Result<(), String> {
|
||||
let text = std::str::from_utf8(bytes).map_err(|_| "SVG 必须使用 UTF-8 编码".to_string())?;
|
||||
let lower = text.to_ascii_lowercase();
|
||||
if !lower.contains("<svg") {
|
||||
@@ -371,13 +430,19 @@ mod tests {
|
||||
fs::write(root.path().join("docs/design.md"), "# 设计\n\n正文").expect("markdown");
|
||||
fs::write(root.path().join("docs/legacy.txt"), [0xff, 0xfe]).expect("legacy text");
|
||||
fs::write(root.path().join("docs/page.html"), "<h1>unsafe</h1>").expect("html");
|
||||
fs::write(root.path().join("docs/archive.bin"), "not a text resource")
|
||||
.expect("unsupported extension");
|
||||
|
||||
let preview =
|
||||
load_local_project_text_preview(root.path(), "docs/design.md").expect("load markdown");
|
||||
assert_eq!(preview.media_type, "text/markdown");
|
||||
assert!(preview.content.contains("正文"));
|
||||
assert!(load_local_project_text_preview(root.path(), "docs/legacy.txt").is_err());
|
||||
assert!(load_local_project_text_preview(root.path(), "docs/page.html").is_err());
|
||||
let html_preview =
|
||||
load_local_project_text_preview(root.path(), "docs/page.html").expect("load HTML");
|
||||
assert_eq!(html_preview.media_type, "text/html");
|
||||
assert_eq!(html_preview.content, "<h1>unsafe</h1>");
|
||||
assert!(load_local_project_text_preview(root.path(), "docs/archive.bin").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -245,6 +245,7 @@ import {
|
||||
type ProjectAgentResultSummary,
|
||||
type ProjectAgentRuntimeSummary,
|
||||
} from './view/project-development';
|
||||
import type { ProjectManifestSnapshotMetadata } from './view/project-development/projectResourceLiveUpdateModel';
|
||||
|
||||
const initialSupervisorMessageClaimsByPage = new WeakMap<Window, Set<string>>();
|
||||
const LEGACY_GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY =
|
||||
@@ -574,6 +575,7 @@ type AppProps = {
|
||||
onManifestChange?: (
|
||||
projectPath: string,
|
||||
manifest: GameCreationAppManifest,
|
||||
metadata?: ProjectManifestSnapshotMetadata,
|
||||
) => void;
|
||||
onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void;
|
||||
onAgentRuntimeSummariesChange?: (
|
||||
@@ -10593,7 +10595,41 @@ export function App({
|
||||
if (!projectSupervisorOnly || !nextProjectPath || !onManifestChange) {
|
||||
return;
|
||||
}
|
||||
onManifestChange(nextProjectPath, manifest);
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const before = await invoke<LocalGameProjectRevisionStatus>(
|
||||
'get_local_game_project_revision',
|
||||
{ projectPath: nextProjectPath },
|
||||
);
|
||||
const currentManifest = await invoke<GameCreationAppManifest>(
|
||||
'get_local_game_manifest',
|
||||
{ projectPath: nextProjectPath },
|
||||
);
|
||||
const after = await invoke<LocalGameProjectRevisionStatus>(
|
||||
'get_local_game_project_revision',
|
||||
{ projectPath: nextProjectPath },
|
||||
);
|
||||
if (before.revision !== after.revision) {
|
||||
continue;
|
||||
}
|
||||
if (!cancelled) {
|
||||
onManifestChange(nextProjectPath, currentManifest, {
|
||||
projectId: currentManifest.projectId,
|
||||
revision: after.revision,
|
||||
source: 'supervisor',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
})().catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
localProject?.projectPath,
|
||||
manifest,
|
||||
@@ -11151,6 +11187,7 @@ export function App({
|
||||
{runtimeConfigOpen ? (
|
||||
<RuntimeConfigDialog
|
||||
projectPath={localProject?.projectPath}
|
||||
showDeveloperEditorApi={devMode}
|
||||
onClose={() => setRuntimeConfigOpen(false)}
|
||||
onLog={(entry) => setCommandLog((current) => [...current, entry])}
|
||||
/>
|
||||
|
||||
@@ -57,6 +57,7 @@ export type LauncherProjectContext = {
|
||||
projectName: string;
|
||||
projectKind: LocalProjectKind;
|
||||
manifest: GameCreationAppManifest;
|
||||
projectRevision: number | null;
|
||||
mode: HomeAgentMode | null;
|
||||
initialPrompt: string;
|
||||
attachments: LauncherImportedAttachment[];
|
||||
|
||||
@@ -1253,6 +1253,18 @@ export function projectNameFromPath(projectPath: string) {
|
||||
);
|
||||
}
|
||||
|
||||
export function projectWorkspaceStatusForDisplay(workspaceStatus: string) {
|
||||
const openedPrefix = '已打开:';
|
||||
if (!workspaceStatus.startsWith(openedPrefix)) {
|
||||
return workspaceStatus;
|
||||
}
|
||||
const projectPath = workspaceStatus.slice(openedPrefix.length).trim();
|
||||
if (!/[\\/]/u.test(projectPath)) {
|
||||
return workspaceStatus;
|
||||
}
|
||||
return `${openedPrefix}${projectNameFromPath(projectPath)}`;
|
||||
}
|
||||
|
||||
export function mergeProjectSupervisorConversation(
|
||||
projectRecords: LocalConversationMessageRecord[],
|
||||
supervisorRecords: LocalConversationMessageRecord[],
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { Fragment, useCallback, useState } from 'react';
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { launcherNotifications } from '../../app/constants';
|
||||
import { closeDialogOnEscape } from '../../app/dialogs';
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import type { LocalGameProjectRevisionStatus } from '../../app/types';
|
||||
import HomeView from '../../view/home';
|
||||
import { type LauncherView, Sidebar } from '../../view/layout';
|
||||
import ProjectDevelopmentView from '../../view/project-development';
|
||||
import {
|
||||
createProjectManifestMergeState,
|
||||
mergeProjectManifestSnapshot,
|
||||
type ProjectManifestMergeState,
|
||||
type ProjectManifestSnapshot,
|
||||
type ProjectManifestSnapshotMetadata,
|
||||
} from '../../view/project-development/projectResourceLiveUpdateModel';
|
||||
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
|
||||
import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet';
|
||||
import {
|
||||
@@ -56,23 +65,127 @@ export function WorkspaceLauncherShell({
|
||||
createHomeDraft,
|
||||
openProject,
|
||||
} = homeProject;
|
||||
const activeProjectContextRef = useRef(currentProjectContext);
|
||||
const manifestMergeRef = useRef<ProjectManifestMergeState | null>(null);
|
||||
activeProjectContextRef.current = currentProjectContext;
|
||||
|
||||
useEffect(() => {
|
||||
const current = activeProjectContextRef.current;
|
||||
manifestMergeRef.current =
|
||||
current?.projectRevision === null || !current
|
||||
? null
|
||||
: createProjectManifestMergeState({
|
||||
projectPath: current.projectPath,
|
||||
projectId: current.manifest.projectId,
|
||||
revision: current.projectRevision,
|
||||
manifest: current.manifest,
|
||||
source: 'initial',
|
||||
});
|
||||
}, [currentProjectContext?.createdAt, currentProjectContext?.projectPath]);
|
||||
|
||||
const applyManifestSnapshot = useCallback(
|
||||
(snapshot: ProjectManifestSnapshot) => {
|
||||
const current = activeProjectContextRef.current;
|
||||
if (
|
||||
!current ||
|
||||
current.projectPath !== snapshot.projectPath ||
|
||||
current.manifest.projectId !== snapshot.projectId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let previous = manifestMergeRef.current;
|
||||
if (
|
||||
!previous ||
|
||||
previous.projectPath !== current.projectPath ||
|
||||
previous.projectId !== current.manifest.projectId
|
||||
) {
|
||||
previous =
|
||||
current.projectRevision === null
|
||||
? null
|
||||
: createProjectManifestMergeState({
|
||||
projectPath: current.projectPath,
|
||||
projectId: current.manifest.projectId,
|
||||
revision: current.projectRevision,
|
||||
manifest: current.manifest,
|
||||
source: 'initial',
|
||||
});
|
||||
}
|
||||
if (!previous) {
|
||||
manifestMergeRef.current = createProjectManifestMergeState(snapshot);
|
||||
} else {
|
||||
const merged = mergeProjectManifestSnapshot(previous, snapshot);
|
||||
manifestMergeRef.current = merged.state;
|
||||
if (merged.decision !== 'accepted') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setCurrentProjectContext((active) =>
|
||||
active &&
|
||||
active.projectPath === snapshot.projectPath &&
|
||||
active.manifest.projectId === snapshot.projectId
|
||||
? {
|
||||
...active,
|
||||
manifest: snapshot.manifest,
|
||||
projectRevision: snapshot.revision,
|
||||
}
|
||||
: active,
|
||||
);
|
||||
},
|
||||
[setCurrentProjectContext],
|
||||
);
|
||||
const syncActiveProjectManifest = useCallback(
|
||||
(
|
||||
sourceProjectPath: string,
|
||||
manifest: NonNullable<typeof currentProjectContext>['manifest'],
|
||||
metadata?: ProjectManifestSnapshotMetadata,
|
||||
) => {
|
||||
setCurrentProjectContext((current) => {
|
||||
if (
|
||||
!current ||
|
||||
current.projectPath !== sourceProjectPath ||
|
||||
current.manifest === manifest
|
||||
) {
|
||||
return current;
|
||||
const current = activeProjectContextRef.current;
|
||||
if (!current || current.projectPath !== sourceProjectPath) {
|
||||
return;
|
||||
}
|
||||
if (metadata) {
|
||||
applyManifestSnapshot({
|
||||
projectPath: sourceProjectPath,
|
||||
manifest,
|
||||
...metadata,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
if (current.projectRevision === null) {
|
||||
setCurrentProjectContext((active) =>
|
||||
active?.projectPath === sourceProjectPath
|
||||
? { ...active, manifest }
|
||||
: active,
|
||||
);
|
||||
}
|
||||
return { ...current, manifest };
|
||||
});
|
||||
return;
|
||||
}
|
||||
void invoke<LocalGameProjectRevisionStatus>(
|
||||
'get_local_game_project_revision',
|
||||
{ projectPath: sourceProjectPath },
|
||||
)
|
||||
.then((status) => {
|
||||
applyManifestSnapshot({
|
||||
projectPath: sourceProjectPath,
|
||||
projectId: manifest.projectId,
|
||||
revision: status.revision,
|
||||
manifest,
|
||||
source: 'supervisor',
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (activeProjectContextRef.current?.projectRevision === null) {
|
||||
setCurrentProjectContext((active) =>
|
||||
active?.projectPath === sourceProjectPath
|
||||
? { ...active, manifest }
|
||||
: active,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
[setCurrentProjectContext],
|
||||
[applyManifestSnapshot, setCurrentProjectContext],
|
||||
);
|
||||
|
||||
function showLauncherNotice(title: string) {
|
||||
@@ -175,6 +288,7 @@ export function WorkspaceLauncherShell({
|
||||
preview={activeProjectPreview}
|
||||
agentRuntimeSummaries={activeProjectAgentRuntimeSummaries}
|
||||
agentResults={activeProjectAgentResults}
|
||||
onManifestChange={syncActiveProjectManifest}
|
||||
onHomeOpen={() => setLauncherView('home')}
|
||||
onProjectsOpen={() => setLauncherView('projects')}
|
||||
supervisor={
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
ProjectAgentResultSummary,
|
||||
ProjectAgentRuntimeSummary,
|
||||
} from '../../view/project-development';
|
||||
import type { ProjectManifestSnapshotMetadata } from '../../view/project-development/projectResourceLiveUpdateModel';
|
||||
import { isAbsoluteProjectPath } from '../project-summary/projectSummary';
|
||||
|
||||
const RECENT_WORKSPACES_STORAGE_KEY =
|
||||
@@ -38,6 +39,7 @@ export type ProjectSupervisorComponentProps = {
|
||||
onManifestChange?: (
|
||||
projectPath: string,
|
||||
manifest: GameCreationAppManifest,
|
||||
metadata?: ProjectManifestSnapshotMetadata,
|
||||
) => void;
|
||||
onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void;
|
||||
onAgentRuntimeSummariesChange?: (
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
InitLocalProjectResult,
|
||||
LauncherImportedAttachment,
|
||||
LauncherProjectContext,
|
||||
LocalGameProjectRevisionStatus,
|
||||
LocalProjectDirectoryStatus,
|
||||
PendingNonEmptyProject,
|
||||
TauriInvoke,
|
||||
@@ -96,6 +97,23 @@ export function useHomeProjectCreation({
|
||||
rememberRecentWorkspace(context.projectPath);
|
||||
}
|
||||
|
||||
async function readCurrentProjectRevision(
|
||||
invoke: TauriInvoke,
|
||||
nextProjectPath: string,
|
||||
) {
|
||||
try {
|
||||
const status = await invoke<LocalGameProjectRevisionStatus>(
|
||||
'get_local_game_project_revision',
|
||||
{ projectPath: nextProjectPath },
|
||||
);
|
||||
return Number.isSafeInteger(status.revision) && status.revision >= 0
|
||||
? status.revision
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function importHomeAttachments(
|
||||
invoke: TauriInvoke,
|
||||
nextProjectPath: string,
|
||||
@@ -213,6 +231,10 @@ export function useHomeProjectCreation({
|
||||
result.manifest.name || projectNameFromPath(result.projectPath),
|
||||
projectKind: 'web',
|
||||
manifest: result.manifest,
|
||||
projectRevision: await readCurrentProjectRevision(
|
||||
invoke,
|
||||
result.projectPath,
|
||||
),
|
||||
mode,
|
||||
initialPrompt:
|
||||
prompt.trim() ||
|
||||
@@ -277,6 +299,10 @@ export function useHomeProjectCreation({
|
||||
result.manifest.name || projectNameFromPath(result.projectPath),
|
||||
projectKind: 'web',
|
||||
manifest: result.manifest,
|
||||
projectRevision: await readCurrentProjectRevision(
|
||||
invoke,
|
||||
result.projectPath,
|
||||
),
|
||||
mode: null,
|
||||
initialPrompt: '',
|
||||
attachments: [],
|
||||
@@ -338,6 +364,10 @@ export function useHomeProjectCreation({
|
||||
projectNameFromPath(trimmedProjectPath),
|
||||
projectKind: directoryStatus.isGodotProject ? 'godot' : 'web',
|
||||
manifest: projectManifest,
|
||||
projectRevision: await readCurrentProjectRevision(
|
||||
invoke,
|
||||
trimmedProjectPath,
|
||||
),
|
||||
mode: null,
|
||||
initialPrompt: '',
|
||||
attachments: [],
|
||||
@@ -481,6 +511,10 @@ export function useHomeProjectCreation({
|
||||
result.manifest.name || projectNameFromPath(result.projectPath),
|
||||
projectKind: 'godot',
|
||||
manifest: result.manifest,
|
||||
projectRevision: await readCurrentProjectRevision(
|
||||
invoke,
|
||||
result.projectPath,
|
||||
),
|
||||
mode: null,
|
||||
initialPrompt: '',
|
||||
attachments: [],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user