game agent无限画布开发 #136

Merged
kdletters merged 73 commits from codex/infinite-canvas into master 2026-08-12 10:54:16 +08:00
147 changed files with 36425 additions and 4963 deletions
@@ -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
View File
@@ -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
@@ -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(&current),
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]
+38 -1
View File
@@ -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