4930 lines
187 KiB
Rust
4930 lines
187 KiB
Rust
use super::*;
|
||
use crate::agent::read_direct_project_chat_history_at;
|
||
use crate::ui_editor::resource::font::FontAsset;
|
||
use sha2::{Digest, Sha256};
|
||
use std::collections::{BTreeMap, HashSet};
|
||
|
||
const UI_EDITOR_FONT_MAX_FILE_SIZE: u64 = 8 * 1024 * 1024;
|
||
const UI_EDITOR_FONT_MAX_TOTAL_SIZE: u64 = 32 * 1024 * 1024;
|
||
const UI_EDITOR_FONT_MAX_COUNT: usize = 64;
|
||
const UI_EDITOR_IMAGE_MAX_FILE_SIZE: u64 = 20 * 1024 * 1024;
|
||
const UI_EDITOR_IMAGE_MAX_TOTAL_SIZE: u64 = 256 * 1024 * 1024;
|
||
const UI_EDITOR_IMAGE_MAX_COUNT: usize = 100;
|
||
// Agent 查询/导入账户素材使用比 UI 更窄的投影和同一批次上限。原始 objectKey、
|
||
// imageSrc 与签名地址只在本文件的客户端下载阶段存在,绝不进入 Agent observation。
|
||
const AGENT_EDITOR_ASSET_LIBRARY_MAX_ITEMS: usize = 500;
|
||
const AGENT_EDITOR_ASSET_ID_MAX_CHARS: usize = 512;
|
||
const AUTOMATIC_PROJECT_NAME_MAX_PROMPT_CHARS: usize = 8_000;
|
||
const AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS: u32 = 64;
|
||
const AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT: &str =
|
||
include_str!("../prompts/automatic-project-name.md");
|
||
|
||
fn is_chinese_project_name_character(value: char) -> bool {
|
||
matches!(
|
||
value,
|
||
'\u{3400}'..='\u{4DBF}'
|
||
| '\u{4E00}'..='\u{9FFF}'
|
||
| '\u{F900}'..='\u{FAFF}'
|
||
| '\u{20000}'..='\u{2FA1F}'
|
||
| '\u{30000}'..='\u{323AF}'
|
||
)
|
||
}
|
||
|
||
pub(crate) fn normalize_suggested_project_name(value: &str) -> Option<String> {
|
||
let value = value.trim();
|
||
let character_count = value.chars().count();
|
||
if !(2..=16).contains(&character_count) || !value.chars().all(is_chinese_project_name_character)
|
||
{
|
||
return None;
|
||
}
|
||
normalize_game_creation_project_name(value).ok()
|
||
}
|
||
|
||
pub(crate) fn build_automatic_project_name_prompt(prompt: &str) -> Result<String, String> {
|
||
let prompt = prompt.trim();
|
||
if prompt.is_empty() {
|
||
return Err("首页创作需求为空,不能提炼项目名称".to_string());
|
||
}
|
||
Ok(prompt
|
||
.chars()
|
||
.take(AUTOMATIC_PROJECT_NAME_MAX_PROMPT_CHARS)
|
||
.collect())
|
||
}
|
||
|
||
async fn request_automatic_project_name(prompt: &str) -> Result<Option<String>, String> {
|
||
let user_prompt = build_automatic_project_name_prompt(prompt)?;
|
||
let app_config = load_game_creator_app_config()?;
|
||
if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER {
|
||
let reply = crate::agent::direct_game_creator_home_codex_chat(
|
||
AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT.trim().to_string(),
|
||
user_prompt,
|
||
)
|
||
.await?;
|
||
return Ok(normalize_suggested_project_name(reply.trim()));
|
||
}
|
||
let mut llm = app_config.llm.clone();
|
||
llm.max_retries = 0;
|
||
let client = build_game_creator_llm_client_from_llm_config(&llm, "llm")?;
|
||
let request =
|
||
LlmRunRequest::single_turn(AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT.trim(), user_prompt)
|
||
.with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?)
|
||
.with_model(llm.model.clone())
|
||
.with_request_timeout_ms(llm.request_timeout_ms)
|
||
.with_max_output_tokens(AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS)
|
||
.with_web_search(false);
|
||
let response = client
|
||
.run(request)
|
||
.await
|
||
.map_err(|error| format!("自动项目命名失败:{error}"))?;
|
||
Ok(normalize_suggested_project_name(response.text.trim()))
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct AssetImportRequirements {
|
||
max_items: usize,
|
||
max_file_size_bytes: Option<u64>,
|
||
max_total_size_bytes: Option<u64>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct AssetImportPolicy {
|
||
destination: String,
|
||
accepted_media_types: Vec<String>,
|
||
accepted_extensions: Vec<String>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(
|
||
tag = "source",
|
||
rename_all = "camelCase",
|
||
rename_all_fields = "camelCase"
|
||
)]
|
||
pub(crate) enum UiEditorAssetImportRequest {
|
||
Local {
|
||
project_path: String,
|
||
local_source_paths: Vec<String>,
|
||
local_policy: AssetImportPolicy,
|
||
local_requirements: AssetImportRequirements,
|
||
},
|
||
Remote {
|
||
project_path: String,
|
||
remote_assets: Vec<serde_json::Value>,
|
||
remote_policy: AssetImportPolicy,
|
||
remote_requirements: AssetImportRequirements,
|
||
},
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod ui_editor_asset_import_request_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn local_request_accepts_tauri_camel_case_fields() {
|
||
let request = serde_json::from_value::<UiEditorAssetImportRequest>(serde_json::json!({
|
||
"source": "local",
|
||
"projectPath": "/project",
|
||
"localSourcePaths": ["/source/font.ttf"],
|
||
"localPolicy": {
|
||
"destination": "assets/fonts",
|
||
"acceptedMediaTypes": ["font/ttf"],
|
||
"acceptedExtensions": ["ttf"]
|
||
},
|
||
"localRequirements": { "maxItems": 64 }
|
||
}))
|
||
.expect("deserialize local import request");
|
||
|
||
assert!(matches!(request, UiEditorAssetImportRequest::Local { .. }));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn asset_import_async_command_completes_local_file_import() {
|
||
let project = tempfile::tempdir().expect("create project directory");
|
||
let root = project.path();
|
||
init_local_game_project_at(root, "ui-editor-import", "UI 编辑器导入测试")
|
||
.expect("initialize project");
|
||
let source = tempfile::NamedTempFile::new().expect("create image source");
|
||
fs::write(
|
||
source.path(),
|
||
[
|
||
0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n', 0, 0, 0, 0,
|
||
],
|
||
)
|
||
.expect("write image source");
|
||
|
||
let result = import_ui_editor_assets(UiEditorAssetImportRequest::Local {
|
||
project_path: root.to_string_lossy().into_owned(),
|
||
local_source_paths: vec![source.path().to_string_lossy().into_owned()],
|
||
local_policy: AssetImportPolicy {
|
||
destination: "assets/uploads".to_string(),
|
||
accepted_media_types: vec!["image/png".to_string()],
|
||
accepted_extensions: vec!["png".to_string()],
|
||
},
|
||
local_requirements: AssetImportRequirements {
|
||
max_items: 1,
|
||
max_file_size_bytes: Some(1024),
|
||
max_total_size_bytes: Some(1024),
|
||
},
|
||
})
|
||
.await
|
||
.expect("import local image through blocking worker");
|
||
|
||
assert_eq!(result.assets.len(), 1);
|
||
assert!(root.join(&result.assets[0].local_path).is_file());
|
||
}
|
||
}
|
||
|
||
fn validate_asset_import_policy(policy: &AssetImportPolicy) -> Result<bool, String> {
|
||
let normalized_destination = policy.destination.trim();
|
||
let is_font = normalized_destination == "assets/fonts";
|
||
let is_image = normalized_destination == "assets/uploads";
|
||
if !is_font && !is_image {
|
||
return Err("不支持的资产导入目录".to_string());
|
||
}
|
||
let allowed_media_types = if is_font {
|
||
["font/ttf", "font/otf", "font/woff", "font/woff2"]
|
||
.into_iter()
|
||
.collect::<std::collections::BTreeSet<_>>()
|
||
} else {
|
||
["image/png", "image/jpeg", "image/webp"]
|
||
.into_iter()
|
||
.collect::<std::collections::BTreeSet<_>>()
|
||
};
|
||
if policy
|
||
.accepted_media_types
|
||
.iter()
|
||
.any(|value| !allowed_media_types.contains(value.trim().to_ascii_lowercase().as_str()))
|
||
{
|
||
return Err("导入策略包含不支持的媒体类型".to_string());
|
||
}
|
||
let allowed_extensions = if is_font {
|
||
["ttf", "otf", "woff", "woff2"]
|
||
.into_iter()
|
||
.collect::<std::collections::BTreeSet<_>>()
|
||
} else {
|
||
["png", "jpg", "jpeg", "webp"]
|
||
.into_iter()
|
||
.collect::<std::collections::BTreeSet<_>>()
|
||
};
|
||
if policy.accepted_extensions.iter().any(|value| {
|
||
!allowed_extensions.contains(
|
||
value
|
||
.trim()
|
||
.trim_start_matches('.')
|
||
.to_ascii_lowercase()
|
||
.as_str(),
|
||
)
|
||
}) {
|
||
return Err("导入策略包含不支持的文件扩展名".to_string());
|
||
}
|
||
Ok(is_font)
|
||
}
|
||
|
||
fn validate_local_asset_import_requirements(
|
||
source_paths: &[String],
|
||
requirements: &AssetImportRequirements,
|
||
) -> Result<u64, String> {
|
||
if source_paths.len() > requirements.max_items {
|
||
return Err(format!("最多导入 {} 个文件", requirements.max_items));
|
||
}
|
||
let mut total_size = 0u64;
|
||
for source in source_paths {
|
||
let path = Path::new(source.trim());
|
||
// Run the explicit user-selection ACL preparation before any size/type
|
||
// preflight. On Windows, metadata traversal can itself fail with
|
||
// ERROR_ACCESS_DENIED; doing this only in the later import worker would
|
||
// leave the early validation path unable to reach the one-shot UAC
|
||
// repair entry.
|
||
crate::prepare_game_creator_user_selected_path_for_read(path, false, "本地导入文件")?;
|
||
let metadata = fs::symlink_metadata(path).map_err(|_| "读取本地文件失败".to_string())?;
|
||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||
return Err("只能导入普通文件".to_string());
|
||
}
|
||
if let Some(max_size) = requirements.max_file_size_bytes {
|
||
if metadata.len() > max_size {
|
||
return Err(format!("文件超过 {} 字节限制", max_size));
|
||
}
|
||
}
|
||
total_size = total_size.saturating_add(metadata.len());
|
||
}
|
||
if let Some(max_total_size) = requirements.max_total_size_bytes {
|
||
if total_size > max_total_size {
|
||
return Err(format!("文件总量超过 {} 字节限制", max_total_size));
|
||
}
|
||
}
|
||
Ok(total_size)
|
||
}
|
||
|
||
fn validate_remote_asset_import_requirements(
|
||
assets: &[serde_json::Value],
|
||
requirements: &AssetImportRequirements,
|
||
) -> Result<(), String> {
|
||
if assets.len() > requirements.max_items {
|
||
return Err(format!("最多导入 {} 个文件", requirements.max_items));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn import_ui_editor_assets(
|
||
request: UiEditorAssetImportRequest,
|
||
) -> Result<LocalImportResult, String> {
|
||
match request {
|
||
UiEditorAssetImportRequest::Local {
|
||
project_path,
|
||
local_source_paths,
|
||
local_policy,
|
||
local_requirements,
|
||
} => {
|
||
let is_font = validate_asset_import_policy(&local_policy)?;
|
||
validate_local_asset_import_requirements(&local_source_paths, &local_requirements)?;
|
||
tokio::task::spawn_blocking(move || {
|
||
if is_font {
|
||
import_ui_editor_local_fonts(project_path, local_source_paths)
|
||
} else {
|
||
import_ui_editor_local_files(project_path, local_source_paths)
|
||
}
|
||
})
|
||
.await
|
||
.map_err(|error| format!("UI 编辑器素材导入任务意外终止:{error}"))?
|
||
}
|
||
UiEditorAssetImportRequest::Remote {
|
||
project_path,
|
||
remote_assets,
|
||
remote_policy,
|
||
remote_requirements,
|
||
} => {
|
||
let is_font = validate_asset_import_policy(&remote_policy)?;
|
||
if is_font {
|
||
return Err("字体导入不支持云端素材".to_string());
|
||
}
|
||
validate_remote_asset_import_requirements(&remote_assets, &remote_requirements)?;
|
||
import_ui_editor_remote_assets(project_path, remote_assets).await
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||
struct HydratePlanGddStateInput {
|
||
project_path: String,
|
||
}
|
||
|
||
fn parse_hydrate_plan_gdd_state_input(
|
||
request: &tauri::ipc::Request<'_>,
|
||
) -> Result<HydratePlanGddStateInput, String> {
|
||
match request.body() {
|
||
tauri::ipc::InvokeBody::Json(value) => serde_json::from_value(value.clone())
|
||
.map_err(|error| format!("hydrate_game_creator_plan_gdd_state 输入无效:{error}")),
|
||
tauri::ipc::InvokeBody::Raw(_) => {
|
||
Err("hydrate_game_creator_plan_gdd_state 只接受 JSON 输入 {projectPath}".to_string())
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(crate) fn closest_existing_project_picker_directory(path: &Path) -> Option<PathBuf> {
|
||
let mut candidate = if path.exists() && path.is_dir() {
|
||
Some(path)
|
||
} else {
|
||
path.parent()
|
||
};
|
||
while let Some(directory) = candidate {
|
||
if directory.is_dir() {
|
||
return Some(directory.to_path_buf());
|
||
}
|
||
candidate = directory.parent();
|
||
}
|
||
None
|
||
}
|
||
|
||
const AUTOMATIC_PROJECTS_DIRECTORY_NAME: &str = "Genarrative GameAgent";
|
||
|
||
fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||
app.path()
|
||
.document_dir()
|
||
.map(|documents_root| documents_root.join(AUTOMATIC_PROJECTS_DIRECTORY_NAME))
|
||
.map_err(|error| format!("无法读取系统文档目录:{error}"))
|
||
}
|
||
|
||
pub(crate) fn create_automatic_local_game_project_at(
|
||
projects_root: &Path,
|
||
requested_name: Option<&str>,
|
||
) -> Result<InitLocalProjectResult, String> {
|
||
let requested_name = requested_name
|
||
.map(normalize_game_creation_project_name)
|
||
.transpose()?;
|
||
if projects_root.as_os_str().is_empty() || !projects_root.is_absolute() {
|
||
return Err("自动工作区根目录必须是绝对路径".to_string());
|
||
}
|
||
ensure_game_creator_private_directory_tree(projects_root, "自动工作区根目录")?;
|
||
prepare_game_creator_private_path_for_read(projects_root, true, "自动工作区根目录")?;
|
||
let metadata = fs::symlink_metadata(projects_root).map_err(|error| {
|
||
format!(
|
||
"读取自动工作区根目录失败:{}: {error}",
|
||
projects_root.display()
|
||
)
|
||
})?;
|
||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||
return Err("自动工作区根目录必须是普通文件夹".to_string());
|
||
}
|
||
|
||
for _ in 0..16 {
|
||
let workspace_id = uuid::Uuid::new_v4().simple().to_string();
|
||
let short_id = &workspace_id[..8];
|
||
let project_name = requested_name
|
||
.clone()
|
||
.unwrap_or_else(|| format!("GameAgent 项目 {short_id}"));
|
||
let project_root = projects_root.join(format!("gameagent-{short_id}"));
|
||
match fs::create_dir(&project_root) {
|
||
Ok(()) => {
|
||
let result = (|| {
|
||
prepare_game_creator_private_path_for_read(
|
||
&project_root,
|
||
true,
|
||
"自动项目目录",
|
||
)?;
|
||
enforce_project_permission_policy(&project_root, "project.create")?;
|
||
let _lock = acquire_project_write_lock(&project_root, "project.create")?;
|
||
init_local_game_project_at(
|
||
&project_root,
|
||
&format!("gameagent-{workspace_id}"),
|
||
&project_name,
|
||
)
|
||
})();
|
||
if result.is_err() {
|
||
let _ = fs::remove_dir_all(&project_root);
|
||
}
|
||
return result;
|
||
}
|
||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||
Err(error) => {
|
||
return Err(format!(
|
||
"创建自动工作区失败:{}: {error}",
|
||
project_root.display()
|
||
));
|
||
}
|
||
}
|
||
}
|
||
Err("自动工作区命名冲突,请重试".to_string())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn create_automatic_local_game_project(
|
||
app: tauri::AppHandle,
|
||
name: Option<String>,
|
||
) -> Result<InitLocalProjectResult, String> {
|
||
create_automatic_local_game_project_at(
|
||
&automatic_local_game_projects_root(&app)?,
|
||
name.as_deref(),
|
||
)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn init_local_game_project(
|
||
project_path: String,
|
||
project_id: String,
|
||
name: String,
|
||
) -> Result<InitLocalProjectResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "project.create")?;
|
||
let _lock = acquire_project_write_lock(root, "project.create")?;
|
||
init_local_game_project_at(root, project_id.trim(), name.trim())
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct RenameLocalProjectResult {
|
||
pub(crate) manifest: GameCreationAppManifest,
|
||
pub(crate) revision: u64,
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn rename_local_game_project(
|
||
project_path: String,
|
||
name: String,
|
||
) -> Result<RenameLocalProjectResult, String> {
|
||
let next_name = normalize_game_creation_project_name(name.trim())?;
|
||
let root = validated_local_project_directory_path(project_path.trim())?;
|
||
enforce_project_permission_policy(&root, "project.rename")?;
|
||
let _lock = acquire_project_write_lock(&root, "project.rename")?;
|
||
let current = read_existing_manifest_for_project(&root)?;
|
||
if current.name == next_name {
|
||
return Ok(RenameLocalProjectResult {
|
||
manifest: current,
|
||
revision: read_game_creator_agent_runtime_project_revision(&root)?.revision,
|
||
});
|
||
}
|
||
|
||
let manifest = mutate_manifest_at(&root, |manifest| {
|
||
manifest.name = next_name;
|
||
Ok(manifest.clone())
|
||
})?;
|
||
Ok(RenameLocalProjectResult {
|
||
manifest,
|
||
revision: read_game_creator_agent_runtime_project_revision(&root)?.revision,
|
||
})
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn import_local_godot_project(
|
||
project_path: String,
|
||
project_id: String,
|
||
name: String,
|
||
) -> Result<InitLocalProjectResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "project.create")?;
|
||
if discover_local_godot_project_root(root)?.is_none() {
|
||
return Err("所选工作区未在根目录或一层子目录发现有效的普通文件 project.godot".to_string());
|
||
}
|
||
let _lock = acquire_project_write_lock(root, "project.create")?;
|
||
import_local_godot_project_at(root, project_id.trim(), name.trim())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn is_local_project_directory_non_empty(project_path: String) -> Result<bool, String> {
|
||
let root = Path::new(project_path.trim());
|
||
if root.as_os_str().is_empty() {
|
||
return Err("项目目录不能为空".to_string());
|
||
}
|
||
if !root.is_absolute() {
|
||
return Err("项目目录必须是绝对路径".to_string());
|
||
}
|
||
if project_path_has_control_chars(root) {
|
||
return Err("项目目录不能包含控制字符".to_string());
|
||
}
|
||
crate::prepare_game_creator_project_root_for_read(root, true, "项目目录")?;
|
||
if !root.exists() {
|
||
return Ok(false);
|
||
}
|
||
if !root.is_dir() {
|
||
return Err("项目目录已存在但不是文件夹".to_string());
|
||
}
|
||
fs::read_dir(root)
|
||
.map_err(|error| format!("读取项目目录失败:{}: {error}", root.display()))?
|
||
.next()
|
||
.transpose()
|
||
.map_err(|error| format!("读取项目目录失败:{}: {error}", root.display()))
|
||
.map(|entry| entry.is_some())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn inspect_local_project_directory(
|
||
project_path: String,
|
||
) -> Result<LocalProjectDirectoryStatus, String> {
|
||
let root = Path::new(project_path.trim());
|
||
if root.as_os_str().is_empty() {
|
||
return Err("项目目录不能为空".to_string());
|
||
}
|
||
if !root.is_absolute() {
|
||
return Err("项目目录必须是绝对路径".to_string());
|
||
}
|
||
if project_path_has_control_chars(root) {
|
||
return Err("项目目录不能包含控制字符".to_string());
|
||
}
|
||
match fs::symlink_metadata(root) {
|
||
Ok(metadata) if metadata.is_dir() => {
|
||
crate::prepare_game_creator_project_root_for_read(root, true, "项目目录")?;
|
||
}
|
||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
|
||
crate::prepare_game_creator_project_root_for_read(root, true, "项目目录")?;
|
||
}
|
||
_ => {}
|
||
}
|
||
let recent_run_trace = recent_game_creator_run_trace(root);
|
||
let godot_project_root = discover_local_godot_project_root(root)?;
|
||
Ok(LocalProjectDirectoryStatus {
|
||
project_path: root.to_string_lossy().into_owned(),
|
||
exists: root.exists(),
|
||
is_directory: root.is_dir(),
|
||
is_game_creator_project: is_game_creator_project_directory(root),
|
||
is_godot_project: godot_project_root.is_some(),
|
||
godot_project_root,
|
||
project_name: game_creator_project_name(root),
|
||
modified_at: project_directory_modified_at(root),
|
||
manifest_error: game_creator_project_manifest_error(root),
|
||
recent_run_status: recent_run_trace.as_ref().map(|trace| trace.status.clone()),
|
||
recent_run_stop_reason: recent_run_trace.map(|trace| trace.stop_reason),
|
||
})
|
||
}
|
||
|
||
fn project_directory_modified_at(root: &Path) -> Option<u64> {
|
||
root.metadata()
|
||
.ok()?
|
||
.modified()
|
||
.ok()?
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.ok()
|
||
.map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
|
||
}
|
||
|
||
pub(crate) fn is_game_creator_project_directory(root: &Path) -> bool {
|
||
if !root.is_dir() {
|
||
return false;
|
||
}
|
||
let manifest_path = root.join(".agent/manifest.json");
|
||
read_manifest(&manifest_path).is_ok()
|
||
}
|
||
|
||
pub(crate) fn game_creator_project_name(root: &Path) -> Option<String> {
|
||
let manifest_path = root.join(".agent/manifest.json");
|
||
let manifest = read_manifest(&manifest_path).ok()?;
|
||
let name = manifest.name.trim();
|
||
if name.is_empty() {
|
||
None
|
||
} else {
|
||
Some(name.to_string())
|
||
}
|
||
}
|
||
|
||
pub(crate) fn game_creator_project_manifest_error(root: &Path) -> Option<String> {
|
||
if !root.is_dir() {
|
||
return None;
|
||
}
|
||
let manifest_path = root.join(".agent/manifest.json");
|
||
if !manifest_storage_exists(&manifest_path).unwrap_or(true) {
|
||
return None;
|
||
}
|
||
read_manifest(&manifest_path).err()
|
||
}
|
||
|
||
pub(crate) fn recent_game_creator_run_trace(root: &Path) -> Option<GameCreationAgentRunTrace> {
|
||
let trace_path = root.join(".agent/run.latest.json");
|
||
let content =
|
||
crate::read_game_creator_private_file_to_string(&trace_path, "最近运行状态", 256 * 1024)
|
||
.ok()?;
|
||
serde_json::from_str::<GameCreationAgentRunTrace>(&content).ok()
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn pick_local_project_directory(
|
||
app: tauri::AppHandle,
|
||
initial_path: Option<String>,
|
||
) -> Result<Option<String>, String> {
|
||
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||
let mut dialog = app.dialog().file().set_title("选择游戏项目目录");
|
||
if let Some(initial_path) = initial_path
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|path| !path.is_empty())
|
||
{
|
||
let initial_path = Path::new(initial_path);
|
||
if initial_path.is_absolute() && !project_path_has_control_chars(initial_path) {
|
||
let starting_directory = closest_existing_project_picker_directory(initial_path);
|
||
if let Some(starting_directory) = starting_directory {
|
||
dialog = dialog.set_directory(starting_directory);
|
||
}
|
||
}
|
||
}
|
||
if let Some(window) = app.get_webview_window("client") {
|
||
dialog = dialog.set_parent(&window);
|
||
}
|
||
dialog.pick_folder(move |path| {
|
||
let _ = sender.send(path);
|
||
});
|
||
let Some(path) = receiver
|
||
.await
|
||
.map_err(|_| "项目目录选择器意外关闭".to_string())?
|
||
else {
|
||
return Ok(None);
|
||
};
|
||
let path = path
|
||
.into_path()
|
||
.map_err(|error| format!("读取项目目录失败:{error}"))?;
|
||
#[cfg(windows)]
|
||
crate::register_game_creator_user_selected_path(&path, true);
|
||
// The native picker is the explicit user-selection boundary. Prepare the
|
||
// selected root before returning it so inspect/create/open never races the
|
||
// first ACL read.
|
||
if let Err(error) =
|
||
crate::prepare_game_creator_project_root_for_read(&path, true, "用户选择项目目录")
|
||
{
|
||
#[cfg(windows)]
|
||
crate::revoke_game_creator_user_selected_path(&path);
|
||
return Err(error);
|
||
}
|
||
Ok(Some(path.to_string_lossy().into_owned()))
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn pick_local_file(app: tauri::AppHandle) -> Result<Option<String>, String> {
|
||
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||
let mut dialog = app.dialog().file().set_title("选择本地文件");
|
||
if let Some(window) = app.get_webview_window("client") {
|
||
dialog = dialog.set_parent(&window);
|
||
}
|
||
dialog.pick_file(move |path| {
|
||
let _ = sender.send(path);
|
||
});
|
||
let Some(path) = receiver
|
||
.await
|
||
.map_err(|_| "本地文件选择器意外关闭".to_string())?
|
||
else {
|
||
return Ok(None);
|
||
};
|
||
let path = path
|
||
.into_path()
|
||
.map_err(|error| format!("读取本地文件失败:{error}"))?;
|
||
#[cfg(windows)]
|
||
crate::register_game_creator_user_selected_path(&path, false);
|
||
if let Err(error) =
|
||
crate::prepare_game_creator_user_selected_path_for_read(&path, false, "用户选择文件")
|
||
{
|
||
#[cfg(windows)]
|
||
crate::revoke_game_creator_user_selected_path(&path);
|
||
return Err(error);
|
||
}
|
||
Ok(Some(path.to_string_lossy().into_owned()))
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn open_local_project_directory(
|
||
app: tauri::AppHandle,
|
||
project_path: String,
|
||
) -> Result<(), String> {
|
||
let path = validated_local_project_directory_path(project_path.trim())?;
|
||
app.opener()
|
||
.open_path(path.to_string_lossy().into_owned(), None::<&str>)
|
||
.map_err(|error| format!("打开项目目录失败:{error}"))
|
||
}
|
||
|
||
pub(crate) fn validated_local_project_directory_path(
|
||
project_path: &str,
|
||
) -> Result<PathBuf, String> {
|
||
let path = Path::new(project_path);
|
||
if path.as_os_str().is_empty() {
|
||
return Err("项目目录不能为空".to_string());
|
||
}
|
||
if !path.is_absolute() {
|
||
return Err("项目目录必须是绝对路径".to_string());
|
||
}
|
||
if project_path_has_control_chars(path) {
|
||
return Err("项目目录不能包含控制字符".to_string());
|
||
}
|
||
if !path.exists() {
|
||
return Err("项目目录不存在".to_string());
|
||
}
|
||
if !path.is_dir() {
|
||
return Err("项目路径不是文件夹".to_string());
|
||
}
|
||
crate::prepare_game_creator_project_root_for_read(path, true, "项目目录")?;
|
||
Ok(path.to_path_buf())
|
||
}
|
||
|
||
/// Open the approved Fast GDD Markdown in whatever application the OS has
|
||
/// registered for it.
|
||
///
|
||
/// The GDD is the one product artifact the 立项策划 lane hands back, and it is
|
||
/// already on disk — `plan.submit_gdd` renders `game/fast_gdd.md` and the
|
||
/// approval receipt re-renders it with the approved header. This command only
|
||
/// hands that existing path to the shell; it never creates or rewrites it.
|
||
#[tauri::command]
|
||
pub(crate) fn open_local_project_plan_gdd_markdown(
|
||
app: tauri::AppHandle,
|
||
project_path: String,
|
||
) -> Result<(), String> {
|
||
let path = validated_local_project_plan_gdd_markdown_path(project_path.trim())?;
|
||
app.opener()
|
||
.open_path(path.to_string_lossy().into_owned(), None::<&str>)
|
||
.map_err(|error| format!("打开 Fast GDD 文件失败:{error}"))
|
||
}
|
||
|
||
pub(crate) fn validated_local_project_plan_gdd_markdown_path(
|
||
project_path: &str,
|
||
) -> Result<PathBuf, String> {
|
||
let root = validated_local_project_directory_path(project_path)?;
|
||
// `resolve_local_project_path` 是项目内路径的唯一安全入口:它做根校验、相对路径
|
||
// 归一化,并逐段拒绝符号链接。这里的相对路径是常量,但仍然走它——GDD 的渲染侧
|
||
// (`planning_storage`)用的也是同一个解析器,两边对「项目内的这个文件」必须是
|
||
// 同一个判定,不能一边解析一边拼字符串。
|
||
let path = resolve_local_project_path(&root, PLAN_FAST_GDD_PATH)?;
|
||
match fs::symlink_metadata(&path) {
|
||
Ok(metadata) if metadata.file_type().is_file() => Ok(path),
|
||
Ok(_) => Err("Fast GDD 产物不是普通文件".to_string()),
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||
Err("Fast GDD 产物尚未生成,请先完成立项策划审批".to_string())
|
||
}
|
||
Err(error) => Err(format!("读取 Fast GDD 产物失败:{error}")),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod plan_gdd_markdown_path_tests {
|
||
use super::*;
|
||
|
||
fn fixture() -> tempfile::TempDir {
|
||
let temporary = tempfile::tempdir().expect("create GDD path fixture");
|
||
crate::project::init_local_game_project_at(
|
||
&temporary.path().join("project"),
|
||
"gdd-open",
|
||
"打开 GDD 产物",
|
||
)
|
||
.expect("initialize GDD path fixture");
|
||
temporary
|
||
}
|
||
|
||
#[test]
|
||
fn resolves_the_rendered_markdown_under_the_project_root() {
|
||
let temporary = fixture();
|
||
let root = temporary.path().join("project");
|
||
fs::create_dir_all(root.join("game")).expect("create game directory");
|
||
fs::write(root.join(PLAN_FAST_GDD_PATH), "# Fast GDD").expect("render markdown");
|
||
|
||
let resolved =
|
||
validated_local_project_plan_gdd_markdown_path(&root.to_string_lossy().into_owned())
|
||
.expect("resolve rendered markdown");
|
||
|
||
assert_eq!(resolved, root.join(PLAN_FAST_GDD_PATH));
|
||
}
|
||
|
||
#[test]
|
||
fn refuses_to_open_a_markdown_that_has_not_been_rendered_yet() {
|
||
// 恢复态下 `plan.submit_gdd` 的 Markdown 渲染可能还没落盘。这时按钮必须给出
|
||
// 明确原因,而不是把一个不存在的路径丢给 shell 由系统弹一个无从解释的错误。
|
||
let temporary = fixture();
|
||
let root = temporary.path().join("project");
|
||
|
||
let error =
|
||
validated_local_project_plan_gdd_markdown_path(&root.to_string_lossy().into_owned())
|
||
.expect_err("missing markdown must fail closed");
|
||
|
||
assert!(error.contains("尚未生成"), "unexpected error: {error}");
|
||
}
|
||
|
||
#[test]
|
||
fn refuses_a_project_path_that_is_not_an_initialized_project() {
|
||
let temporary = tempfile::tempdir().expect("create bare fixture");
|
||
let error = validated_local_project_plan_gdd_markdown_path(
|
||
&temporary.path().to_string_lossy().into_owned(),
|
||
)
|
||
.expect_err("a directory without .agent is not a project root");
|
||
assert!(!error.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn refuses_a_relative_project_path() {
|
||
let error = validated_local_project_plan_gdd_markdown_path("relative/project")
|
||
.expect_err("relative project path must fail");
|
||
assert!(error.contains("绝对路径"), "unexpected error: {error}");
|
||
}
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn get_local_game_manifest(
|
||
project_path: String,
|
||
command_id: Option<String>,
|
||
) -> Result<GameCreationAppManifest, String> {
|
||
let root = Path::new(project_path.trim());
|
||
let command_id = command_id
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.unwrap_or("project.status");
|
||
if !matches!(
|
||
command_id,
|
||
"project.status" | "asset.list" | "task.list" | "agent.audit"
|
||
) {
|
||
return Err(format!("不支持通过 manifest 执行命令:{command_id}"));
|
||
}
|
||
enforce_project_permission_policy(root, command_id)?;
|
||
read_manifest_for_project_with_godot_root_calibration(root)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn read_local_project_resource_canvas_layout(
|
||
project_path: String,
|
||
mode: ProjectResourceCanvasLayoutMode,
|
||
) -> Result<ProjectResourceCanvasLayout, String> {
|
||
read_project_resource_canvas_layout_at(Path::new(project_path.trim()), mode)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn read_local_project_resource_graph(
|
||
project_path: String,
|
||
expected_project_id: String,
|
||
resources: Vec<ProjectResourceGraphNodeInput>,
|
||
) -> Result<ProjectResourceGraphReadModel, String> {
|
||
let root = validated_local_project_directory_path(project_path.trim())?;
|
||
enforce_project_auto_permission_policy(&root, "asset.list")?;
|
||
read_project_resource_graph_at(&root, expected_project_id.trim(), resources)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn update_local_project_resource_canvas_layout(
|
||
project_path: String,
|
||
expected_project_id: String,
|
||
mode: ProjectResourceCanvasLayoutMode,
|
||
expected_revision: u64,
|
||
positions: Vec<ProjectResourceCanvasPosition>,
|
||
) -> Result<UpdateProjectResourceCanvasLayoutResult, String> {
|
||
update_project_resource_canvas_layout_at(
|
||
Path::new(project_path.trim()),
|
||
mode,
|
||
&expected_project_id,
|
||
expected_revision,
|
||
positions,
|
||
)
|
||
}
|
||
|
||
#[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 acknowledge_local_project_asset_canvas_candidate_layers(
|
||
input: AcknowledgeAssetCanvasCandidateLayersInput,
|
||
) -> Result<AcknowledgeAssetCanvasCandidateLayersResult, String> {
|
||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||
acknowledge_candidate_layers_at(&root, &input)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn import_local_project_asset_canvas_images(
|
||
app: tauri::AppHandle,
|
||
input: ImportAssetCanvasImagesInput,
|
||
) -> Result<ImportAssetCanvasImagesResult, String> {
|
||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||
let mut dialog = app
|
||
.dialog()
|
||
.file()
|
||
.set_title("导入图片")
|
||
.add_filter("图片", &["png", "jpg", "jpeg", "webp"]);
|
||
if let Some(window) = app.get_webview_window("client") {
|
||
dialog = dialog.set_parent(&window);
|
||
}
|
||
dialog.pick_files(move |paths| {
|
||
let _ = sender.send(paths);
|
||
});
|
||
let Some(paths) = receiver
|
||
.await
|
||
.map_err(|_| "图片文件选择器意外关闭".to_string())?
|
||
else {
|
||
return Ok(ImportAssetCanvasImagesResult::cancelled());
|
||
};
|
||
if paths.is_empty() {
|
||
return Ok(ImportAssetCanvasImagesResult::cancelled());
|
||
}
|
||
let paths = paths
|
||
.into_iter()
|
||
.map(|path| {
|
||
path.into_path()
|
||
.map_err(|error| format!("读取导入图片路径失败:{error}"))
|
||
})
|
||
.collect::<Result<Vec<_>, _>>()?;
|
||
tokio::task::spawn_blocking(move || import_asset_canvas_images_at(&root, &input, &paths))
|
||
.await
|
||
.map_err(|error| format!("图片导入任务异常结束:{error}"))?
|
||
}
|
||
|
||
#[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 archive_failed_local_project_asset_canvas_generation(
|
||
input: ArchiveAssetCanvasGenerationInput,
|
||
) -> Result<ArchiveAssetCanvasGenerationResult, String> {
|
||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||
archive_failed_asset_canvas_generation_at(&root, &input).await
|
||
}
|
||
|
||
#[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 finalize_local_project_asset_canvas_generation_failure(
|
||
input: FinalizeAssetCanvasGenerationFailureInput,
|
||
) -> Result<FinalizeAssetCanvasGenerationFailureResult, String> {
|
||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||
finalize_asset_canvas_generation_failure_at(&root, &input).await
|
||
}
|
||
|
||
#[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 commit_local_project_asset_canvas_candidate(
|
||
app: tauri::AppHandle,
|
||
input: CommitAssetCanvasCandidateInput,
|
||
) -> Result<CommitAssetCanvasResult, String> {
|
||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||
let execution = commit_asset_canvas_candidate_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,
|
||
project_path: String,
|
||
action: String,
|
||
detail: Option<String>,
|
||
) -> Result<AgentRunControlResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
let command_id = match action.trim() {
|
||
"status" => "agent.run_status",
|
||
"kill" => "agent.kill",
|
||
"retry" => "agent.retry",
|
||
"resume" => "agent.resume",
|
||
_ => "agent.run_status",
|
||
};
|
||
enforce_project_permission_policy(root, command_id)?;
|
||
if matches!(action.trim(), "retry" | "resume") {
|
||
enforce_project_permission_policy(root, "game.generate_draft")?;
|
||
}
|
||
let _lock = if command_id == "agent.run_status" {
|
||
None
|
||
} else {
|
||
Some(acquire_project_write_lock(root, command_id)?)
|
||
};
|
||
control_agent_run_at(
|
||
root,
|
||
action.trim(),
|
||
detail
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty()),
|
||
Some(&AgentProgressEmitter::new(&app, project_path.trim())),
|
||
)
|
||
.await
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn generate_local_game_draft(
|
||
app: tauri::AppHandle,
|
||
project_path: String,
|
||
prompt: String,
|
||
) -> Result<GenerateLocalGameDraftResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "game.generate_draft")?;
|
||
let _lock = acquire_project_write_lock(root, "game.generate_draft")?;
|
||
advance_agent_runtime_project_revision_locked(root)?;
|
||
generate_local_game_draft_at(
|
||
root,
|
||
prompt.trim(),
|
||
Some(&AgentProgressEmitter::new(&app, project_path.trim())),
|
||
)
|
||
.await
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn chat_with_game_creator_agent(
|
||
project_path: String,
|
||
prompt: String,
|
||
) -> Result<GameCreatorChatAgentReply, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
chat_with_game_creator_agent_at(root, prompt.trim()).await
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn chat_with_game_creator_role_agent(
|
||
project_path: String,
|
||
agent_id: String,
|
||
session_id: Option<String>,
|
||
prompt: String,
|
||
) -> Result<GameCreatorChatAgentReply, String> {
|
||
let root = Path::new(project_path.trim());
|
||
let agent_id = normalize_game_creator_runtime_agent_id(agent_id.trim())?;
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
enforce_project_permission_policy(root, "conversation.write")?;
|
||
let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(root, &agent_id)?
|
||
.ok_or_else(|| format!("Agent 正在执行其他前台或后台任务:{agent_id}"))?;
|
||
let result = chat_with_game_creator_role_agent_runtime_for_session_at(
|
||
root,
|
||
&agent_id,
|
||
session_id.as_deref(),
|
||
prompt.trim(),
|
||
"",
|
||
)
|
||
.await
|
||
.map(|(reply, _runtime)| reply);
|
||
spawn_next_game_creator_agent_background_task_drain_with_lock(root, &agent_id, runtime_lock);
|
||
match result {
|
||
Ok(reply) => Ok(reply),
|
||
Err(error) => Err(error),
|
||
}
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn chat_with_game_creator_role_agent_stream(
|
||
app: tauri::AppHandle,
|
||
project_path: String,
|
||
agent_id: String,
|
||
session_id: Option<String>,
|
||
prompt: String,
|
||
run_id: String,
|
||
) -> Result<GameCreatorChatAgentReply, String> {
|
||
let project_path = project_path.trim().to_string();
|
||
let agent_id = normalize_game_creator_runtime_agent_id(agent_id.trim())?;
|
||
let run_id = run_id.trim().to_string();
|
||
let root = Path::new(project_path.as_str());
|
||
let session_id =
|
||
resolve_agent_conversation_session_id_at(root, &agent_id, session_id.as_deref(), true)?;
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
enforce_project_permission_policy(root, "conversation.write")?;
|
||
let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(root, &agent_id)?
|
||
.ok_or_else(|| format!("Agent 正在执行其他前台或后台任务:{agent_id}"))?;
|
||
let emit_app = app.clone();
|
||
let event_project_path = project_path.clone();
|
||
let event_agent_id = agent_id.clone();
|
||
let event_run_id = run_id.clone();
|
||
let command_result = async {
|
||
let mut runtime_state = start_game_creator_agent_runtime_turn_for_session_at(
|
||
root,
|
||
agent_id.as_str(),
|
||
Some(&session_id),
|
||
prompt.trim(),
|
||
&run_id,
|
||
)?;
|
||
runtime_state = advance_game_creator_agent_runtime_turn_at(
|
||
root,
|
||
runtime_state,
|
||
"llm",
|
||
"请求 Agent LLM",
|
||
"已读取项目上下文,正在让 Agent 独立推理。",
|
||
)?;
|
||
let mut streaming_runtime_state = runtime_state.clone();
|
||
streaming_runtime_state.current_action = "正在接收 Agent 回复".to_string();
|
||
let _ = app.emit(
|
||
"game-creator-role-agent-chat-stream",
|
||
GameCreatorRoleAgentChatStreamEvent {
|
||
project_path: event_project_path.clone(),
|
||
agent_id: event_agent_id.clone(),
|
||
run_id: event_run_id.clone(),
|
||
status: "started".to_string(),
|
||
delta_text: String::new(),
|
||
accumulated_text: String::new(),
|
||
finish_reason: None,
|
||
session_id: Some(runtime_state.session_id.clone()),
|
||
runtime_status: Some(runtime_state.status.clone()),
|
||
runtime_phase: Some(runtime_state.phase.clone()),
|
||
runtime_summary: Some(runtime_state.current_action.clone()),
|
||
runtime_state: Some(runtime_state.clone()),
|
||
},
|
||
);
|
||
let result = chat_with_game_creator_role_agent_stream_for_session_at(
|
||
root,
|
||
agent_id.as_str(),
|
||
Some(&session_id),
|
||
prompt.trim(),
|
||
|delta| {
|
||
let _ = emit_app.emit(
|
||
"game-creator-role-agent-chat-stream",
|
||
GameCreatorRoleAgentChatStreamEvent {
|
||
project_path: event_project_path.clone(),
|
||
agent_id: event_agent_id.clone(),
|
||
run_id: event_run_id.clone(),
|
||
status: "delta".to_string(),
|
||
delta_text: delta.delta_text.clone(),
|
||
accumulated_text: delta.accumulated_text.clone(),
|
||
finish_reason: delta.finish_reason.clone(),
|
||
session_id: Some(streaming_runtime_state.session_id.clone()),
|
||
runtime_status: Some("running".to_string()),
|
||
runtime_phase: Some("llm".to_string()),
|
||
runtime_summary: Some("正在接收 Agent 回复".to_string()),
|
||
runtime_state: Some(streaming_runtime_state.clone()),
|
||
},
|
||
);
|
||
},
|
||
)
|
||
.await;
|
||
match result {
|
||
Ok(reply) => {
|
||
let completed_runtime = finish_game_creator_agent_runtime_turn_at(
|
||
root,
|
||
runtime_state,
|
||
&reply.reply_text,
|
||
)?;
|
||
let _ = app.emit(
|
||
"game-creator-role-agent-chat-stream",
|
||
GameCreatorRoleAgentChatStreamEvent {
|
||
project_path: project_path.clone(),
|
||
agent_id: agent_id.clone(),
|
||
run_id: run_id.clone(),
|
||
status: "completed".to_string(),
|
||
delta_text: String::new(),
|
||
accumulated_text: reply.reply_text.clone(),
|
||
finish_reason: None,
|
||
session_id: Some(completed_runtime.session_id.clone()),
|
||
runtime_status: Some(completed_runtime.status.clone()),
|
||
runtime_phase: Some(completed_runtime.phase.clone()),
|
||
runtime_summary: Some(completed_runtime.current_action.clone()),
|
||
runtime_state: Some(completed_runtime),
|
||
},
|
||
);
|
||
Ok(reply)
|
||
}
|
||
Err(error) => {
|
||
let failed_runtime =
|
||
fail_game_creator_agent_runtime_turn_at(root, runtime_state, &error).ok();
|
||
let _ = app.emit(
|
||
"game-creator-role-agent-chat-stream",
|
||
GameCreatorRoleAgentChatStreamEvent {
|
||
project_path: project_path.clone(),
|
||
agent_id: agent_id.clone(),
|
||
run_id: run_id.clone(),
|
||
status: "failed".to_string(),
|
||
delta_text: String::new(),
|
||
accumulated_text: String::new(),
|
||
finish_reason: None,
|
||
session_id: failed_runtime
|
||
.as_ref()
|
||
.map(|runtime| runtime.session_id.clone()),
|
||
runtime_status: failed_runtime
|
||
.as_ref()
|
||
.map(|runtime| runtime.status.clone()),
|
||
runtime_phase: failed_runtime.as_ref().map(|runtime| runtime.phase.clone()),
|
||
runtime_summary: failed_runtime
|
||
.as_ref()
|
||
.map(|runtime| runtime.current_action.clone()),
|
||
runtime_state: failed_runtime,
|
||
},
|
||
);
|
||
Err(error)
|
||
}
|
||
}
|
||
}
|
||
.await;
|
||
spawn_next_game_creator_agent_background_task_drain_with_lock(root, &agent_id, runtime_lock);
|
||
match command_result {
|
||
Ok(reply) => Ok(reply),
|
||
Err(error) => Err(error),
|
||
}
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn start_game_creator_agent_runtime_task(
|
||
project_path: String,
|
||
agent_id: String,
|
||
session_id: Option<String>,
|
||
task: String,
|
||
run_id: String,
|
||
) -> Result<AgentRuntimeResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
enforce_project_permission_policy(root, "conversation.write")?;
|
||
enforce_project_permission_policy(root, "agent.run_status")?;
|
||
start_game_creator_agent_background_task_for_session_at(
|
||
root,
|
||
agent_id.trim(),
|
||
session_id.as_deref(),
|
||
task.trim(),
|
||
run_id.trim(),
|
||
)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn start_game_creator_supervisor_runtime_task(
|
||
project_path: String,
|
||
session_id: Option<String>,
|
||
task: String,
|
||
run_id: String,
|
||
run_profile: Option<String>,
|
||
source: Option<String>,
|
||
) -> Result<AgentRuntimeResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
enforce_project_permission_policy(root, "conversation.write")?;
|
||
enforce_project_permission_policy(root, "agent.run_status")?;
|
||
let run_profile = run_profile
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.unwrap_or(AGENT_RUNTIME_RUN_PROFILE_STANDARD);
|
||
let source = source
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.unwrap_or(AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE);
|
||
if !agent_runtime_supervisor_source_is_trusted(source) {
|
||
return Err("Project Supervisor 提交 source 不受信任".to_string());
|
||
}
|
||
reject_supervisor_plan_autonomous_profile(source, run_profile)?;
|
||
if agent_runtime_supervisor_source_is_plan(source)
|
||
&& !crate::config::game_creator_planning_capability_enabled()?
|
||
{
|
||
return Err("PLAN_CAPABILITY_DISABLED: 立项策划能力当前已停用".to_string());
|
||
}
|
||
start_game_creator_supervisor_background_task_for_session_at(
|
||
root,
|
||
session_id.as_deref(),
|
||
task.trim(),
|
||
run_id.trim(),
|
||
source,
|
||
run_profile,
|
||
)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn compact_game_creator_agent_runtime_context(
|
||
project_path: String,
|
||
agent_id: String,
|
||
session_id: Option<String>,
|
||
) -> Result<AgentRuntimeContextCompactionResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
enforce_project_permission_policy(root, "agent.compact")?;
|
||
if external_agent_runner_enabled() && !external_agent_runner_is_server_process() {
|
||
compact_external_agent_runner_context(root, agent_id.trim(), session_id.as_deref())
|
||
} else {
|
||
compact_game_creator_agent_runtime_session_at(root, agent_id.trim(), session_id.as_deref())
|
||
.await
|
||
}
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn read_game_creator_agent_goal(
|
||
project_path: String,
|
||
agent_id: String,
|
||
session_id: String,
|
||
) -> Result<Option<AgentGoalRecord>, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
enforce_project_permission_policy(root, "agent.run_status")?;
|
||
read_game_creator_agent_goal_at(root, agent_id.trim(), session_id.trim())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn start_game_creator_agent_goal(
|
||
project_path: String,
|
||
agent_id: String,
|
||
session_id: Option<String>,
|
||
outcome: String,
|
||
constraints: Vec<String>,
|
||
verification: Vec<String>,
|
||
run_id: String,
|
||
) -> Result<AgentGoalMutationResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
enforce_project_permission_policy(root, "conversation.write")?;
|
||
enforce_project_permission_policy(root, "agent.run_status")?;
|
||
start_game_creator_agent_goal_at(
|
||
root,
|
||
agent_id.trim(),
|
||
session_id.as_deref(),
|
||
outcome.trim(),
|
||
constraints,
|
||
verification,
|
||
run_id.trim(),
|
||
)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn edit_game_creator_agent_goal(
|
||
project_path: String,
|
||
agent_id: String,
|
||
session_id: String,
|
||
goal_id: String,
|
||
expected_revision: u64,
|
||
outcome: String,
|
||
constraints: Vec<String>,
|
||
verification: Vec<String>,
|
||
) -> Result<AgentGoalMutationResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
enforce_project_permission_policy(root, "conversation.write")?;
|
||
enforce_project_permission_policy(root, "agent.run_status")?;
|
||
edit_game_creator_agent_goal_at(
|
||
root,
|
||
agent_id.trim(),
|
||
session_id.trim(),
|
||
goal_id.trim(),
|
||
expected_revision,
|
||
outcome.trim(),
|
||
constraints,
|
||
verification,
|
||
)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn pause_game_creator_agent_goal(
|
||
project_path: String,
|
||
agent_id: String,
|
||
session_id: String,
|
||
goal_id: String,
|
||
expected_revision: u64,
|
||
) -> Result<AgentGoalMutationResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
enforce_project_permission_policy(root, "conversation.write")?;
|
||
enforce_project_permission_policy(root, "agent.run_status")?;
|
||
pause_game_creator_agent_goal_at(
|
||
root,
|
||
agent_id.trim(),
|
||
session_id.trim(),
|
||
goal_id.trim(),
|
||
expected_revision,
|
||
)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn resume_game_creator_agent_goal(
|
||
project_path: String,
|
||
agent_id: String,
|
||
session_id: String,
|
||
goal_id: String,
|
||
expected_revision: u64,
|
||
) -> Result<AgentGoalMutationResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
enforce_project_permission_policy(root, "conversation.write")?;
|
||
enforce_project_permission_policy(root, "agent.run_status")?;
|
||
enforce_project_permission_policy(root, "agent.resume")?;
|
||
resume_game_creator_agent_goal_at(
|
||
root,
|
||
agent_id.trim(),
|
||
session_id.trim(),
|
||
goal_id.trim(),
|
||
expected_revision,
|
||
)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn clear_game_creator_agent_goal(
|
||
project_path: String,
|
||
agent_id: String,
|
||
session_id: String,
|
||
goal_id: String,
|
||
expected_revision: u64,
|
||
) -> Result<AgentGoalMutationResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
enforce_project_permission_policy(root, "conversation.write")?;
|
||
enforce_project_permission_policy(root, "agent.run_status")?;
|
||
clear_game_creator_agent_goal_at(
|
||
root,
|
||
agent_id.trim(),
|
||
session_id.trim(),
|
||
goal_id.trim(),
|
||
expected_revision,
|
||
)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn steer_game_creator_agent_runtime_task(
|
||
project_path: String,
|
||
agent_id: String,
|
||
session_id: String,
|
||
run_id: String,
|
||
steer_id: String,
|
||
instruction: String,
|
||
run_profile: Option<String>,
|
||
source: Option<String>,
|
||
) -> Result<AgentRuntimeSteerResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
enforce_project_permission_policy(root, "conversation.write")?;
|
||
enforce_project_permission_policy(root, "agent.run_status")?;
|
||
if let Some(source) = source
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
{
|
||
reject_supervisor_plan_root_steer(source)?;
|
||
if !agent_runtime_supervisor_source_is_trusted(source) {
|
||
return Err("Project Supervisor steer source 不受信任".to_string());
|
||
}
|
||
let task = read_latest_game_creator_agent_runtime_task_by_run_id(
|
||
root,
|
||
agent_id.trim(),
|
||
run_id.trim(),
|
||
)?
|
||
.ok_or_else(|| "Agent Runtime steer 的 Run 不存在".to_string())?;
|
||
if task.source != source {
|
||
return Err("Agent Runtime steer source 与当前 Run 不一致".to_string());
|
||
}
|
||
}
|
||
let mut result = steer_game_creator_agent_runtime_task_for_profile_at(
|
||
root,
|
||
agent_id.trim(),
|
||
session_id.trim(),
|
||
run_id.trim(),
|
||
steer_id.trim(),
|
||
instruction.trim(),
|
||
run_profile.as_deref(),
|
||
"tauri",
|
||
)?;
|
||
let external_runner =
|
||
external_agent_runner_enabled() && !external_agent_runner_is_server_process();
|
||
let wake_external_runner_without_interrupt = || -> Result<(), String> {
|
||
let provider_interrupted =
|
||
steer_external_agent_runner(root, agent_id.trim(), run_id.trim(), steer_id.trim())?;
|
||
if provider_interrupted {
|
||
return Err("Agent Runner 的 runtime.steer 非法中断了 Provider".to_string());
|
||
}
|
||
Ok(())
|
||
};
|
||
let state = result.runtime.state.clone();
|
||
let requires_supervisor_decision = state.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|
||
&& state.parent_agent_id.is_none()
|
||
&& state.parent_run_id.is_none();
|
||
if requires_supervisor_decision {
|
||
match decide_game_creator_agent_runtime_steer_at(
|
||
root,
|
||
&state,
|
||
steer_id.trim(),
|
||
result.sequence,
|
||
instruction.trim(),
|
||
)
|
||
.await
|
||
{
|
||
Ok(decision) => {
|
||
result.assistant_reply = Some(decision.reply.clone());
|
||
result.interrupt_decision = Some(decision.interrupt_current_provider);
|
||
result.decision_reason = Some(decision.reason.clone());
|
||
if decision.interrupt_current_provider {
|
||
result.provider_interrupted = if external_runner {
|
||
interrupt_external_agent_runner_provider_for_steer_decision(
|
||
root,
|
||
agent_id.trim(),
|
||
run_id.trim(),
|
||
steer_id.trim(),
|
||
)?
|
||
} else {
|
||
interrupt_game_creator_agent_runtime_provider_for_decided_steer_at(
|
||
root,
|
||
agent_id.trim(),
|
||
run_id.trim(),
|
||
steer_id.trim(),
|
||
)?
|
||
};
|
||
if !external_runner {
|
||
wake_pending_game_creator_agent_background_tasks_at(root)
|
||
.map_err(|error| error.to_string())?;
|
||
}
|
||
} else if external_runner {
|
||
wake_external_runner_without_interrupt()?;
|
||
} else {
|
||
wake_pending_game_creator_agent_background_tasks_at(root)
|
||
.map_err(|error| error.to_string())?;
|
||
}
|
||
}
|
||
Err(error) => {
|
||
result.assistant_reply = Some(
|
||
append_game_creator_agent_runtime_steer_decision_failure_reply_at(
|
||
root,
|
||
&state,
|
||
steer_id.trim(),
|
||
&error,
|
||
)?,
|
||
);
|
||
result.decision_reason = Some("decision-failed".to_string());
|
||
if external_runner {
|
||
wake_external_runner_without_interrupt()?;
|
||
} else {
|
||
wake_pending_game_creator_agent_background_tasks_at(root)
|
||
.map_err(|error| error.to_string())?;
|
||
}
|
||
}
|
||
}
|
||
} else if external_runner {
|
||
wake_external_runner_without_interrupt()?;
|
||
} else {
|
||
wake_pending_game_creator_agent_background_tasks_at(root)
|
||
.map_err(|error| error.to_string())?;
|
||
}
|
||
result.runtime = read_game_creator_agent_runtime_for_session_at(
|
||
root,
|
||
agent_id.trim(),
|
||
Some(session_id.trim()),
|
||
)?;
|
||
Ok(result)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn cancel_game_creator_agent_runtime_task(
|
||
project_path: String,
|
||
agent_id: String,
|
||
run_id: String,
|
||
) -> Result<AgentRuntimeResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
enforce_project_permission_policy(root, "conversation.write")?;
|
||
enforce_project_permission_policy(root, "agent.run_status")?;
|
||
cancel_game_creator_agent_runtime_task_at(root, agent_id.trim(), run_id.trim())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn retry_game_creator_agent_runtime_task(
|
||
project_path: String,
|
||
agent_id: String,
|
||
run_id: String,
|
||
next_run_id: String,
|
||
) -> Result<AgentRuntimeResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
enforce_project_permission_policy(root, "conversation.write")?;
|
||
enforce_project_permission_policy(root, "agent.run_status")?;
|
||
enforce_project_auto_permission_policy(root, "agent.resume")?;
|
||
retry_game_creator_agent_runtime_task_at(
|
||
root,
|
||
agent_id.trim(),
|
||
run_id.trim(),
|
||
next_run_id.trim(),
|
||
)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn confirm_retry_game_creator_agent_runtime_task(
|
||
project_path: String,
|
||
agent_id: String,
|
||
run_id: String,
|
||
next_run_id: String,
|
||
) -> Result<AgentRuntimeResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
enforce_project_permission_policy(root, "conversation.write")?;
|
||
enforce_project_permission_policy(root, "agent.run_status")?;
|
||
// 正式工作台的“在当前项目重试”按钮本身就是用户对本次 agent.resume 的明确确认。
|
||
enforce_project_permission_policy(root, "agent.resume")?;
|
||
retry_game_creator_agent_runtime_task_at(
|
||
root,
|
||
agent_id.trim(),
|
||
run_id.trim(),
|
||
next_run_id.trim(),
|
||
)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn confirm_game_creator_agent_runtime_task(
|
||
project_path: String,
|
||
agent_id: String,
|
||
run_id: String,
|
||
action_id: String,
|
||
note: String,
|
||
) -> Result<AgentRuntimeResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
enforce_project_permission_policy(root, "conversation.write")?;
|
||
enforce_project_permission_policy(root, "agent.run_status")?;
|
||
// 该命令只由开发者显式点击“确认继续”触发。
|
||
enforce_project_permission_policy(root, "agent.resume")?;
|
||
confirm_game_creator_agent_runtime_task_at(
|
||
root,
|
||
agent_id.trim(),
|
||
run_id.trim(),
|
||
action_id.trim(),
|
||
note.trim(),
|
||
)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn reject_game_creator_agent_runtime_task(
|
||
project_path: String,
|
||
agent_id: String,
|
||
run_id: String,
|
||
action_id: String,
|
||
note: String,
|
||
) -> Result<AgentRuntimeResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
enforce_project_permission_policy(root, "conversation.write")?;
|
||
enforce_project_permission_policy(root, "agent.run_status")?;
|
||
// 该命令只由开发者显式点击“拒绝并继续”触发。
|
||
enforce_project_permission_policy(root, "agent.resume")?;
|
||
reject_game_creator_agent_runtime_task_at(
|
||
root,
|
||
agent_id.trim(),
|
||
run_id.trim(),
|
||
action_id.trim(),
|
||
note.trim(),
|
||
)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn answer_game_creator_agent_runtime_user_input(
|
||
project_path: String,
|
||
agent_id: String,
|
||
run_id: String,
|
||
action_id: String,
|
||
request_id: String,
|
||
response_id: String,
|
||
answers: BTreeMap<String, String>,
|
||
) -> Result<AgentRuntimeResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
enforce_project_permission_policy(root, "conversation.write")?;
|
||
enforce_project_permission_policy(root, "agent.run_status")?;
|
||
enforce_project_permission_policy(root, "agent.resume")?;
|
||
answer_game_creator_agent_runtime_user_input_at(
|
||
root,
|
||
agent_id.trim(),
|
||
run_id.trim(),
|
||
action_id.trim(),
|
||
request_id.trim(),
|
||
response_id.trim(),
|
||
answers,
|
||
)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn decide_game_creator_plan_gdd(
|
||
project_path: String,
|
||
gdd_id: String,
|
||
version: u32,
|
||
fingerprint: String,
|
||
pending_action_id: String,
|
||
approval_request_id: String,
|
||
response_id: String,
|
||
action: String,
|
||
comment: Option<String>,
|
||
) -> Result<PlanGddDecisionResultV1, String> {
|
||
let root = validated_local_project_directory_path(project_path.trim())?;
|
||
enforce_project_permission_policy(&root, "conversation.read")?;
|
||
enforce_project_permission_policy(&root, "conversation.write")?;
|
||
enforce_project_permission_policy(&root, "agent.run_status")?;
|
||
enforce_project_permission_policy(&root, "agent.resume")?;
|
||
let mut result = match decide_plan_gdd_at(
|
||
&root,
|
||
&DecidePlanGddInputV1 {
|
||
gdd_id,
|
||
version,
|
||
fingerprint,
|
||
pending_action_id,
|
||
approval_request_id,
|
||
response_id,
|
||
action,
|
||
comment,
|
||
},
|
||
) {
|
||
Ok(result) => result,
|
||
Err(error) => {
|
||
return Err(error.to_string());
|
||
}
|
||
};
|
||
if !result.recovery_pending {
|
||
if let Err(error) = wake_pending_game_creator_agent_background_tasks_at(&root) {
|
||
let detail = error.to_string();
|
||
crate::error_report::report_diagnostic_error(
|
||
"agent",
|
||
&detail,
|
||
None,
|
||
Some("wake_pending_game_creator_agent_background_tasks"),
|
||
None,
|
||
);
|
||
// The receipt is already the user-decision linearization point;
|
||
// surface a recoverable projection state instead of turning a
|
||
// durable approval into a false command failure.
|
||
result.recovery_pending = true;
|
||
}
|
||
}
|
||
Ok(result)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn hydrate_game_creator_plan_gdd_state(
|
||
request: tauri::ipc::Request<'_>,
|
||
) -> Result<PlanGddStateViewV1, String> {
|
||
let input = parse_hydrate_plan_gdd_state_input(&request)?;
|
||
hydrate_game_creator_plan_gdd_state_for_path(&input.project_path)
|
||
}
|
||
|
||
/// Transport-independent projection read shared by the Tauri command and the
|
||
/// headless CLI entry. Both must cross the same permission gate, otherwise the
|
||
/// CLI would become a way to read a project the policy denies.
|
||
pub(crate) fn hydrate_game_creator_plan_gdd_state_for_path(
|
||
project_path: &str,
|
||
) -> Result<PlanGddStateViewV1, String> {
|
||
let root = validated_local_project_directory_path(project_path.trim())?;
|
||
enforce_project_permission_policy(&root, "conversation.read")?;
|
||
hydrate_game_creator_plan_gdd_state_at(&root).map_err(|error| error.to_string())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn read_game_creator_agent_runtime(
|
||
project_path: String,
|
||
agent_id: String,
|
||
session_id: Option<String>,
|
||
) -> Result<AgentRuntimeResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
read_game_creator_agent_runtime_for_session_at(root, agent_id.trim(), session_id.as_deref())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn read_game_creator_agent_runtimes(
|
||
project_path: String,
|
||
) -> Result<Vec<AgentRuntimeResult>, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
read_game_creator_agent_runtimes_at(root)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn resume_game_creator_agent_runtime_tasks(
|
||
project_path: String,
|
||
) -> Result<Vec<AgentRuntimeResult>, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
enforce_project_permission_policy(root, "agent.run_status")?;
|
||
if !has_recoverable_game_creator_agent_background_tasks_at(root)? {
|
||
return Ok(Vec::new());
|
||
}
|
||
enforce_project_permission_policy(root, "conversation.write")?;
|
||
enforce_project_auto_permission_policy(root, "agent.resume")?;
|
||
resume_game_creator_agent_background_tasks_at(root)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn confirm_resume_game_creator_agent_runtime_tasks(
|
||
project_path: String,
|
||
) -> Result<Vec<AgentRuntimeResult>, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
enforce_project_permission_policy(root, "conversation.write")?;
|
||
enforce_project_permission_policy(root, "agent.run_status")?;
|
||
// 该命令只由开发者在 agent.resume 确认卡中明确批准后调用。
|
||
enforce_project_permission_policy(root, "agent.resume")?;
|
||
resume_game_creator_agent_background_tasks_at(root)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn schedule_game_creator_agent_ready_tasks(
|
||
project_path: String,
|
||
limit: usize,
|
||
) -> Result<Vec<AgentRuntimeResult>, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
enforce_project_permission_policy(root, "conversation.write")?;
|
||
enforce_project_permission_policy(root, "agent.run_status")?;
|
||
enforce_project_auto_permission_policy(root, "agent.schedule_ready")?;
|
||
schedule_game_creator_agent_ready_tasks_at(root, limit)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn check_game_creator_llm_config() -> Result<GameCreatorLlmConfigStatus, String> {
|
||
// Configuration reads/writes are short local file operations, but the
|
||
// diagnostic status path may synchronously spawn Codex CLI and run
|
||
// `app-server --help`. Keep that blocking probe off Tauri's async/window
|
||
// thread so opening or saving runtime settings never freezes the shell.
|
||
tokio::task::spawn_blocking(check_game_creator_llm_config_from_config)
|
||
.await
|
||
.map_err(|error| format!("LLM 配置诊断任务意外终止:{error}"))
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn suggest_automatic_project_name(
|
||
prompt: String,
|
||
) -> Result<Option<String>, String> {
|
||
request_automatic_project_name(prompt.trim())
|
||
.await
|
||
.map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320))
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn read_platform_account_session_generation() -> u64 {
|
||
current_platform_session_generation()
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn install_platform_account_session(
|
||
user_id: String,
|
||
access_token: String,
|
||
api_base_url: String,
|
||
generation: u64,
|
||
) -> Result<(), String> {
|
||
validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?;
|
||
install_external_agent_runner_platform_session(
|
||
&user_id,
|
||
&access_token,
|
||
&api_base_url,
|
||
generation,
|
||
)?;
|
||
install_platform_session(&user_id, &access_token, &api_base_url, generation)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn clear_platform_account_session(generation: u64) -> Result<(), String> {
|
||
shutdown_game_creator_codex_app_servers()?;
|
||
clear_external_agent_runner_platform_session(generation)?;
|
||
clear_platform_session(generation);
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn read_game_creator_app_config() -> Result<GameCreatorAppConfigView, String> {
|
||
game_creator_app_config_view(load_game_creator_app_config()?)
|
||
}
|
||
|
||
static GAME_CREATOR_CONFIG_WRITE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn write_game_creator_app_config(
|
||
mut config: GameCreatorAppConfig,
|
||
) -> Result<GameCreatorAppConfigView, String> {
|
||
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
|
||
.lock()
|
||
.map_err(|_| "配置写入锁不可用")?;
|
||
let (current, overlays) = load_game_creator_app_config_for_write()?;
|
||
config.selected_model_id = current.selected_model_id;
|
||
persist_game_creator_app_config(config, overlays, false)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn select_game_creator_model(
|
||
model_id: String,
|
||
) -> Result<GameCreatorAppConfigView, String> {
|
||
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
|
||
.lock()
|
||
.map_err(|_| "配置写入锁不可用")?;
|
||
if model_id.is_empty()
|
||
|| model_id.len() > 64
|
||
|| !model_id
|
||
.bytes()
|
||
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
|
||
{
|
||
return Err("模型标识无效".into());
|
||
}
|
||
let (mut config, overlays) = load_game_creator_app_config_for_write()?;
|
||
config.selected_model_id = model_id;
|
||
persist_game_creator_app_config(config, overlays, true)
|
||
}
|
||
|
||
fn persist_game_creator_app_config(
|
||
config: GameCreatorAppConfig,
|
||
overlays: Vec<(PathBuf, serde_json::Value)>,
|
||
model_only: bool,
|
||
) -> Result<GameCreatorAppConfigView, String> {
|
||
let config = normalize_game_creator_app_config(config)?;
|
||
let path = writable_game_creator_config_path()?;
|
||
let content = serialize_game_creator_app_config_for_renderer_write(&config)?;
|
||
let mut writes = vec![(path, format!("{content}\n"))];
|
||
let saved: serde_json::Value = serde_json::from_str(&content)
|
||
.map_err(|error| format!("解析已序列化客户端配置失败:{error}"))?;
|
||
for (overlay_path, mut overlay) in overlays {
|
||
let previous = overlay.clone();
|
||
if let Some(fields) = overlay.as_object_mut() {
|
||
for (key, value) in fields.iter_mut() {
|
||
if (key == "selectedModelId") == model_only {
|
||
if let Some(saved_value) = saved.get(key) {
|
||
// 仅同步已有覆盖项;其它字段继续保留原有覆盖语义。
|
||
if key == "agentLlm" {
|
||
*value = saved_value.clone();
|
||
} else {
|
||
update_existing_config_overlay_fields(value, saved_value);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if overlay != previous {
|
||
let content = serde_json::to_string_pretty(&overlay)
|
||
.map_err(|error| format!("序列化客户端覆盖配置失败:{error}"))?;
|
||
writes.push((overlay_path, format!("{content}\n")));
|
||
}
|
||
}
|
||
crate::config::write_game_creator_config_batch(&writes)?;
|
||
// `config` is already normalized and is exactly what was persisted.
|
||
// Avoid reloading it here: a reload repeats the Windows private-path and
|
||
// ACL checks and made saving the settings panel appear to hang.
|
||
game_creator_app_config_view(config)
|
||
}
|
||
|
||
fn update_existing_config_overlay_fields(
|
||
overlay: &mut serde_json::Value,
|
||
saved: &serde_json::Value,
|
||
) {
|
||
if let (Some(fields), Some(saved_fields)) = (overlay.as_object_mut(), saved.as_object()) {
|
||
for (key, value) in fields {
|
||
if let Some(saved_value) = saved_fields.get(key) {
|
||
update_existing_config_overlay_fields(value, saved_value);
|
||
}
|
||
}
|
||
} else if !overlay.is_null() {
|
||
*overlay = saved.clone();
|
||
}
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn upload_local_asset(
|
||
project_path: String,
|
||
file_name: String,
|
||
media_type: String,
|
||
bytes: Vec<u8>,
|
||
) -> Result<UploadLocalAssetResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "asset.upload")?;
|
||
let _lock = acquire_project_write_lock(root, "asset.upload")?;
|
||
advance_agent_runtime_project_revision_locked(root)?;
|
||
upload_local_asset_at(root, file_name.trim(), media_type.trim(), &bytes)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn register_local_asset(
|
||
project_path: String,
|
||
local_path: String,
|
||
kind: String,
|
||
media_type: String,
|
||
source_kind: String,
|
||
canvas_project_id: Option<String>,
|
||
resource_id: Option<String>,
|
||
asset_object_id: Option<String>,
|
||
task_id: Option<String>,
|
||
prompt: Option<String>,
|
||
model: Option<String>,
|
||
) -> Result<UploadLocalAssetResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "asset.register")?;
|
||
let _lock = acquire_project_write_lock(root, "asset.register")?;
|
||
advance_agent_runtime_project_revision_locked(root)?;
|
||
register_local_asset_at(
|
||
root,
|
||
local_path.trim(),
|
||
kind.trim(),
|
||
media_type.trim(),
|
||
source_kind.trim(),
|
||
GameCreationAppAssetSource {
|
||
kind: parse_asset_source_kind(source_kind.trim())?,
|
||
canvas_project_id: trim_optional_string(canvas_project_id),
|
||
resource_id: trim_optional_string(resource_id),
|
||
asset_object_id: trim_optional_string(asset_object_id),
|
||
task_id: trim_optional_string(task_id),
|
||
prompt: trim_optional_string(prompt),
|
||
model: trim_optional_string(model),
|
||
generation_route: None,
|
||
generation_kind: None,
|
||
reference_resource_ids: Vec::new(),
|
||
},
|
||
)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn create_ui_design_resource(
|
||
project_path: String,
|
||
expected_project_id: String,
|
||
) -> Result<CreateUiDesignResourceResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "asset.register")?;
|
||
let _lock = acquire_project_write_lock(root, "asset.register")?;
|
||
let manifest = read_existing_manifest_for_project(root)?;
|
||
if manifest.project_id != expected_project_id.trim() {
|
||
return Err("project-identity-conflict".to_string());
|
||
}
|
||
let next_index = manifest
|
||
.assets
|
||
.iter()
|
||
.filter(|asset| asset.kind == "UI")
|
||
.count()
|
||
+ 1;
|
||
let resource_name = format!("UI 设计 {next_index}");
|
||
let relative_path = format!("ui/{resource_name}.json");
|
||
let absolute_path = resolve_local_project_path(root, &relative_path)?;
|
||
if let Some(parent) = absolute_path.parent() {
|
||
ensure_game_creator_private_directory_tree(parent, "UI 资源目录")?;
|
||
prepare_game_creator_private_path_for_read(parent, true, "UI 资源目录")?;
|
||
}
|
||
if prepare_game_creator_private_path_for_read(&absolute_path, false, "UI 资源")? {
|
||
return Err("UI 设计资源路径已存在,拒绝覆盖".to_string());
|
||
}
|
||
let mut options = fs::OpenOptions::new();
|
||
options.write(true).create_new(true);
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::fs::OpenOptionsExt;
|
||
options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
|
||
}
|
||
let mut file = options
|
||
.open(&absolute_path)
|
||
.map_err(|error| format!("创建 UI 资源失败:{}: {error}", absolute_path.display()))?;
|
||
if let Err(error) = harden_new_game_creator_private_path(&absolute_path, false, "UI 资源") {
|
||
drop(file);
|
||
let _ = fs::remove_file(&absolute_path);
|
||
return Err(error);
|
||
}
|
||
file.sync_all()
|
||
.map_err(|error| format!("同步 UI 资源失败:{}: {error}", absolute_path.display()))?;
|
||
drop(file);
|
||
let asset = match register_local_asset_at(
|
||
root,
|
||
&relative_path,
|
||
"UI",
|
||
"application/json",
|
||
"generated",
|
||
GameCreationAppAssetSource {
|
||
kind: GameCreationAppAssetSourceKind::Generated,
|
||
canvas_project_id: None,
|
||
resource_id: Some(format!("ui:{next_index}")),
|
||
asset_object_id: None,
|
||
task_id: None,
|
||
prompt: None,
|
||
model: None,
|
||
generation_route: None,
|
||
generation_kind: None,
|
||
reference_resource_ids: Vec::new(),
|
||
},
|
||
) {
|
||
Ok(asset) => asset,
|
||
Err(error) => {
|
||
let rollback = (|| {
|
||
write_manifest(&root.join(".agent/manifest.json"), &manifest)?;
|
||
fs::remove_file(&absolute_path).map_err(|remove_error| {
|
||
format!(
|
||
"删除未完成 UI 设计资源失败:{}: {remove_error}",
|
||
absolute_path.display()
|
||
)
|
||
})
|
||
})();
|
||
return match rollback {
|
||
Ok(()) => Err(error),
|
||
Err(rollback_error) => Err(format!(
|
||
"UI 设计资源登记失败:{error};reconciliation-required: 回滚未完成:{rollback_error}"
|
||
)),
|
||
};
|
||
}
|
||
};
|
||
if let Err(error) = ui_editor::persistence::initialize_ui_design_state_at(
|
||
root,
|
||
expected_project_id.trim(),
|
||
&asset.id,
|
||
) {
|
||
let rollback = (|| {
|
||
let mut current = read_existing_manifest_for_project(root)?;
|
||
current.assets.retain(|entry| entry.id != asset.id);
|
||
write_manifest(&root.join(".agent/manifest.json"), ¤t)?;
|
||
fs::remove_file(&absolute_path).map_err(|remove_error| {
|
||
format!(
|
||
"删除未完成 UI 设计资源失败:{}: {remove_error}",
|
||
absolute_path.display()
|
||
)
|
||
})
|
||
})();
|
||
return match rollback {
|
||
Ok(()) => Err(error),
|
||
Err(rollback_error) => Err(format!(
|
||
"UI 设计资源初始化失败:{error};reconciliation-required: 回滚未完成:{rollback_error}"
|
||
)),
|
||
};
|
||
}
|
||
advance_agent_runtime_project_revision_locked(root).map_err(|error| {
|
||
format!("reconciliation-required: UI 设计资源已创建,但项目 revision 未能推进:{error}")
|
||
})?;
|
||
let manifest = read_existing_manifest_for_project(root)?;
|
||
let revision = read_game_creator_agent_runtime_project_revision(root)?.revision;
|
||
Ok(CreateUiDesignResourceResult {
|
||
asset,
|
||
manifest,
|
||
committed_project_revision: revision,
|
||
})
|
||
}
|
||
|
||
#[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,
|
||
local_path: String,
|
||
kind: String,
|
||
media_type: String,
|
||
canvas_project_id: String,
|
||
resource_id: Option<String>,
|
||
asset_object_id: Option<String>,
|
||
task_id: Option<String>,
|
||
prompt: Option<String>,
|
||
model: Option<String>,
|
||
) -> Result<UploadLocalAssetResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "canvas.asset_import")?;
|
||
let _lock = acquire_project_write_lock(root, "canvas.asset_import")?;
|
||
advance_agent_runtime_project_revision_locked(root)?;
|
||
import_canvas_asset_at(
|
||
root,
|
||
local_path.trim(),
|
||
kind.trim(),
|
||
media_type.trim(),
|
||
canvas_project_id.trim(),
|
||
trim_optional_string(resource_id),
|
||
trim_optional_string(asset_object_id),
|
||
trim_optional_string(task_id),
|
||
trim_optional_string(prompt),
|
||
trim_optional_string(model),
|
||
)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn import_canvas_export(
|
||
project_path: String,
|
||
export_path: String,
|
||
canvas_project_id: String,
|
||
) -> Result<ImportCanvasExportResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "canvas.export_import")?;
|
||
let _lock = acquire_project_write_lock(root, "canvas.export_import")?;
|
||
advance_agent_runtime_project_revision_locked(root)?;
|
||
import_canvas_export_at(
|
||
root,
|
||
Path::new(export_path.trim()),
|
||
canvas_project_id.trim(),
|
||
)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn sync_canvas_project_assets(
|
||
project_path: String,
|
||
canvas_project_id: String,
|
||
api_base_url: Option<String>,
|
||
api_key: Option<String>,
|
||
) -> Result<SyncCanvasProjectAssetsResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "canvas.project_sync")?;
|
||
let _lock = acquire_project_write_lock(root, "canvas.project_sync")?;
|
||
advance_agent_runtime_project_revision_locked(root)?;
|
||
sync_canvas_project_assets_at(root, canvas_project_id.trim(), api_base_url, api_key).await
|
||
}
|
||
|
||
pub(crate) fn import_ui_editor_local_files(
|
||
project_path: String,
|
||
source_paths: Vec<String>,
|
||
) -> Result<LocalImportResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "asset.upload")?;
|
||
let _lock = acquire_project_write_lock(root, "asset.upload")?;
|
||
if source_paths.len() > UI_EDITOR_IMAGE_MAX_COUNT {
|
||
return Err(format!("一次最多选择 {UI_EDITOR_IMAGE_MAX_COUNT} 张图片"));
|
||
}
|
||
// V1 有意采用增量导入:每个文件独立写入并登记,后续失败不回滚此前成功项。
|
||
// 调用方必须以返回结果和 manifest 为准重建已提交集合;整批原子语义需另行定义 transaction/reconciliation 合同。
|
||
let mut inputs = Vec::new();
|
||
let mut total_size = 0u64;
|
||
for source in source_paths {
|
||
let path = Path::new(source.trim());
|
||
crate::prepare_game_creator_user_selected_path_for_read(path, false, "本地图片")?;
|
||
let metadata = fs::symlink_metadata(path).map_err(|e| format!("读取本地图片失败:{e}"))?;
|
||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||
return Err("只能导入普通图片文件".to_string());
|
||
}
|
||
if metadata.len() > UI_EDITOR_IMAGE_MAX_FILE_SIZE {
|
||
return Err(format!(
|
||
"图片超过 {} 字节限制",
|
||
UI_EDITOR_IMAGE_MAX_FILE_SIZE
|
||
));
|
||
}
|
||
let bytes = fs::read(path).map_err(|e| format!("读取本地图片失败:{e}"))?;
|
||
if bytes.len() as u64 > UI_EDITOR_IMAGE_MAX_FILE_SIZE {
|
||
return Err(format!(
|
||
"图片超过 {} 字节限制",
|
||
UI_EDITOR_IMAGE_MAX_FILE_SIZE
|
||
));
|
||
}
|
||
total_size = total_size
|
||
.checked_add(bytes.len() as u64)
|
||
.filter(|size| *size <= UI_EDITOR_IMAGE_MAX_TOTAL_SIZE)
|
||
.ok_or_else(|| {
|
||
format!(
|
||
"图片批次总量超过 {} 字节限制",
|
||
UI_EDITOR_IMAGE_MAX_TOTAL_SIZE
|
||
)
|
||
})?;
|
||
let media_type = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||
"image/png"
|
||
} else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
|
||
"image/jpeg"
|
||
} else if bytes.starts_with(b"RIFF") && bytes.len() > 12 && &bytes[8..12] == b"WEBP" {
|
||
"image/webp"
|
||
} else {
|
||
return Err("本地文件不是受支持的 PNG/JPEG/WEBP 图片".to_string());
|
||
};
|
||
let name = path
|
||
.file_name()
|
||
.and_then(|v| v.to_str())
|
||
.unwrap_or("image")
|
||
.to_string();
|
||
inputs.push((name, media_type.to_string(), bytes));
|
||
}
|
||
let mut assets = Vec::with_capacity(inputs.len());
|
||
for (name, media, bytes) in inputs {
|
||
let asset = upload_local_asset_at(root, &name, &media, &bytes)?;
|
||
// 参考 save_ui_design_state_at:只有文件和 manifest 写入成功后才推进 revision。
|
||
// 每个成功项目独立提交,后续失败不会让已落盘项目停留在旧 revision。
|
||
advance_agent_runtime_project_revision_locked(root).map_err(|error| {
|
||
format!("reconciliation-required: 图片已导入,但项目 revision 未能推进:{error}")
|
||
})?;
|
||
assets.push(ImportedAsset {
|
||
id: asset.id,
|
||
local_path: asset.local_path,
|
||
asset_kind: None,
|
||
});
|
||
}
|
||
Ok(LocalImportResult { assets })
|
||
}
|
||
|
||
fn is_ui_editor_font_manifest_asset(asset: &GameCreationAppAssetManifestEntry) -> bool {
|
||
let media_type = asset.media_type.trim().to_ascii_lowercase();
|
||
let extension = Path::new(&asset.local_path)
|
||
.extension()
|
||
.and_then(|value| value.to_str())
|
||
.unwrap_or_default()
|
||
.to_ascii_lowercase();
|
||
matches!(
|
||
media_type.as_str(),
|
||
"font/ttf" | "font/otf" | "font/woff" | "font/woff2"
|
||
) || matches!(extension.as_str(), "ttf" | "otf" | "woff" | "woff2")
|
||
}
|
||
|
||
fn read_registered_ui_editor_font(
|
||
root: &Path,
|
||
asset: &GameCreationAppAssetManifestEntry,
|
||
) -> Result<(FontAsset, Vec<u8>), String> {
|
||
if !is_ui_editor_font_manifest_asset(asset) {
|
||
return Err("项目资产不是受支持的字体候选".to_string());
|
||
}
|
||
let target = resolve_local_project_path(root, &asset.local_path)?;
|
||
prepare_game_creator_private_path_for_read(&target, false, "项目字体")?;
|
||
let metadata = fs::symlink_metadata(&target).map_err(|_| "读取项目字体失败".to_string())?;
|
||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||
return Err("项目字体必须是普通文件".to_string());
|
||
}
|
||
if metadata.len() > UI_EDITOR_FONT_MAX_FILE_SIZE {
|
||
return Err("项目字体超过 8 MiB 限制".to_string());
|
||
}
|
||
let bytes = fs::read(&target).map_err(|_| "读取项目字体失败".to_string())?;
|
||
let source_file_name = target
|
||
.file_name()
|
||
.and_then(|value| value.to_str())
|
||
.unwrap_or("font")
|
||
.to_string();
|
||
let mut font = FontAsset::from_verified_bytes(
|
||
asset.id.clone(),
|
||
asset.local_path.clone(),
|
||
source_file_name,
|
||
&bytes,
|
||
)?;
|
||
if let Some(copied_name) = target.file_name().and_then(|value| value.to_str()) {
|
||
let hash_prefix = format!("{}-", font.content_sha256);
|
||
if let Some(original_name) = copied_name.strip_prefix(&hash_prefix) {
|
||
if !original_name.is_empty() {
|
||
font.metadata.source_file_name = original_name.to_string();
|
||
}
|
||
}
|
||
}
|
||
Ok((font, bytes))
|
||
}
|
||
|
||
fn find_registered_ui_editor_font<'a>(
|
||
manifest: &'a GameCreationAppManifest,
|
||
asset_id: &str,
|
||
relative_path: &str,
|
||
) -> Result<&'a GameCreationAppAssetManifestEntry, String> {
|
||
manifest
|
||
.assets
|
||
.iter()
|
||
.find(|asset| asset.id == asset_id && asset.local_path == relative_path)
|
||
.ok_or_else(|| "字体不是当前项目已登记资产".to_string())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn prepare_ui_editor_project_fonts(
|
||
project_path: String,
|
||
assets: Vec<ImportedAsset>,
|
||
) -> Result<Vec<FontAsset>, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "asset.list")?;
|
||
if assets.len() > UI_EDITOR_FONT_MAX_COUNT {
|
||
return Err("一次最多选择 64 个字体面".to_string());
|
||
}
|
||
let manifest = read_existing_manifest_for_project(root)?;
|
||
let mut fonts = Vec::with_capacity(assets.len());
|
||
for selected in assets {
|
||
let asset = find_registered_ui_editor_font(&manifest, &selected.id, &selected.local_path)?;
|
||
fonts.push(read_registered_ui_editor_font(root, asset)?.0);
|
||
}
|
||
Ok(fonts)
|
||
}
|
||
|
||
pub(crate) fn import_ui_editor_local_fonts(
|
||
project_path: String,
|
||
source_paths: Vec<String>,
|
||
) -> Result<LocalImportResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "asset.upload")?;
|
||
let _lock = acquire_project_write_lock(root, "asset.upload")?;
|
||
if source_paths.len() > UI_EDITOR_FONT_MAX_COUNT {
|
||
return Err("一次最多选择 64 个字体面".to_string());
|
||
}
|
||
|
||
let mut inputs = Vec::new();
|
||
let mut input_hashes = std::collections::BTreeSet::new();
|
||
for source in source_paths {
|
||
let path = Path::new(source.trim());
|
||
crate::prepare_game_creator_user_selected_path_for_read(path, false, "本地字体")?;
|
||
let metadata = fs::symlink_metadata(path).map_err(|_| "读取本地字体失败".to_string())?;
|
||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||
return Err("只能导入普通字体文件".to_string());
|
||
}
|
||
if metadata.len() > UI_EDITOR_FONT_MAX_FILE_SIZE {
|
||
return Err("字体超过 8 MiB 限制".to_string());
|
||
}
|
||
let bytes = fs::read(path).map_err(|_| "读取本地字体失败".to_string())?;
|
||
let source_file_name = path
|
||
.file_name()
|
||
.and_then(|value| value.to_str())
|
||
.unwrap_or("font")
|
||
.to_string();
|
||
let validated = FontAsset::from_verified_bytes(
|
||
"font-validation",
|
||
"assets/fonts/validation.ttf",
|
||
source_file_name.clone(),
|
||
&bytes,
|
||
)?;
|
||
if input_hashes.insert(validated.content_sha256.clone()) {
|
||
inputs.push((source_file_name, bytes, validated));
|
||
}
|
||
}
|
||
|
||
let manifest = read_existing_manifest_for_project(root)?;
|
||
let mut existing_by_hash = std::collections::BTreeMap::new();
|
||
let mut existing_total_size = 0u64;
|
||
let mut existing_count = 0usize;
|
||
for asset in manifest
|
||
.assets
|
||
.iter()
|
||
.filter(|asset| is_ui_editor_font_manifest_asset(asset))
|
||
{
|
||
let Ok((font, bytes)) = read_registered_ui_editor_font(root, asset) else {
|
||
continue;
|
||
};
|
||
existing_total_size = existing_total_size.saturating_add(bytes.len() as u64);
|
||
existing_count += 1;
|
||
existing_by_hash
|
||
.entry(font.content_sha256.clone())
|
||
.or_insert(font);
|
||
}
|
||
let new_inputs = inputs
|
||
.iter()
|
||
.filter(|(_, _, font)| !existing_by_hash.contains_key(&font.content_sha256))
|
||
.collect::<Vec<_>>();
|
||
let new_total_size = new_inputs.iter().fold(0u64, |total, (_, bytes, _)| {
|
||
total.saturating_add(bytes.len() as u64)
|
||
});
|
||
if existing_count.saturating_add(new_inputs.len()) > UI_EDITOR_FONT_MAX_COUNT {
|
||
return Err("项目字体面最多 64 个".to_string());
|
||
}
|
||
if existing_total_size.saturating_add(new_total_size) > UI_EDITOR_FONT_MAX_TOTAL_SIZE {
|
||
return Err("项目字体总量超过 32 MiB 限制".to_string());
|
||
}
|
||
|
||
if !new_inputs.is_empty() {
|
||
let font_root = root.join("assets/fonts");
|
||
ensure_game_creator_private_directory_tree(&font_root, "项目字体目录")?;
|
||
prepare_game_creator_private_path_for_read(&font_root, true, "项目字体目录")?;
|
||
}
|
||
// 字体批次同样是增量提交合同:已经复制并登记的字体在后续失败时保留。
|
||
let mut result = Vec::with_capacity(inputs.len());
|
||
for (source_file_name, bytes, validated) in inputs {
|
||
if let Some(existing) = existing_by_hash.get(&validated.content_sha256) {
|
||
result.push(existing.clone());
|
||
continue;
|
||
}
|
||
let safe_stem = sanitize_file_name(
|
||
Path::new(&source_file_name)
|
||
.file_stem()
|
||
.and_then(|value| value.to_str())
|
||
.unwrap_or("font"),
|
||
);
|
||
let relative_path = format!(
|
||
"assets/fonts/{}-{safe_stem}.{}",
|
||
validated.content_sha256,
|
||
validated.metadata.format.extension()
|
||
);
|
||
let target = resolve_local_project_path(root, &relative_path)?;
|
||
if prepare_game_creator_private_path_for_read(&target, false, "项目字体")? {
|
||
return Err(format!("项目字体目标已存在但未登记:{}", target.display()));
|
||
}
|
||
let mut options = fs::OpenOptions::new();
|
||
options.write(true).create_new(true);
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::fs::OpenOptionsExt;
|
||
options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
|
||
}
|
||
let mut file = options
|
||
.open(&target)
|
||
.map_err(|error| format!("写入项目字体失败:{}: {error}", target.display()))?;
|
||
if let Err(error) = harden_new_game_creator_private_path(&target, false, "项目字体") {
|
||
drop(file);
|
||
let _ = fs::remove_file(&target);
|
||
return Err(error);
|
||
}
|
||
file.write_all(&bytes)
|
||
.and_then(|_| file.sync_all())
|
||
.map_err(|error| format!("写入项目字体失败:{}: {error}", target.display()))?;
|
||
drop(file);
|
||
let registered = register_local_asset_entry(
|
||
root,
|
||
&relative_path,
|
||
"font",
|
||
validated.metadata.format.media_type(),
|
||
"font",
|
||
GameCreationAppAssetSource {
|
||
kind: GameCreationAppAssetSourceKind::Uploaded,
|
||
canvas_project_id: None,
|
||
resource_id: None,
|
||
asset_object_id: None,
|
||
task_id: None,
|
||
prompt: None,
|
||
model: None,
|
||
generation_route: None,
|
||
generation_kind: None,
|
||
reference_resource_ids: Vec::new(),
|
||
},
|
||
)?;
|
||
let font = FontAsset::from_verified_bytes(
|
||
registered.id,
|
||
registered.local_path,
|
||
source_file_name,
|
||
&bytes,
|
||
)?;
|
||
existing_by_hash.insert(font.content_sha256.clone(), font.clone());
|
||
result.push(font);
|
||
// 参考 save_ui_design_state_at:登记成功后才推进 revision;失败项不会伪造提交版本。
|
||
advance_agent_runtime_project_revision_locked(root).map_err(|error| {
|
||
format!("reconciliation-required: 字体已复制并登记,但项目 revision 未能推进:{error}")
|
||
})?;
|
||
}
|
||
Ok(LocalImportResult {
|
||
assets: result
|
||
.into_iter()
|
||
.map(|font| ImportedAsset {
|
||
id: font.asset_id.as_str().to_string(),
|
||
local_path: font.path,
|
||
asset_kind: Some("font".to_string()),
|
||
})
|
||
.collect(),
|
||
})
|
||
}
|
||
|
||
fn read_checked_ui_editor_font(
|
||
project_path: &str,
|
||
asset_id: &str,
|
||
relative_path: &str,
|
||
expected_sha256: &str,
|
||
) -> Result<(FontAsset, Vec<u8>), String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "file.read")?;
|
||
let manifest = read_existing_manifest_for_project(root)?;
|
||
let asset = find_registered_ui_editor_font(&manifest, asset_id, relative_path)?;
|
||
let (font, bytes) = read_registered_ui_editor_font(root, asset)?;
|
||
if font.content_sha256 != expected_sha256 {
|
||
return Err("字体文件内容与资源摘要不一致".to_string());
|
||
}
|
||
Ok((font, bytes))
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn read_ui_editor_font_bytes(
|
||
project_path: String,
|
||
asset_id: String,
|
||
relative_path: String,
|
||
expected_sha256: String,
|
||
) -> Result<tauri::ipc::Response, String> {
|
||
read_checked_ui_editor_font(
|
||
&project_path,
|
||
asset_id.trim(),
|
||
relative_path.trim(),
|
||
expected_sha256.trim(),
|
||
)
|
||
.map(|(_, bytes)| tauri::ipc::Response::new(bytes))
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn check_ui_editor_font_glyph_coverage(
|
||
project_path: String,
|
||
asset_id: String,
|
||
relative_path: String,
|
||
expected_sha256: String,
|
||
content: String,
|
||
) -> Result<bool, String> {
|
||
let (font, bytes) = read_checked_ui_editor_font(
|
||
&project_path,
|
||
asset_id.trim(),
|
||
relative_path.trim(),
|
||
expected_sha256.trim(),
|
||
)?;
|
||
font.has_missing_glyphs(&bytes, &content)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod ui_editor_font_tests {
|
||
use super::*;
|
||
|
||
fn fixture_font_path() -> PathBuf {
|
||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../public/fusion-pixel.ttf")
|
||
}
|
||
|
||
#[test]
|
||
fn registered_font_reader_requires_manifest_identity_and_verified_bytes() {
|
||
let project = tempfile::tempdir().expect("project tempdir");
|
||
let root = project.path();
|
||
init_local_game_project_at(root, "font-project", "Font Project")
|
||
.expect("initialize project");
|
||
let relative_path = "assets/fonts/fusion-pixel.ttf";
|
||
let target = root.join(relative_path);
|
||
fs::create_dir_all(target.parent().expect("font parent")).expect("create font parent");
|
||
fs::copy(fixture_font_path(), &target).expect("copy font fixture");
|
||
let registered = register_local_asset_entry(
|
||
root,
|
||
relative_path,
|
||
"font",
|
||
"font/ttf",
|
||
"font",
|
||
GameCreationAppAssetSource {
|
||
kind: GameCreationAppAssetSourceKind::Uploaded,
|
||
canvas_project_id: None,
|
||
resource_id: None,
|
||
asset_object_id: None,
|
||
task_id: None,
|
||
prompt: None,
|
||
model: None,
|
||
generation_route: None,
|
||
generation_kind: None,
|
||
reference_resource_ids: Vec::new(),
|
||
},
|
||
)
|
||
.expect("register font");
|
||
let manifest = read_existing_manifest_for_project(root).expect("read manifest");
|
||
let entry =
|
||
find_registered_ui_editor_font(&manifest, ®istered.id, ®istered.local_path)
|
||
.expect("find registered font");
|
||
let (font, bytes) = read_registered_ui_editor_font(root, entry).expect("read font");
|
||
|
||
assert_eq!(font.asset_id.as_str(), registered.id);
|
||
assert_eq!(font.path, relative_path);
|
||
assert_eq!(font.content_sha256.len(), 64);
|
||
assert!(!bytes.is_empty());
|
||
assert!(find_registered_ui_editor_font(&manifest, "missing", relative_path).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn local_font_import_copies_registers_and_reuses_identical_content() {
|
||
let project = tempfile::tempdir().expect("project tempdir");
|
||
let root = project.path();
|
||
init_local_game_project_at(root, "font-project", "Font Project")
|
||
.expect("initialize project");
|
||
let source_path = fixture_font_path().to_string_lossy().into_owned();
|
||
let revision_before = read_game_creator_agent_runtime_project_revision(root)
|
||
.expect("read initial revision")
|
||
.revision;
|
||
|
||
let first = import_ui_editor_local_fonts(
|
||
root.to_string_lossy().into_owned(),
|
||
vec![source_path.clone()],
|
||
)
|
||
.expect("import font");
|
||
assert_eq!(first.assets.len(), 1);
|
||
let imported = &first.assets[0];
|
||
assert!(imported.local_path.starts_with("assets/fonts/"));
|
||
assert!(imported.local_path.ends_with("-fusion-pixel.ttf"));
|
||
assert!(root.join(&imported.local_path).is_file());
|
||
let manifest = read_existing_manifest_for_project(root).expect("read manifest");
|
||
assert_eq!(
|
||
manifest
|
||
.assets
|
||
.iter()
|
||
.filter(|asset| is_ui_editor_font_manifest_asset(asset))
|
||
.count(),
|
||
1
|
||
);
|
||
assert!(manifest
|
||
.assets
|
||
.iter()
|
||
.any(|asset| { asset.id == imported.id && asset.local_path == imported.local_path }));
|
||
let revision_after_first = read_game_creator_agent_runtime_project_revision(root)
|
||
.expect("read imported revision")
|
||
.revision;
|
||
assert_eq!(revision_after_first, revision_before + 1);
|
||
|
||
let second =
|
||
import_ui_editor_local_fonts(root.to_string_lossy().into_owned(), vec![source_path])
|
||
.expect("reimport identical font");
|
||
assert_eq!(second, first);
|
||
let manifest = read_existing_manifest_for_project(root).expect("read reused manifest");
|
||
assert_eq!(
|
||
manifest
|
||
.assets
|
||
.iter()
|
||
.filter(|asset| is_ui_editor_font_manifest_asset(asset))
|
||
.count(),
|
||
1
|
||
);
|
||
assert_eq!(
|
||
read_game_creator_agent_runtime_project_revision(root)
|
||
.expect("read reused revision")
|
||
.revision,
|
||
revision_after_first
|
||
);
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn registered_font_reader_rejects_symlink() {
|
||
use std::os::unix::fs::symlink;
|
||
|
||
let project = tempfile::tempdir().expect("project tempdir");
|
||
let root = project.path();
|
||
let relative_path = "assets/fonts/linked.ttf";
|
||
let target = root.join(relative_path);
|
||
fs::create_dir_all(target.parent().expect("font parent")).expect("create font parent");
|
||
symlink(fixture_font_path(), &target).expect("create font symlink");
|
||
let entry = GameCreationAppAssetManifestEntry {
|
||
id: "font-linked".to_string(),
|
||
kind: "font".to_string(),
|
||
media_type: "font/ttf".to_string(),
|
||
local_path: relative_path.to_string(),
|
||
image_sequence_frames: None,
|
||
image_sequence_duration_ms: None,
|
||
source: GameCreationAppAssetSource {
|
||
kind: GameCreationAppAssetSourceKind::Uploaded,
|
||
canvas_project_id: None,
|
||
resource_id: None,
|
||
asset_object_id: None,
|
||
task_id: None,
|
||
prompt: None,
|
||
model: None,
|
||
generation_route: None,
|
||
generation_kind: None,
|
||
reference_resource_ids: Vec::new(),
|
||
},
|
||
};
|
||
|
||
assert_eq!(
|
||
read_registered_ui_editor_font(root, &entry).expect_err("reject symlink"),
|
||
"项目文件路径不能包含符号链接或 Windows reparse point"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod remote_asset_path_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn remote_asset_paths_distinguish_ids_that_sanitize_to_the_same_name() {
|
||
let slash_path = remote_asset_local_path("a/b", "png");
|
||
let colon_path = remote_asset_local_path("a:b", "png");
|
||
|
||
assert_ne!(slash_path, colon_path);
|
||
assert_eq!(slash_path, remote_asset_local_path("a/b", "png"));
|
||
assert!(slash_path.starts_with("assets/uploads/remote-a_b-"));
|
||
assert!(slash_path.ends_with(".png"));
|
||
}
|
||
|
||
#[test]
|
||
fn duplicate_remote_asset_destination_is_rejected() {
|
||
let mut destinations = HashSet::new();
|
||
let local_path = remote_asset_local_path("stable-id", "webp");
|
||
|
||
reserve_remote_asset_destination(&mut destinations, &local_path)
|
||
.expect("reserve first destination");
|
||
assert_eq!(
|
||
reserve_remote_asset_destination(&mut destinations, &local_path)
|
||
.expect_err("reject duplicate destination"),
|
||
format!("平台素材目标路径重复:{local_path}")
|
||
);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod agent_asset_import_tests {
|
||
use super::*;
|
||
|
||
fn tiny_png() -> Vec<u8> {
|
||
vec![0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']
|
||
}
|
||
|
||
#[test]
|
||
fn account_library_parser_filters_non_static_media_and_projects_safe_metadata() {
|
||
let payload = serde_json::json!({
|
||
"data": {
|
||
"library": {
|
||
"folders": [{"folderId": "folder-1", "label": "UI"}],
|
||
"assets": [
|
||
{
|
||
"assetId": "image-1",
|
||
"folderId": "folder-1",
|
||
"label": "按钮",
|
||
"imageSrc": "/assets/button.png",
|
||
"objectKey": "private/button.png",
|
||
"assetKind": "icon",
|
||
"width": 64,
|
||
"height": 64
|
||
},
|
||
{
|
||
"assetId": "video-1",
|
||
"imageSrc": "/video.mp4",
|
||
"assetKind": "video"
|
||
},
|
||
{
|
||
"assetId": "audio-1",
|
||
"imageSrc": "/audio.mp3",
|
||
"sourceType": "audio"
|
||
},
|
||
{
|
||
"assetId": "sequence-1",
|
||
"imageSrc": "/frame.png",
|
||
"imageSequenceFrames": []
|
||
}
|
||
]
|
||
}
|
||
}
|
||
});
|
||
|
||
let records = parse_agent_editor_asset_library(&payload).expect("parse library");
|
||
assert_eq!(records.len(), 1);
|
||
assert_eq!(records[0].asset_id, "image-1");
|
||
assert_eq!(records[0].folder_label.as_deref(), Some("UI"));
|
||
assert_eq!(records[0].object_key.as_deref(), Some("private/button.png"));
|
||
}
|
||
|
||
#[test]
|
||
fn project_resource_parser_requires_matching_project_and_filters_sequences() {
|
||
let payload = serde_json::json!({
|
||
"ok": true,
|
||
"data": {
|
||
"project": {
|
||
"projectId": "project-1",
|
||
"resources": [
|
||
{
|
||
"resourceId": "resource-1",
|
||
"imageSrc": "/resource.png",
|
||
"objectKey": "private/resource.png",
|
||
"assetKind": "scene",
|
||
"width": 320,
|
||
"height": 180
|
||
},
|
||
{
|
||
"resourceId": "resource-video",
|
||
"imageSrc": "/video.mp4",
|
||
"assetKind": "video"
|
||
},
|
||
{
|
||
"resourceId": "resource-sequence",
|
||
"imageSrc": "/frame.png",
|
||
"imageSequenceFrames": []
|
||
}
|
||
]
|
||
}
|
||
}
|
||
});
|
||
|
||
let records = parse_agent_editor_project_resources(&payload, "project-1")
|
||
.expect("parse project resources");
|
||
assert_eq!(records.len(), 1);
|
||
assert_eq!(records[0].asset_id, "resource-1");
|
||
assert_eq!(records[0].origin, AgentEditorAssetOrigin::ProjectCanvas);
|
||
assert_eq!(records[0].canvas_project_id.as_deref(), Some("project-1"));
|
||
assert!(parse_agent_editor_project_resources(&payload, "project-other").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn local_project_asset_import_registers_multiple_types_and_is_idempotent() {
|
||
let project = tempfile::tempdir().expect("create project directory");
|
||
let root = project.path();
|
||
init_local_game_project_at(root, "agent-local-import", "Agent local import")
|
||
.expect("initialize project");
|
||
fs::create_dir_all(root.join("assets")).expect("create assets directory");
|
||
fs::create_dir_all(root.join("game")).expect("create game directory");
|
||
fs::write(root.join("assets/in-place.png"), tiny_png()).expect("write in-place image");
|
||
fs::write(root.join("game/copied.png"), tiny_png()).expect("write game image");
|
||
fs::write(root.join("assets/theme.mp3"), b"fake-mp3").expect("write audio");
|
||
fs::write(root.join("assets/intro.mp4"), b"fake-mp4").expect("write video");
|
||
fs::write(root.join("game/index.html"), b"<html></html>").expect("write code");
|
||
fs::write(root.join("assets/design.md"), b"# design").expect("write document");
|
||
|
||
let revision_before = read_game_creator_agent_runtime_project_revision(root)
|
||
.expect("read initial revision")
|
||
.revision;
|
||
let first = import_local_project_assets_for_agent(
|
||
root,
|
||
&[
|
||
"assets/in-place.png".to_string(),
|
||
"game/copied.png".to_string(),
|
||
"assets/theme.mp3".to_string(),
|
||
"assets/intro.mp4".to_string(),
|
||
"game/index.html".to_string(),
|
||
"assets/design.md".to_string(),
|
||
],
|
||
)
|
||
.expect("import local assets");
|
||
assert_eq!(first.assets.len(), 6);
|
||
assert_eq!(first.assets[0].local_path, "assets/in-place.png");
|
||
assert!(first.assets[1]
|
||
.local_path
|
||
.starts_with("assets/uploads/local-"));
|
||
assert!(first.assets[1].local_path.ends_with(".png"));
|
||
assert_eq!(first.assets[0].asset_kind.as_deref(), Some("ui"));
|
||
assert_eq!(first.assets[2].asset_kind.as_deref(), Some("audio"));
|
||
assert_eq!(first.assets[3].asset_kind.as_deref(), Some("video"));
|
||
assert_eq!(first.assets[4].asset_kind.as_deref(), Some("code"));
|
||
assert_eq!(first.assets[5].asset_kind.as_deref(), Some("document"));
|
||
assert!(first.assets[4].local_path.ends_with(".html"));
|
||
assert!(root.join(&first.assets[1].local_path).is_file());
|
||
let revision_after_first = read_game_creator_agent_runtime_project_revision(root)
|
||
.expect("read imported revision")
|
||
.revision;
|
||
assert_eq!(revision_after_first, revision_before + 6);
|
||
|
||
let second = import_local_project_assets_for_agent(
|
||
root,
|
||
&[
|
||
"assets/in-place.png".to_string(),
|
||
"game/copied.png".to_string(),
|
||
"assets/theme.mp3".to_string(),
|
||
"assets/intro.mp4".to_string(),
|
||
"game/index.html".to_string(),
|
||
"assets/design.md".to_string(),
|
||
],
|
||
)
|
||
.expect("reimport local images");
|
||
assert_eq!(second.assets, first.assets);
|
||
assert_eq!(
|
||
read_game_creator_agent_runtime_project_revision(root)
|
||
.expect("read idempotent revision")
|
||
.revision,
|
||
revision_after_first
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn local_project_asset_import_rejects_absolute_and_case_insensitive_agent_paths() {
|
||
let project = tempfile::tempdir().expect("create project directory");
|
||
let root = project.path();
|
||
init_local_game_project_at(root, "agent-local-import", "Agent local import")
|
||
.expect("initialize project");
|
||
assert!(import_local_project_assets_for_agent(
|
||
root,
|
||
&[root.join("image.png").to_string_lossy().into_owned()]
|
||
)
|
||
.is_err());
|
||
assert!(
|
||
import_local_project_assets_for_agent(root, &[".AGENT/manifest.json".to_string()])
|
||
.is_err()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn local_project_asset_import_rejects_hidden_and_build_tree_sources() {
|
||
let project = tempfile::tempdir().expect("create project directory");
|
||
let root = project.path();
|
||
init_local_game_project_at(root, "agent-local-import", "Agent local import")
|
||
.expect("initialize project");
|
||
for (index, directory) in [
|
||
".git",
|
||
".codex",
|
||
"node_modules",
|
||
"target",
|
||
"dist",
|
||
"build",
|
||
"coverage",
|
||
]
|
||
.into_iter()
|
||
.enumerate()
|
||
{
|
||
let relative = format!("{directory}/image-{index}.png");
|
||
let source = root.join(&relative);
|
||
fs::create_dir_all(source.parent().expect("source parent"))
|
||
.expect("create forbidden source directory");
|
||
fs::write(&source, tiny_png()).expect("write forbidden source image");
|
||
assert!(
|
||
import_local_project_assets_for_agent(root, &[relative.clone()]).is_err(),
|
||
"forbidden source path should be rejected: {relative}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn local_project_asset_import_rejects_unknown_and_invalid_text_files() {
|
||
let project = tempfile::tempdir().expect("create project directory");
|
||
let root = project.path();
|
||
init_local_game_project_at(root, "agent-local-import", "Agent local import")
|
||
.expect("initialize project");
|
||
fs::create_dir_all(root.join("assets")).expect("create assets directory");
|
||
fs::write(root.join("assets/unknown.bin"), b"bytes").expect("write unknown file");
|
||
fs::write(root.join("assets/broken.js"), [0xff, 0xfe]).expect("write invalid source");
|
||
assert!(
|
||
import_local_project_assets_for_agent(root, &["assets/unknown.bin".to_string()])
|
||
.is_err()
|
||
);
|
||
assert!(
|
||
import_local_project_assets_for_agent(root, &["assets/broken.js".to_string()]).is_err()
|
||
);
|
||
}
|
||
}
|
||
|
||
fn remote_asset_local_path(asset_id: &str, extension: &str) -> String {
|
||
let readable_id = sanitize_file_name(asset_id);
|
||
let identity_digest = format!("{:x}", Sha256::digest(asset_id.as_bytes()));
|
||
let short_digest = &identity_digest[..12];
|
||
format!("assets/uploads/remote-{readable_id}-{short_digest}.{extension}")
|
||
}
|
||
|
||
fn reserve_remote_asset_destination(
|
||
destinations: &mut HashSet<String>,
|
||
local_path: &str,
|
||
) -> Result<(), String> {
|
||
if destinations.insert(local_path.to_string()) {
|
||
Ok(())
|
||
} else {
|
||
Err(format!("平台素材目标路径重复:{local_path}"))
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
enum AgentEditorAssetOrigin {
|
||
AccountLibrary,
|
||
ProjectCanvas,
|
||
}
|
||
|
||
/// 账户素材库或当前网页项目画布中的一项内部记录。
|
||
///
|
||
/// 这个类型故意不实现 `Serialize`/`Debug`:`object_key` 与 `image_src` 只用于本地
|
||
/// 客户端向受控的 `read-url` 换签,不能随 Agent 上下文、日志或 IPC 投影出去。
|
||
#[derive(Clone)]
|
||
struct AgentEditorAssetRecord {
|
||
asset_id: String,
|
||
origin: AgentEditorAssetOrigin,
|
||
canvas_project_id: Option<String>,
|
||
folder_id: Option<String>,
|
||
folder_label: Option<String>,
|
||
label: String,
|
||
object_key: Option<String>,
|
||
image_src: Option<String>,
|
||
asset_object_id: Option<String>,
|
||
asset_kind: Option<String>,
|
||
source_type: Option<String>,
|
||
width: Option<u32>,
|
||
height: Option<u32>,
|
||
size_bytes: Option<u64>,
|
||
}
|
||
|
||
fn bounded_agent_editor_asset_id(value: &str) -> Result<String, String> {
|
||
let value = value.trim();
|
||
if value.is_empty()
|
||
|| value.chars().count() > AGENT_EDITOR_ASSET_ID_MAX_CHARS
|
||
|| value.chars().any(char::is_control)
|
||
{
|
||
return Err("账户素材缺少有效的稳定 assetId".to_string());
|
||
}
|
||
Ok(value.to_string())
|
||
}
|
||
|
||
fn safe_agent_editor_asset_label(value: Option<String>, fallback: &str) -> String {
|
||
let value = value
|
||
.unwrap_or_default()
|
||
.chars()
|
||
.filter(|character| !character.is_control())
|
||
.take(120)
|
||
.collect::<String>()
|
||
.trim()
|
||
.to_string();
|
||
if value.is_empty() {
|
||
fallback.chars().take(120).collect()
|
||
} else {
|
||
value
|
||
}
|
||
}
|
||
|
||
fn account_asset_kind_is_static_image(asset: &serde_json::Value) -> bool {
|
||
if asset
|
||
.get("imageSequenceFrames")
|
||
.is_some_and(serde_json::Value::is_array)
|
||
{
|
||
return false;
|
||
}
|
||
// `assetKind` is authoritative when present, while older library rows may only
|
||
// carry `sourceType`/MIME metadata. Reject every known non-raster signal before
|
||
// exposing an ID to the Agent; the downloaded magic bytes remain the final gate.
|
||
[
|
||
"assetKind",
|
||
"sourceType",
|
||
"mediaType",
|
||
"mimeType",
|
||
"contentType",
|
||
]
|
||
.into_iter()
|
||
.filter_map(|field| json_string_field(asset, field))
|
||
.map(|value| value.to_ascii_lowercase())
|
||
.all(|value| {
|
||
!value.contains("video")
|
||
&& !value.contains("audio")
|
||
&& !value.contains("sound")
|
||
&& !value.contains("music")
|
||
&& !value.contains("animation")
|
||
&& !value.contains("sequence")
|
||
})
|
||
}
|
||
|
||
fn parse_agent_editor_asset_library(
|
||
payload: &serde_json::Value,
|
||
) -> Result<Vec<AgentEditorAssetRecord>, String> {
|
||
let data = external_editor_response_data(payload);
|
||
let library = data
|
||
.get("library")
|
||
.or_else(|| payload.get("library"))
|
||
.ok_or_else(|| "账户素材库响应缺少 library".to_string())?;
|
||
let folders = library
|
||
.get("folders")
|
||
.and_then(serde_json::Value::as_array)
|
||
.ok_or_else(|| "账户素材库响应缺少 library.folders".to_string())?;
|
||
let folder_labels = folders
|
||
.iter()
|
||
.filter_map(|folder| {
|
||
let id = json_string_field(folder, "folderId")?;
|
||
Some((
|
||
id,
|
||
safe_agent_editor_asset_label(json_string_field(folder, "label"), "未分类"),
|
||
))
|
||
})
|
||
.collect::<BTreeMap<_, _>>();
|
||
let assets = library
|
||
.get("assets")
|
||
.and_then(serde_json::Value::as_array)
|
||
.map(Vec::as_slice)
|
||
.unwrap_or_default();
|
||
if assets.len() > AGENT_EDITOR_ASSET_LIBRARY_MAX_ITEMS {
|
||
return Err(format!(
|
||
"账户素材库数量超过 {} 项安全上限",
|
||
AGENT_EDITOR_ASSET_LIBRARY_MAX_ITEMS
|
||
));
|
||
}
|
||
|
||
let mut records = Vec::with_capacity(assets.len());
|
||
let mut seen_ids = std::collections::BTreeSet::new();
|
||
for asset in assets {
|
||
if !asset.is_object() || !account_asset_kind_is_static_image(asset) {
|
||
continue;
|
||
}
|
||
let Some(asset_id) = json_string_field(asset, "assetId")
|
||
.map(|value| bounded_agent_editor_asset_id(&value))
|
||
.transpose()?
|
||
else {
|
||
// 只有 objectKey/imageSrc 而没有业务 assetId 的记录不能安全地交给 Agent
|
||
// 作为可导入身份;它们仍可由 UI Importer 按原有流程处理。
|
||
continue;
|
||
};
|
||
if !seen_ids.insert(asset_id.clone()) {
|
||
continue;
|
||
}
|
||
let object_key = json_string_field(asset, "objectKey");
|
||
let image_src = json_string_field(asset, "imageSrc");
|
||
if object_key.is_none() && image_src.is_none() {
|
||
continue;
|
||
}
|
||
let folder_id = json_string_field(asset, "folderId");
|
||
let folder_label = folder_id
|
||
.as_ref()
|
||
.and_then(|id| folder_labels.get(id).cloned());
|
||
let label =
|
||
safe_agent_editor_asset_label(json_string_field(asset, "label"), asset_id.as_str());
|
||
let width = asset
|
||
.get("width")
|
||
.and_then(serde_json::Value::as_u64)
|
||
.and_then(|value| u32::try_from(value).ok());
|
||
let height = asset
|
||
.get("height")
|
||
.and_then(serde_json::Value::as_u64)
|
||
.and_then(|value| u32::try_from(value).ok());
|
||
let size_bytes = asset
|
||
.get("sizeBytes")
|
||
.or_else(|| asset.get("size"))
|
||
.and_then(serde_json::Value::as_u64);
|
||
records.push(AgentEditorAssetRecord {
|
||
asset_id,
|
||
origin: AgentEditorAssetOrigin::AccountLibrary,
|
||
canvas_project_id: None,
|
||
folder_id,
|
||
folder_label,
|
||
label,
|
||
object_key,
|
||
image_src,
|
||
asset_object_id: json_string_field(asset, "assetObjectId"),
|
||
asset_kind: json_string_field(asset, "assetKind"),
|
||
source_type: json_string_field(asset, "sourceType"),
|
||
width,
|
||
height,
|
||
size_bytes,
|
||
});
|
||
}
|
||
records.sort_by(|left, right| {
|
||
(
|
||
left.folder_label.as_deref().unwrap_or(""),
|
||
left.label.as_str(),
|
||
left.asset_id.as_str(),
|
||
)
|
||
.cmp(&(
|
||
right.folder_label.as_deref().unwrap_or(""),
|
||
right.label.as_str(),
|
||
right.asset_id.as_str(),
|
||
))
|
||
});
|
||
Ok(records)
|
||
}
|
||
|
||
fn parse_agent_editor_project_resources(
|
||
payload: &serde_json::Value,
|
||
canvas_project_id: &str,
|
||
) -> Result<Vec<AgentEditorAssetRecord>, String> {
|
||
let data = external_editor_response_data(payload);
|
||
let project = data
|
||
.get("project")
|
||
.or_else(|| payload.pointer("/data/project"))
|
||
.ok_or_else(|| "网页项目响应缺少 project".to_string())?;
|
||
let returned_project_id = json_string_field(project, "projectId")
|
||
.ok_or_else(|| "网页项目响应缺少 projectId".to_string())?;
|
||
if returned_project_id != canvas_project_id {
|
||
return Err("网页项目响应的 projectId 与请求不一致,已拒绝使用".to_string());
|
||
}
|
||
let resources = project
|
||
.get("resources")
|
||
.and_then(serde_json::Value::as_array)
|
||
.ok_or_else(|| "网页项目响应缺少 resources".to_string())?;
|
||
if resources.len() > AGENT_EDITOR_ASSET_LIBRARY_MAX_ITEMS {
|
||
return Err(format!(
|
||
"网页项目画布资源数量超过 {} 项安全上限",
|
||
AGENT_EDITOR_ASSET_LIBRARY_MAX_ITEMS
|
||
));
|
||
}
|
||
|
||
let mut records = Vec::with_capacity(resources.len());
|
||
let mut seen_ids = std::collections::BTreeSet::new();
|
||
for resource in resources {
|
||
if !resource.is_object() || !account_asset_kind_is_static_image(resource) {
|
||
continue;
|
||
}
|
||
let Some(resource_id) = json_string_field(resource, "resourceId")
|
||
.map(|value| bounded_agent_editor_asset_id(&value))
|
||
.transpose()?
|
||
else {
|
||
continue;
|
||
};
|
||
if !seen_ids.insert(resource_id.clone()) {
|
||
continue;
|
||
}
|
||
let object_key = json_string_field(resource, "objectKey");
|
||
let image_src = json_string_field(resource, "imageSrc");
|
||
if object_key.is_none() && image_src.is_none() {
|
||
continue;
|
||
}
|
||
let asset_kind = json_string_field(resource, "assetKind");
|
||
let label = safe_agent_editor_asset_label(
|
||
json_string_field(resource, "label"),
|
||
asset_kind.as_deref().unwrap_or(resource_id.as_str()),
|
||
);
|
||
let width = resource
|
||
.get("width")
|
||
.and_then(serde_json::Value::as_u64)
|
||
.and_then(|value| u32::try_from(value).ok());
|
||
let height = resource
|
||
.get("height")
|
||
.and_then(serde_json::Value::as_u64)
|
||
.and_then(|value| u32::try_from(value).ok());
|
||
let size_bytes = resource
|
||
.get("sizeBytes")
|
||
.or_else(|| resource.get("size"))
|
||
.and_then(serde_json::Value::as_u64);
|
||
records.push(AgentEditorAssetRecord {
|
||
asset_id: resource_id,
|
||
origin: AgentEditorAssetOrigin::ProjectCanvas,
|
||
canvas_project_id: Some(canvas_project_id.to_string()),
|
||
folder_id: None,
|
||
folder_label: None,
|
||
label,
|
||
object_key,
|
||
image_src,
|
||
asset_object_id: json_string_field(resource, "assetObjectId"),
|
||
asset_kind,
|
||
source_type: json_string_field(resource, "sourceType"),
|
||
width,
|
||
height,
|
||
size_bytes,
|
||
});
|
||
}
|
||
records.sort_by(|left, right| {
|
||
(left.label.as_str(), left.asset_id.as_str())
|
||
.cmp(&(right.label.as_str(), right.asset_id.as_str()))
|
||
});
|
||
Ok(records)
|
||
}
|
||
|
||
fn agent_editor_canvas_project_ids(root: &Path) -> Result<Vec<String>, String> {
|
||
let manifest = read_existing_manifest_for_project(root)?;
|
||
let mut project_ids = std::collections::BTreeSet::new();
|
||
for asset in manifest.assets {
|
||
if asset.source.kind != GameCreationAppAssetSourceKind::Canvas {
|
||
continue;
|
||
}
|
||
if let Some(project_id) = asset
|
||
.source
|
||
.canvas_project_id
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
{
|
||
let project_id = bounded_agent_editor_asset_id(project_id)
|
||
.map_err(|_| "本地 manifest 的画布项目 ID 无效".to_string())?;
|
||
project_ids.insert(project_id);
|
||
}
|
||
}
|
||
if project_ids.len() > 8 {
|
||
return Err("当前项目关联的网页画布超过 8 个,拒绝批量读取".to_string());
|
||
}
|
||
Ok(project_ids.into_iter().collect())
|
||
}
|
||
|
||
async fn fetch_agent_editor_asset_records(
|
||
root: Option<&Path>,
|
||
) -> Result<
|
||
(
|
||
String,
|
||
String,
|
||
Option<PlatformSessionSnapshot>,
|
||
Vec<AgentEditorAssetRecord>,
|
||
),
|
||
String,
|
||
> {
|
||
let (api_base_url, bearer_token, frozen_session) =
|
||
resolve_canvas_sync_api_credentials(None, None)?;
|
||
let access =
|
||
ExternalEditorBindingAccess::new(&api_base_url, &bearer_token, frozen_session.as_ref())?;
|
||
let client = crate::http_client::agc_main_site_client_builder()
|
||
.connect_timeout(std::time::Duration::from_secs(10))
|
||
.timeout(std::time::Duration::from_secs(60))
|
||
.redirect(reqwest::redirect::Policy::none())
|
||
.build()
|
||
.map_err(|error| format!("创建账户素材客户端失败:{error}"))?;
|
||
access.validate_frozen_session()?;
|
||
let library_payload = external_editor_json_request(
|
||
client
|
||
.get(format!(
|
||
"{}{}",
|
||
access.api_base_url(),
|
||
access.api_route("/api/external/v1/editor/assets/library")
|
||
))
|
||
.bearer_auth(access.bearer_token()),
|
||
"读取账户素材库",
|
||
)
|
||
.await?;
|
||
access.validate_frozen_session()?;
|
||
let mut records = parse_agent_editor_asset_library(&library_payload)?;
|
||
|
||
if let Some(root) = root {
|
||
let mut project_ids = agent_editor_canvas_project_ids(root)?
|
||
.into_iter()
|
||
.collect::<std::collections::BTreeSet<_>>();
|
||
// 新建本地项目可能尚未把任何远端资源写入 manifest,但已经完成了
|
||
// External Editor project binding;优先读取这个按账号隔离的私有 binding,
|
||
// 不接受模型提交的 projectId。
|
||
let manifest = read_existing_manifest_for_project(root)?;
|
||
let principal = external_editor_binding_principal(&access)?;
|
||
if let Some(binding) =
|
||
read_external_editor_project_binding_at(root, &manifest.project_id, &principal)?
|
||
{
|
||
let bound_project_id = bounded_agent_editor_asset_id(&binding.remote_project_id)
|
||
.map_err(|_| "网页项目 binding 的远端 projectId 无效".to_string())?;
|
||
project_ids.insert(bound_project_id);
|
||
}
|
||
if project_ids.len() > 8 {
|
||
return Err("当前项目关联的网页画布超过 8 个,拒绝批量读取".to_string());
|
||
}
|
||
for canvas_project_id in project_ids {
|
||
access.validate_frozen_session()?;
|
||
let payload = external_editor_json_request(
|
||
client
|
||
.get(format!(
|
||
"{}{}",
|
||
access.api_base_url(),
|
||
access.api_route(&format!(
|
||
"/api/external/v1/editor/projects/{}",
|
||
percent_encode_query_component(&canvas_project_id)
|
||
))
|
||
))
|
||
.bearer_auth(access.bearer_token()),
|
||
"读取网页项目画布资源",
|
||
)
|
||
.await?;
|
||
access.validate_frozen_session()?;
|
||
records.extend(parse_agent_editor_project_resources(
|
||
&payload,
|
||
&canvas_project_id,
|
||
)?);
|
||
}
|
||
}
|
||
records.sort_by(|left, right| {
|
||
(
|
||
left.origin as u8,
|
||
left.folder_label.as_deref().unwrap_or(""),
|
||
left.label.as_str(),
|
||
left.asset_id.as_str(),
|
||
)
|
||
.cmp(&(
|
||
right.origin as u8,
|
||
right.folder_label.as_deref().unwrap_or(""),
|
||
right.label.as_str(),
|
||
right.asset_id.as_str(),
|
||
))
|
||
});
|
||
Ok((api_base_url, bearer_token, frozen_session, records))
|
||
}
|
||
|
||
async fn fetch_agent_editor_asset_library() -> Result<
|
||
(
|
||
String,
|
||
String,
|
||
Option<PlatformSessionSnapshot>,
|
||
Vec<AgentEditorAssetRecord>,
|
||
),
|
||
String,
|
||
> {
|
||
fetch_agent_editor_asset_records(None).await
|
||
}
|
||
|
||
/// 给普通 Agent/Direct Codex 的账户素材安全投影。只返回业务 ID 与展示元数据,
|
||
/// 不返回 objectKey、imageSrc、signedUrl、绝对路径、provider 或凭据。
|
||
pub(crate) async fn list_account_editor_assets_for_agent() -> Result<serde_json::Value, String> {
|
||
let (_api_base_url, _bearer_token, _session, records) =
|
||
fetch_agent_editor_asset_library().await?;
|
||
let assets = records
|
||
.iter()
|
||
.map(|asset| {
|
||
serde_json::json!({
|
||
"assetId": asset.asset_id,
|
||
"label": asset.label,
|
||
"folderId": asset.folder_id,
|
||
"folderLabel": asset.folder_label,
|
||
"assetKind": asset.asset_kind,
|
||
"sourceType": asset.source_type,
|
||
"width": asset.width,
|
||
"height": asset.height,
|
||
"sizeBytes": asset.size_bytes,
|
||
})
|
||
})
|
||
.collect::<Vec<_>>();
|
||
Ok(serde_json::json!({
|
||
"status": "completed",
|
||
"total": assets.len(),
|
||
"assets": assets,
|
||
"next": "使用返回的 assetId 调用 canvas.asset_import;不要提交 objectKey、URL 或本地绝对路径"
|
||
}))
|
||
}
|
||
|
||
/// 给 Agent 的统一安全投影:当前账号素材库 + 已绑定网页项目画布资源。
|
||
/// `assets` 中的 project-canvas 项仍只暴露 resourceId 作为 assetId,不暴露媒体地址。
|
||
pub(crate) async fn list_editor_assets_for_agent_at(
|
||
root: &Path,
|
||
) -> Result<serde_json::Value, String> {
|
||
let (_api_base_url, _bearer_token, _session, records) =
|
||
fetch_agent_editor_asset_records(Some(root)).await?;
|
||
let assets = records
|
||
.iter()
|
||
.map(|asset| {
|
||
let source = match asset.origin {
|
||
AgentEditorAssetOrigin::AccountLibrary => "account",
|
||
AgentEditorAssetOrigin::ProjectCanvas => "project-canvas",
|
||
};
|
||
serde_json::json!({
|
||
"assetId": asset.asset_id,
|
||
"resourceId": (asset.origin == AgentEditorAssetOrigin::ProjectCanvas).then_some(&asset.asset_id),
|
||
"source": source,
|
||
"canvasProjectId": asset.canvas_project_id,
|
||
"label": asset.label,
|
||
"folderId": asset.folder_id,
|
||
"folderLabel": asset.folder_label,
|
||
"assetKind": asset.asset_kind,
|
||
"sourceType": asset.source_type,
|
||
"width": asset.width,
|
||
"height": asset.height,
|
||
"sizeBytes": asset.size_bytes,
|
||
})
|
||
})
|
||
.collect::<Vec<_>>();
|
||
Ok(serde_json::json!({
|
||
"status": "completed",
|
||
"total": assets.len(),
|
||
"assets": assets,
|
||
"next": "账户素材使用 assetId;网页项目画布资源也使用返回的 resourceId/assetId;本地资源先用 file.list,再把项目相对路径交给 canvas.asset_import;不要提交 objectKey、URL 或本地绝对路径"
|
||
}))
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
struct AgentLocalProjectFileType {
|
||
category: &'static str,
|
||
asset_kind: &'static str,
|
||
media_type: &'static str,
|
||
max_file_size: u64,
|
||
}
|
||
|
||
fn agent_image_media_type(bytes: &[u8]) -> Option<&'static str> {
|
||
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||
Some("image/png")
|
||
} else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
|
||
Some("image/jpeg")
|
||
} else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
|
||
Some("image/webp")
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
fn agent_local_project_file_type(
|
||
relative_path: &str,
|
||
bytes: &[u8],
|
||
) -> Result<AgentLocalProjectFileType, String> {
|
||
let extension = Path::new(relative_path)
|
||
.extension()
|
||
.and_then(|value| value.to_str())
|
||
.unwrap_or_default()
|
||
.to_ascii_lowercase();
|
||
let file_type = match extension.as_str() {
|
||
"png" | "jpg" | "jpeg" | "webp" | "gif" | "svg" | "avif" | "bmp" => {
|
||
let media_type = match extension.as_str() {
|
||
"png" => agent_image_media_type(bytes),
|
||
"jpg" | "jpeg" => bytes
|
||
.starts_with(&[0xff, 0xd8, 0xff])
|
||
.then_some("image/jpeg"),
|
||
"webp" => (bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP")
|
||
.then_some("image/webp"),
|
||
"gif" => (bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a"))
|
||
.then_some("image/gif"),
|
||
"svg" => std::str::from_utf8(bytes)
|
||
.ok()
|
||
.filter(|text| text.to_ascii_lowercase().contains("<svg"))
|
||
.map(|_| "image/svg+xml"),
|
||
"avif" => (!bytes.is_empty()).then_some("image/avif"),
|
||
"bmp" => bytes.starts_with(b"BM").then_some("image/bmp"),
|
||
_ => None,
|
||
};
|
||
media_type.map(|media_type| AgentLocalProjectFileType {
|
||
category: "image",
|
||
asset_kind: "ui",
|
||
media_type,
|
||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||
})
|
||
}
|
||
"ttf" | "otf" | "woff" | "woff2" => Some(AgentLocalProjectFileType {
|
||
category: "font",
|
||
asset_kind: "font",
|
||
media_type: match extension.as_str() {
|
||
"ttf" => "font/ttf",
|
||
"otf" => "font/otf",
|
||
"woff" => "font/woff",
|
||
_ => "font/woff2",
|
||
},
|
||
max_file_size: UI_EDITOR_FONT_MAX_FILE_SIZE,
|
||
}),
|
||
"mp3" => Some(AgentLocalProjectFileType {
|
||
category: "audio",
|
||
asset_kind: "audio",
|
||
media_type: "audio/mpeg",
|
||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||
}),
|
||
"wav" => Some(AgentLocalProjectFileType {
|
||
category: "audio",
|
||
asset_kind: "audio",
|
||
media_type: "audio/wav",
|
||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||
}),
|
||
"ogg" => Some(AgentLocalProjectFileType {
|
||
category: "audio",
|
||
asset_kind: "audio",
|
||
media_type: "audio/ogg",
|
||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||
}),
|
||
"flac" => Some(AgentLocalProjectFileType {
|
||
category: "audio",
|
||
asset_kind: "audio",
|
||
media_type: "audio/flac",
|
||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||
}),
|
||
"m4a" => Some(AgentLocalProjectFileType {
|
||
category: "audio",
|
||
asset_kind: "audio",
|
||
media_type: "audio/mp4",
|
||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||
}),
|
||
"aac" => Some(AgentLocalProjectFileType {
|
||
category: "audio",
|
||
asset_kind: "audio",
|
||
media_type: "audio/aac",
|
||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||
}),
|
||
"opus" => Some(AgentLocalProjectFileType {
|
||
category: "audio",
|
||
asset_kind: "audio",
|
||
media_type: "audio/opus",
|
||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||
}),
|
||
"mp4" => Some(AgentLocalProjectFileType {
|
||
category: "video",
|
||
asset_kind: "video",
|
||
media_type: "video/mp4",
|
||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||
}),
|
||
"webm" => Some(AgentLocalProjectFileType {
|
||
category: "video",
|
||
asset_kind: "video",
|
||
media_type: "video/webm",
|
||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||
}),
|
||
"mov" => Some(AgentLocalProjectFileType {
|
||
category: "video",
|
||
asset_kind: "video",
|
||
media_type: "video/quicktime",
|
||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||
}),
|
||
"md" | "markdown" | "mdx" | "txt" | "json" | "yaml" | "yml" | "toml" | "csv" | "ini"
|
||
| "conf" | "xml" => Some(AgentLocalProjectFileType {
|
||
category: "document",
|
||
asset_kind: "document",
|
||
media_type: if extension == "json" {
|
||
"application/json"
|
||
} else if matches!(extension.as_str(), "yaml" | "yml") {
|
||
"application/yaml"
|
||
} else if extension == "xml" {
|
||
"application/xml"
|
||
} else {
|
||
"text/plain"
|
||
},
|
||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||
}),
|
||
"html" | "htm" | "css" | "scss" | "less" | "js" | "mjs" | "cjs" | "ts" | "tsx" | "gd"
|
||
| "rs" | "py" | "go" | "java" | "kt" | "kts" | "c" | "cc" | "cpp" | "h" | "hpp" | "cs"
|
||
| "swift" | "php" | "rb" | "lua" | "sh" | "bash" | "zsh" | "sql" | "graphql" | "gql"
|
||
| "vue" | "svelte" => Some(AgentLocalProjectFileType {
|
||
category: "code",
|
||
asset_kind: "code",
|
||
media_type: if matches!(extension.as_str(), "html" | "htm") {
|
||
"text/html"
|
||
} else if matches!(extension.as_str(), "css" | "scss" | "less") {
|
||
"text/css"
|
||
} else {
|
||
"text/plain"
|
||
},
|
||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||
}),
|
||
_ => None,
|
||
};
|
||
let file_type = file_type.ok_or_else(|| format!("本地文件类型不受支持:{relative_path}"))?;
|
||
if file_type.category == "font" {
|
||
FontAsset::from_verified_bytes(
|
||
"font-validation",
|
||
"assets/fonts/validation.ttf",
|
||
Path::new(relative_path)
|
||
.file_name()
|
||
.and_then(|value| value.to_str())
|
||
.unwrap_or("font"),
|
||
bytes,
|
||
)?;
|
||
} else if bytes.is_empty() {
|
||
return Err(format!("本地文件不能为空:{relative_path}"));
|
||
} else if matches!(file_type.category, "document" | "code")
|
||
&& std::str::from_utf8(bytes).is_err()
|
||
{
|
||
return Err(format!("本地文本文件不是有效 UTF-8:{relative_path}"));
|
||
}
|
||
Ok(file_type)
|
||
}
|
||
|
||
fn local_agent_asset_destination(relative_path: &str, bytes: &[u8], extension: &str) -> String {
|
||
let digest = format!("{:x}", Sha256::digest(bytes));
|
||
let stem = Path::new(relative_path)
|
||
.file_stem()
|
||
.and_then(|value| value.to_str())
|
||
.map(sanitize_file_name)
|
||
.filter(|value| !value.is_empty())
|
||
.unwrap_or_else(|| "image".to_string());
|
||
format!(
|
||
"assets/uploads/local-{stem}-{}.{}",
|
||
&digest[..12],
|
||
extension
|
||
)
|
||
}
|
||
|
||
fn reject_agent_local_resource_source_path(normalized_path: &str) -> Result<(), String> {
|
||
if should_skip_project_snapshot_path(normalized_path)
|
||
|| normalized_path
|
||
.split('/')
|
||
.any(|part| part.eq_ignore_ascii_case(".codex"))
|
||
{
|
||
return Err("本地资源导入不得访问隐藏、构建或工具控制目录".to_string());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 从当前项目根目录内的相对路径导入未登记资源。Agent 不能提交宿主绝对路径,
|
||
/// 也不能穿越项目根;路径外文件仍由 UI 原生文件选择器导入。
|
||
pub(crate) fn import_local_project_assets_for_agent(
|
||
root: &Path,
|
||
relative_paths: &[String],
|
||
) -> Result<RemoteImportResult, String> {
|
||
enforce_project_permission_policy(root, "canvas.asset_import")?;
|
||
validate_project_root(root)?;
|
||
if relative_paths.is_empty() {
|
||
return Err("本地资源导入至少需要一个项目相对路径".to_string());
|
||
}
|
||
if relative_paths.len() > UI_EDITOR_IMAGE_MAX_COUNT {
|
||
return Err(format!(
|
||
"一次最多导入 {} 个本地资源",
|
||
UI_EDITOR_IMAGE_MAX_COUNT
|
||
));
|
||
}
|
||
let mut seen = std::collections::BTreeSet::new();
|
||
let mut destinations = std::collections::BTreeSet::new();
|
||
let mut inputs = Vec::with_capacity(relative_paths.len());
|
||
let mut total_size = 0u64;
|
||
for raw_path in relative_paths {
|
||
let normalized = normalize_relative_path(raw_path.trim())?;
|
||
reject_agent_runtime_private_control_path(&normalized)?;
|
||
reject_sensitive_project_file_read(&normalized)?;
|
||
reject_agent_local_resource_source_path(&normalized)?;
|
||
let source = resolve_local_project_path(root, &normalized)?;
|
||
prepare_game_creator_private_path_for_read(&source, false, "本地资源")?;
|
||
let metadata =
|
||
fs::symlink_metadata(&source).map_err(|_| format!("本地资源不存在:{normalized}"))?;
|
||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||
return Err(format!("本地素材只能是普通文件:{normalized}"));
|
||
}
|
||
let bytes = fs::read(&source).map_err(|_| format!("读取本地资源失败:{normalized}"))?;
|
||
let file_type = agent_local_project_file_type(&normalized, &bytes)?;
|
||
if metadata.len() > file_type.max_file_size {
|
||
return Err(format!(
|
||
"本地资源超过单文件 {} 字节限制:{normalized}",
|
||
file_type.max_file_size
|
||
));
|
||
}
|
||
total_size = total_size
|
||
.checked_add(bytes.len() as u64)
|
||
.filter(|size| *size <= UI_EDITOR_IMAGE_MAX_TOTAL_SIZE)
|
||
.ok_or_else(|| "本地资源批次总量超过 256 MiB 限制".to_string())?;
|
||
if !seen.insert(normalized.clone()) {
|
||
continue;
|
||
}
|
||
let extension = infer_file_extension(Some(&normalized), file_type.media_type);
|
||
let local_path = if normalized.starts_with("assets/") {
|
||
normalized.clone()
|
||
} else {
|
||
local_agent_asset_destination(&normalized, &bytes, extension).to_string()
|
||
};
|
||
if !destinations.insert(local_path.clone()) {
|
||
continue;
|
||
}
|
||
inputs.push((
|
||
local_path,
|
||
file_type.asset_kind.to_string(),
|
||
file_type.media_type.to_string(),
|
||
bytes,
|
||
));
|
||
}
|
||
|
||
let _lock = acquire_project_write_lock(root, "canvas.asset_import")?;
|
||
let manifest = read_existing_manifest_for_project(root)?;
|
||
let mut imported = Vec::with_capacity(inputs.len());
|
||
for (local_path, asset_kind, media_type, bytes) in inputs {
|
||
let target = resolve_local_project_path(root, &local_path)?;
|
||
if let Some(existing) = manifest
|
||
.assets
|
||
.iter()
|
||
.find(|asset| asset.local_path == local_path)
|
||
{
|
||
imported.push(ImportedAsset {
|
||
id: existing.id.clone(),
|
||
local_path: existing.local_path.clone(),
|
||
asset_kind: Some(existing.kind.clone()),
|
||
});
|
||
continue;
|
||
}
|
||
if target.exists() {
|
||
prepare_game_creator_private_path_for_read(&target, false, "目标资源")?;
|
||
let existing_bytes = fs::read(&target).map_err(|_| "读取目标资源失败".to_string())?;
|
||
if existing_bytes != bytes {
|
||
return Err(format!("本地资源目标已存在且内容不同:{local_path}"));
|
||
}
|
||
} else {
|
||
if let Some(parent) = target.parent() {
|
||
ensure_game_creator_private_directory_tree(parent, "本地资源导入目录")?;
|
||
prepare_game_creator_private_path_for_read(parent, true, "本地资源导入目录")?;
|
||
}
|
||
let mut options = fs::OpenOptions::new();
|
||
options.write(true).create_new(true);
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::fs::OpenOptionsExt;
|
||
options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
|
||
}
|
||
let mut file = options
|
||
.open(&target)
|
||
.map_err(|error| format!("写入本地资源失败:{}: {error}", target.display()))?;
|
||
if let Err(error) = harden_new_game_creator_private_path(&target, false, "目标资源")
|
||
{
|
||
drop(file);
|
||
let _ = fs::remove_file(&target);
|
||
return Err(error);
|
||
}
|
||
file.write_all(&bytes)
|
||
.and_then(|_| file.sync_all())
|
||
.map_err(|error| format!("写入本地资源失败:{}: {error}", target.display()))?;
|
||
drop(file);
|
||
}
|
||
let registered = register_local_asset_entry(
|
||
root,
|
||
&local_path,
|
||
&asset_kind,
|
||
&media_type,
|
||
"local",
|
||
GameCreationAppAssetSource {
|
||
kind: GameCreationAppAssetSourceKind::Uploaded,
|
||
canvas_project_id: None,
|
||
resource_id: None,
|
||
asset_object_id: None,
|
||
task_id: None,
|
||
prompt: None,
|
||
model: None,
|
||
generation_route: Some("agent.local-asset-import".to_string()),
|
||
generation_kind: None,
|
||
reference_resource_ids: Vec::new(),
|
||
},
|
||
)?;
|
||
imported.push(ImportedAsset {
|
||
id: registered.id,
|
||
local_path: registered.local_path,
|
||
asset_kind: Some(asset_kind),
|
||
});
|
||
advance_agent_runtime_project_revision_locked(root).map_err(|error| {
|
||
format!("reconciliation-required: 本地资源已登记,但项目 revision 未能推进:{error}")
|
||
})?;
|
||
}
|
||
Ok(RemoteImportResult { assets: imported })
|
||
}
|
||
|
||
/// 按账户素材 `assetId` 查询权威素材、换签下载并登记到当前项目。模型只提交
|
||
/// `assetId`,objectKey/URL/Token 始终由本函数在客户端内部解析。
|
||
pub(crate) async fn import_account_editor_assets_for_agent(
|
||
root: &Path,
|
||
asset_ids: &[String],
|
||
) -> Result<RemoteImportResult, String> {
|
||
enforce_project_permission_policy(root, "canvas.asset_import")?;
|
||
validate_project_root(root)?;
|
||
if asset_ids.is_empty() {
|
||
return Err("账户图片导入至少需要一个 assetId".to_string());
|
||
}
|
||
if asset_ids.len() > UI_EDITOR_IMAGE_MAX_COUNT {
|
||
return Err(format!("一次最多导入 {} 张图片", UI_EDITOR_IMAGE_MAX_COUNT));
|
||
}
|
||
let mut requested = Vec::with_capacity(asset_ids.len());
|
||
let mut seen = std::collections::BTreeSet::new();
|
||
for value in asset_ids {
|
||
let id = bounded_agent_editor_asset_id(value)?;
|
||
if seen.insert(id.clone()) {
|
||
requested.push(id);
|
||
}
|
||
}
|
||
|
||
let (api_base_url, bearer_token, frozen_session, records) =
|
||
fetch_agent_editor_asset_records(Some(root)).await?;
|
||
let mut by_id = BTreeMap::<String, AgentEditorAssetRecord>::new();
|
||
for record in records {
|
||
if by_id.insert(record.asset_id.clone(), record).is_some() {
|
||
return Err("账户素材与网页项目画布存在相同稳定 ID,已拒绝不明确导入".to_string());
|
||
}
|
||
}
|
||
let mut selected = Vec::with_capacity(requested.len());
|
||
for id in requested {
|
||
selected.push(
|
||
by_id.remove(&id).ok_or_else(|| {
|
||
"账户素材不存在、已删除或不属于当前登录账号,未执行导入".to_string()
|
||
})?,
|
||
);
|
||
}
|
||
let access =
|
||
ExternalEditorBindingAccess::new(&api_base_url, &bearer_token, frozen_session.as_ref())?;
|
||
let client = crate::http_client::agc_main_site_client_builder()
|
||
.connect_timeout(std::time::Duration::from_secs(10))
|
||
.timeout(std::time::Duration::from_secs(60))
|
||
.redirect(reqwest::redirect::Policy::none())
|
||
.build()
|
||
.map_err(|error| format!("创建账户图片下载客户端失败:{error}"))?;
|
||
let mut total_size = 0u64;
|
||
let mut destinations = HashSet::with_capacity(selected.len());
|
||
let mut downloads = Vec::with_capacity(selected.len());
|
||
for record in selected {
|
||
let source = serde_json::json!({
|
||
"objectKey": record.object_key.as_deref(),
|
||
"imageSrc": record.image_src.as_deref(),
|
||
});
|
||
let remaining = UI_EDITOR_IMAGE_MAX_TOTAL_SIZE.saturating_sub(total_size);
|
||
let read_url_route = access.api_route("/api/external/v1/assets/read-url");
|
||
let download = resolve_canvas_resource_download_with_limit_route_and_fence(
|
||
&client,
|
||
&api_base_url,
|
||
&bearer_token,
|
||
&source,
|
||
remaining as usize,
|
||
&read_url_route,
|
||
|| access.validate_frozen_session(),
|
||
)
|
||
.await?
|
||
.ok_or_else(|| "账户图片缺少可下载内容,未执行导入".to_string())?;
|
||
// Content-Type 来自远端响应,不能单独作为安全依据;以已下载字节的
|
||
// magic 校验结果作为最终媒体类型,避免 octet-stream 或伪造头部绕过限制。
|
||
let media_type = agent_image_media_type(&download.bytes)
|
||
.ok_or_else(|| "账户素材不是受支持的 PNG/JPEG/WEBP 图片,未执行导入".to_string())?
|
||
.to_string();
|
||
total_size = total_size
|
||
.checked_add(download.bytes.len() as u64)
|
||
.filter(|size| *size <= UI_EDITOR_IMAGE_MAX_TOTAL_SIZE)
|
||
.ok_or_else(|| "账户图片批次总量超过 256 MiB 限制".to_string())?;
|
||
let extension = infer_file_extension(
|
||
record.object_key.as_deref().or(record.image_src.as_deref()),
|
||
&media_type,
|
||
);
|
||
let local_path = remote_asset_local_path(&record.asset_id, extension);
|
||
reserve_remote_asset_destination(&mut destinations, &local_path)?;
|
||
downloads.push((record, media_type, local_path, download.bytes));
|
||
}
|
||
|
||
access.validate_frozen_session()?;
|
||
let _platform_session_lease = frozen_session
|
||
.as_ref()
|
||
.map(|session| {
|
||
acquire_validated_platform_session_fingerprint(
|
||
&session.user_id,
|
||
&session.api_base_url,
|
||
session.generation,
|
||
&format!("{:x}", Sha256::digest(session.access_token.as_bytes())),
|
||
)
|
||
})
|
||
.transpose()?;
|
||
let _lock = acquire_project_write_lock(root, "canvas.asset_import")?;
|
||
access.validate_frozen_session()?;
|
||
let manifest = read_existing_manifest_for_project(root)?;
|
||
let mut imported = Vec::with_capacity(downloads.len());
|
||
for (record, media_type, local_path, bytes) in downloads {
|
||
let target = resolve_local_project_path(root, &local_path)?;
|
||
if let Some(existing) = manifest.assets.iter().find(|asset| {
|
||
asset.local_path == local_path
|
||
|| asset.source.resource_id.as_deref() == Some(record.asset_id.as_str())
|
||
}) {
|
||
imported.push(ImportedAsset {
|
||
id: existing.id.clone(),
|
||
local_path: existing.local_path.clone(),
|
||
asset_kind: Some(existing.kind.clone()),
|
||
});
|
||
continue;
|
||
}
|
||
if target.exists() {
|
||
prepare_game_creator_private_path_for_read(&target, false, "账户图片目标")?;
|
||
return Err(format!("账户图片目标已存在但尚未登记:{local_path}"));
|
||
}
|
||
if let Some(parent) = target.parent() {
|
||
ensure_game_creator_private_directory_tree(parent, "账户图片导入目录")?;
|
||
prepare_game_creator_private_path_for_read(parent, true, "账户图片导入目录")?;
|
||
}
|
||
let mut options = fs::OpenOptions::new();
|
||
options.write(true).create_new(true);
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::fs::OpenOptionsExt;
|
||
options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
|
||
}
|
||
let mut file = options
|
||
.open(&target)
|
||
.map_err(|error| format!("写入账户图片失败:{}: {error}", target.display()))?;
|
||
if let Err(error) = harden_new_game_creator_private_path(&target, false, "账户图片目标")
|
||
{
|
||
drop(file);
|
||
let _ = fs::remove_file(&target);
|
||
return Err(error);
|
||
}
|
||
file.write_all(&bytes)
|
||
.and_then(|_| file.sync_all())
|
||
.map_err(|error| format!("写入账户图片失败:{}: {error}", target.display()))?;
|
||
drop(file);
|
||
let (source_kind, canvas_project_id, generation_route) = match record.origin {
|
||
AgentEditorAssetOrigin::AccountLibrary => (
|
||
GameCreationAppAssetSourceKind::Canvas,
|
||
None,
|
||
"editor.asset-library.agent-import",
|
||
),
|
||
AgentEditorAssetOrigin::ProjectCanvas => (
|
||
GameCreationAppAssetSourceKind::Canvas,
|
||
record.canvas_project_id.clone(),
|
||
"editor.project-canvas.agent-import",
|
||
),
|
||
};
|
||
let registered = register_local_asset_entry(
|
||
root,
|
||
&local_path,
|
||
"ui",
|
||
&media_type,
|
||
"canvas",
|
||
GameCreationAppAssetSource {
|
||
kind: source_kind,
|
||
canvas_project_id,
|
||
resource_id: Some(record.asset_id.clone()),
|
||
asset_object_id: record.asset_object_id.clone(),
|
||
task_id: None,
|
||
prompt: None,
|
||
model: None,
|
||
generation_route: Some(generation_route.to_string()),
|
||
generation_kind: None,
|
||
reference_resource_ids: Vec::new(),
|
||
},
|
||
)?;
|
||
imported.push(ImportedAsset {
|
||
id: registered.id,
|
||
local_path: registered.local_path,
|
||
asset_kind: record.asset_kind,
|
||
});
|
||
advance_agent_runtime_project_revision_locked(root).map_err(|error| {
|
||
format!(
|
||
"reconciliation-required: 账户图片已导入并登记,但项目 revision 未能推进:{error}"
|
||
)
|
||
})?;
|
||
}
|
||
Ok(RemoteImportResult { assets: imported })
|
||
}
|
||
|
||
pub(crate) async fn import_ui_editor_remote_assets(
|
||
project_path: String,
|
||
assets: Vec<serde_json::Value>,
|
||
) -> Result<RemoteImportResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "canvas.asset_import")?;
|
||
if assets.len() > UI_EDITOR_IMAGE_MAX_COUNT {
|
||
return Err(format!("一次最多选择 {UI_EDITOR_IMAGE_MAX_COUNT} 张图片"));
|
||
}
|
||
// IPC 只传稳定引用/签名 URL,由 Rust 流式下载并限制响应体;下载期间不占有项目写锁。
|
||
let mut total_size = 0u64;
|
||
let mut downloads = Vec::with_capacity(assets.len());
|
||
let mut destinations = HashSet::with_capacity(assets.len());
|
||
for asset in assets {
|
||
let asset_id = json_string_field(&asset, "assetId")
|
||
.or_else(|| json_string_field(&asset, "objectKey"))
|
||
.ok_or_else(|| "平台素材缺少稳定 assetId/objectKey,拒绝导入".to_string())?;
|
||
let download_url = json_string_field(&asset, "downloadUrl")
|
||
.ok_or_else(|| format!("平台素材 {asset_id} 缺少 downloadUrl"))?;
|
||
let remaining = UI_EDITOR_IMAGE_MAX_TOTAL_SIZE.saturating_sub(total_size);
|
||
let bytes = download_ui_editor_remote_asset(&download_url, remaining).await?;
|
||
total_size = total_size
|
||
.checked_add(bytes.len() as u64)
|
||
.filter(|size| *size <= UI_EDITOR_IMAGE_MAX_TOTAL_SIZE)
|
||
.ok_or_else(|| {
|
||
format!(
|
||
"图片批次总量超过 {} 字节限制",
|
||
UI_EDITOR_IMAGE_MAX_TOTAL_SIZE
|
||
)
|
||
})?;
|
||
let media_type = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||
"image/png"
|
||
} else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
|
||
"image/jpeg"
|
||
} else if bytes.starts_with(b"RIFF") && bytes.len() > 12 && &bytes[8..12] == b"WEBP" {
|
||
"image/webp"
|
||
} else {
|
||
return Err("平台素材不是受支持的 PNG/JPEG/WEBP 图片".to_string());
|
||
};
|
||
let extension = infer_file_extension(
|
||
json_string_field(&asset, "objectKey")
|
||
.or_else(|| json_string_field(&asset, "imageSrc"))
|
||
.as_deref(),
|
||
media_type,
|
||
);
|
||
let local_path = remote_asset_local_path(&asset_id, extension);
|
||
reserve_remote_asset_destination(&mut destinations, &local_path)?;
|
||
downloads.push((asset, asset_id, media_type.to_string(), local_path, bytes));
|
||
}
|
||
|
||
let _lock = acquire_project_write_lock(root, "canvas.asset_import")?;
|
||
// 远程素材导入保持与本地图片/字体相同的增量语义,不对已成功项目文件做整批回滚。
|
||
let mut imported = Vec::with_capacity(downloads.len());
|
||
for (asset, asset_id, media_type, local_path, bytes) in downloads {
|
||
let target = resolve_local_project_path(root, &local_path)?;
|
||
if let Some(parent) = target.parent() {
|
||
ensure_game_creator_private_directory_tree(parent, "平台素材导入目录")?;
|
||
prepare_game_creator_private_path_for_read(parent, true, "平台素材导入目录")?;
|
||
}
|
||
if prepare_game_creator_private_path_for_read(&target, false, "平台素材")? {
|
||
return Err(format!("平台素材目标已存在但尚未登记:{local_path}"));
|
||
}
|
||
let mut options = fs::OpenOptions::new();
|
||
options.write(true).create_new(true);
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::fs::OpenOptionsExt;
|
||
options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
|
||
}
|
||
let mut file = options
|
||
.open(&target)
|
||
.map_err(|error| format!("写入平台素材失败:{e}", e = error))?;
|
||
if let Err(error) = harden_new_game_creator_private_path(&target, false, "平台素材") {
|
||
drop(file);
|
||
let _ = fs::remove_file(&target);
|
||
return Err(error);
|
||
}
|
||
file.write_all(&bytes)
|
||
.and_then(|_| file.sync_all())
|
||
.map_err(|error| format!("写入平台素材失败:{error}"))?;
|
||
drop(file);
|
||
let registered = register_local_asset_entry(
|
||
root,
|
||
&local_path,
|
||
"ui",
|
||
&media_type,
|
||
"canvas",
|
||
GameCreationAppAssetSource {
|
||
kind: GameCreationAppAssetSourceKind::Canvas,
|
||
canvas_project_id: None,
|
||
resource_id: json_string_field(&asset, "assetId"),
|
||
asset_object_id: json_string_field(&asset, "assetObjectId"),
|
||
task_id: None,
|
||
prompt: None,
|
||
model: None,
|
||
generation_route: Some("editor.asset-library.import".to_string()),
|
||
generation_kind: None,
|
||
reference_resource_ids: Vec::new(),
|
||
},
|
||
)?;
|
||
imported.push(ImportedAsset {
|
||
id: registered.id,
|
||
local_path: registered.local_path,
|
||
asset_kind: json_string_field(&asset, "assetKind"),
|
||
});
|
||
// 参考 save_ui_design_state_at:远程文件与 manifest 成功后才推进 revision。
|
||
advance_agent_runtime_project_revision_locked(root).map_err(|error| {
|
||
format!("reconciliation-required: 远程素材已导入,但项目 revision 未能推进:{error}")
|
||
})?;
|
||
}
|
||
Ok(RemoteImportResult { assets: imported })
|
||
}
|
||
|
||
async fn download_ui_editor_remote_asset(url: &str, max_bytes: u64) -> Result<Vec<u8>, String> {
|
||
if max_bytes == 0 {
|
||
return Err("图片批次总量已达到服务端限制".to_string());
|
||
}
|
||
let parsed = validate_external_asset_download_url(url, "", false)?;
|
||
let client = build_external_asset_download_client(&parsed, "", false).await?;
|
||
let mut response = client
|
||
.get(parsed)
|
||
.send()
|
||
.await
|
||
.map_err(|error| format!("下载平台素材失败:{error}"))?;
|
||
if response.status().is_redirection() {
|
||
return Err("平台素材下载地址发生重定向,已拒绝继续请求".to_string());
|
||
}
|
||
if !response.status().is_success() {
|
||
return Err(format!(
|
||
"下载平台素材失败:HTTP {}",
|
||
response.status().as_u16()
|
||
));
|
||
}
|
||
if response
|
||
.content_length()
|
||
.is_some_and(|size| size > UI_EDITOR_IMAGE_MAX_FILE_SIZE || size > max_bytes)
|
||
{
|
||
return Err(format!(
|
||
"平台素材超过单文件 {} 或批次剩余 {} 字节限制",
|
||
UI_EDITOR_IMAGE_MAX_FILE_SIZE, max_bytes
|
||
));
|
||
}
|
||
let capacity = response
|
||
.content_length()
|
||
.and_then(|size| usize::try_from(size).ok())
|
||
.unwrap_or_default()
|
||
.min(UI_EDITOR_IMAGE_MAX_FILE_SIZE as usize);
|
||
let mut bytes = Vec::with_capacity(capacity);
|
||
while let Some(chunk) = response
|
||
.chunk()
|
||
.await
|
||
.map_err(|error| format!("读取平台素材失败:{error}"))?
|
||
{
|
||
let next_size = bytes.len().saturating_add(chunk.len());
|
||
if next_size as u64 > UI_EDITOR_IMAGE_MAX_FILE_SIZE || next_size as u64 > max_bytes {
|
||
return Err("平台素材超过服务端下载限制".to_string());
|
||
}
|
||
bytes.extend_from_slice(&chunk);
|
||
}
|
||
Ok(bytes)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn generate_platform_art_asset(
|
||
project_path: String,
|
||
prompt: String,
|
||
) -> Result<UploadLocalAssetResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "canvas.asset_generate")?;
|
||
let generated = generate_platform_art_asset_at(root, prompt.trim(), &[]).await?;
|
||
Ok(generated.asset)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn open_canvas_project(
|
||
app: tauri::AppHandle,
|
||
canvas_project_id: Option<String>,
|
||
editor_base_url: Option<String>,
|
||
) -> Result<OpenCanvasProjectResult, String> {
|
||
let url = build_canvas_project_url(editor_base_url.as_deref(), canvas_project_id.as_deref())?;
|
||
app.opener()
|
||
.open_url(&url, None::<&str>)
|
||
.map_err(|error| format!("打开画板失败:{error}"))?;
|
||
Ok(OpenCanvasProjectResult { url })
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn get_game_creation_agent_capabilities() -> Vec<GameCreationAgentCapabilityDescriptor> {
|
||
GAME_CREATION_AGENT_CAPABILITIES.to_vec()
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn get_limited_local_commands() -> Vec<GameCreationAppLimitedRunCommandDescriptor> {
|
||
GAME_CREATION_APP_LIMITED_RUN_COMMANDS.to_vec()
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn run_limited_local_command(
|
||
project_path: String,
|
||
command_id: String,
|
||
) -> Result<LimitedLocalCommandResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
let command_id = command_id.trim();
|
||
enforce_project_permission_policy(root, "command.run_limited")?;
|
||
let _lock = acquire_project_write_lock(root, "command.run_limited")?;
|
||
let result = run_limited_local_command_at(root, command_id)?;
|
||
if command_id == "game.static_smoke" {
|
||
append_static_smoke_manual_trace_step(root, &result)?;
|
||
}
|
||
Ok(result)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn append_local_permission_log(
|
||
project_path: String,
|
||
event: String,
|
||
command_id: String,
|
||
) -> Result<(), String> {
|
||
append_local_permission_log_at(
|
||
Path::new(project_path.trim()),
|
||
event.trim(),
|
||
command_id.trim(),
|
||
)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn list_local_project_files(
|
||
project_path: String,
|
||
) -> Result<ListLocalProjectFilesResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "file.list")?;
|
||
list_local_project_files_at(root)
|
||
}
|
||
|
||
/// 登记当前项目中已经存在、但尚未写入 manifest 的本地资源。
|
||
///
|
||
/// 该入口只接受项目根相对路径;实际文件签名、大小、敏感路径、项目锁和
|
||
/// manifest/revision 更新统一复用 Agent 的受控导入实现。未登记文件在调用前
|
||
/// 只能作为候选展示,不能由前端构造 asset ID。
|
||
#[tauri::command]
|
||
pub(crate) async fn import_local_project_image_assets(
|
||
project_path: String,
|
||
relative_paths: Vec<String>,
|
||
) -> Result<LocalImportResult, String> {
|
||
let root = PathBuf::from(project_path.trim());
|
||
tokio::task::spawn_blocking(move || {
|
||
import_local_project_assets_for_agent(&root, &relative_paths)
|
||
})
|
||
.await
|
||
.map_err(|error| format!("项目内资源登记任务意外终止:{error}"))
|
||
.and_then(|result| result)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn read_local_project_file(
|
||
project_path: String,
|
||
relative_path: String,
|
||
command_id: Option<String>,
|
||
) -> Result<LocalProjectFileResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
let normalized_path = normalize_relative_path(relative_path.trim())?;
|
||
let command_id = command_id
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.unwrap_or("file.read");
|
||
if command_id == "agent.trace_read" {
|
||
if !is_agent_trace_read_path(&normalized_path) {
|
||
return Err("agent.trace_read 只能读取 Agent run trace".to_string());
|
||
}
|
||
} else if command_id != "file.read" {
|
||
return Err(format!("不支持通过文件读取执行命令:{command_id}"));
|
||
}
|
||
enforce_project_permission_policy(root, command_id)?;
|
||
read_local_project_file_at(root, &normalized_path)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn read_local_project_image_preview(
|
||
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
|
||
project_path: String,
|
||
relative_path: String,
|
||
scope_id: String,
|
||
request_id: String,
|
||
) -> Result<LocalProjectImagePreview, String> {
|
||
preview_manager
|
||
.run(&scope_id, &request_id, move |cancellation| {
|
||
read_local_project_image_preview_at(&project_path, &relative_path, cancellation)
|
||
})
|
||
.await
|
||
}
|
||
|
||
pub(crate) fn read_local_project_image_preview_at(
|
||
project_path: &str,
|
||
relative_path: &str,
|
||
cancellation: &ProjectResourcePreviewScopeCancellation,
|
||
) -> Result<LocalProjectImagePreview, String> {
|
||
cancellation.check()?;
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_auto_permission_policy(root, "file.read")?;
|
||
cancellation.check()?;
|
||
let normalized_path = normalize_relative_path(relative_path.trim())?;
|
||
let manifest = read_manifest(&root.join(".agent/manifest.json"))?;
|
||
cancellation.check()?;
|
||
let is_registered_asset = manifest
|
||
.assets
|
||
.iter()
|
||
.any(|asset| asset.local_path == normalized_path);
|
||
let is_completed_task_artifact = manifest.tasks.iter().any(|task| {
|
||
task.status == GameCreationAppTaskStatus::Completed
|
||
&& task.artifacts.iter().any(|path| path == &normalized_path)
|
||
});
|
||
if !is_registered_asset && !is_completed_task_artifact {
|
||
return Err("只能预览已登记资源或已完成任务的图片产物".to_string());
|
||
}
|
||
cancellation.check()?;
|
||
load_local_project_image_preview_with_cancellation(root, &normalized_path, cancellation)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn read_local_project_text_preview(
|
||
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
|
||
project_path: String,
|
||
relative_path: String,
|
||
scope_id: String,
|
||
request_id: String,
|
||
) -> Result<LocalProjectTextPreview, String> {
|
||
preview_manager
|
||
.run(&scope_id, &request_id, move |cancellation| {
|
||
read_local_project_text_preview_at(&project_path, &relative_path, cancellation)
|
||
})
|
||
.await
|
||
}
|
||
|
||
pub(crate) fn read_local_project_text_preview_at(
|
||
project_path: &str,
|
||
relative_path: &str,
|
||
cancellation: &ProjectResourcePreviewScopeCancellation,
|
||
) -> Result<LocalProjectTextPreview, String> {
|
||
cancellation.check()?;
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_auto_permission_policy(root, "file.read")?;
|
||
cancellation.check()?;
|
||
let normalized_path = normalize_relative_path(relative_path.trim())?;
|
||
let manifest = read_manifest(&root.join(".agent/manifest.json"))?;
|
||
cancellation.check()?;
|
||
let is_registered_document = manifest.assets.iter().any(|asset| {
|
||
asset.local_path == normalized_path
|
||
&& is_supported_project_text_resource(&asset.local_path, &asset.media_type)
|
||
}) || manifest.tasks.iter().any(|task| {
|
||
task.status == GameCreationAppTaskStatus::Completed
|
||
&& task.artifacts.iter().any(|path| path == &normalized_path)
|
||
&& is_supported_project_text_resource(&normalized_path, "")
|
||
});
|
||
if !is_registered_document {
|
||
return Err("只能读取当前项目已登记的文档资源".to_string());
|
||
}
|
||
cancellation.check()?;
|
||
load_local_project_text_preview_with_cancellation(root, &normalized_path, cancellation)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn read_local_project_media_preview(
|
||
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
|
||
project_path: String,
|
||
relative_path: String,
|
||
category: String,
|
||
scope_id: String,
|
||
request_id: String,
|
||
) -> Result<LocalProjectMediaPreview, String> {
|
||
preview_manager
|
||
.run(&scope_id, &request_id, move |cancellation| {
|
||
read_local_project_media_preview_at(
|
||
&project_path,
|
||
&relative_path,
|
||
&category,
|
||
cancellation,
|
||
)
|
||
})
|
||
.await
|
||
}
|
||
|
||
pub(crate) fn read_local_project_media_preview_at(
|
||
project_path: &str,
|
||
relative_path: &str,
|
||
category: &str,
|
||
cancellation: &ProjectResourcePreviewScopeCancellation,
|
||
) -> Result<LocalProjectMediaPreview, String> {
|
||
cancellation.check()?;
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_auto_permission_policy(root, "file.read")?;
|
||
cancellation.check()?;
|
||
let normalized_path = normalize_relative_path(relative_path.trim())?;
|
||
let manifest = read_manifest(&root.join(".agent/manifest.json"))?;
|
||
cancellation.check()?;
|
||
let kind = match category.trim() {
|
||
"art" => ProjectMediaPreviewKind::Art,
|
||
"audio" => ProjectMediaPreviewKind::Audio,
|
||
_ => return Err("媒体预览类别只支持 art 或 audio".to_string()),
|
||
};
|
||
let is_registered_media = manifest.assets.iter().any(|asset| {
|
||
asset.local_path == normalized_path
|
||
&& match kind {
|
||
ProjectMediaPreviewKind::Art => {
|
||
is_supported_project_art_media_resource(&asset.local_path, &asset.media_type)
|
||
}
|
||
ProjectMediaPreviewKind::Audio => {
|
||
is_supported_project_audio_resource(&asset.local_path, &asset.media_type)
|
||
}
|
||
}
|
||
}) || 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());
|
||
}
|
||
cancellation.check()?;
|
||
load_local_project_media_preview_with_cancellation(root, &normalized_path, kind, cancellation)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn cancel_local_project_resource_preview_scope(
|
||
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
|
||
scope_id: String,
|
||
) -> Result<(), String> {
|
||
preview_manager.cancel_scope(&scope_id)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn write_local_project_file(
|
||
project_path: String,
|
||
relative_path: String,
|
||
content: String,
|
||
) -> Result<LocalProjectFileMutationResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "file.write")?;
|
||
let _lock = acquire_project_write_lock(root, "file.write")?;
|
||
advance_agent_runtime_project_revision_locked(root)?;
|
||
write_local_project_file_at(root, relative_path.trim(), &content)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn delete_local_project_file(
|
||
project_path: String,
|
||
relative_path: String,
|
||
) -> Result<LocalProjectFileMutationResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "file.delete")?;
|
||
let _lock = acquire_project_write_lock(root, "file.delete")?;
|
||
advance_agent_runtime_project_revision_locked(root)?;
|
||
delete_local_project_file_at(root, relative_path.trim())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn read_local_game_memory(
|
||
project_path: String,
|
||
scope: String,
|
||
) -> Result<LocalGameMemoryResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "memory.read")?;
|
||
read_local_game_memory_at(root, scope.trim())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn read_local_agent_memory(
|
||
project_path: String,
|
||
task_id: String,
|
||
) -> Result<LocalAgentMemoryResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
let task_id = normalize_game_creator_runtime_agent_id(task_id.trim())?;
|
||
enforce_project_permission_policy(root, "memory.read")?;
|
||
read_local_agent_memory_at(root, &task_id)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn write_local_agent_memory(
|
||
project_path: String,
|
||
task_id: String,
|
||
content: String,
|
||
) -> Result<LocalAgentMemoryResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
let task_id = normalize_game_creator_runtime_agent_id(task_id.trim())?;
|
||
enforce_project_permission_policy(root, "memory.write")?;
|
||
let _lock = acquire_project_write_lock(root, "memory.write")?;
|
||
advance_agent_runtime_project_revision_locked(root)?;
|
||
write_local_agent_memory_at(root, &task_id, &content)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn write_local_game_memory(
|
||
project_path: String,
|
||
scope: String,
|
||
content: String,
|
||
) -> Result<LocalGameMemoryResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "memory.write")?;
|
||
let _lock = acquire_project_write_lock(root, "memory.write")?;
|
||
advance_agent_runtime_project_revision_locked(root)?;
|
||
write_local_game_memory_at(root, scope.trim(), &content)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn delete_local_game_memory(
|
||
project_path: String,
|
||
scope: String,
|
||
) -> Result<LocalGameMemoryResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "memory.delete")?;
|
||
let _lock = acquire_project_write_lock(root, "memory.delete")?;
|
||
advance_agent_runtime_project_revision_locked(root)?;
|
||
delete_local_game_memory_at(root, scope.trim())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn list_game_creator_agent_sessions(
|
||
project_path: String,
|
||
agent_id: String,
|
||
) -> Result<AgentConversationSessionListResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
list_game_creator_agent_sessions_at(root, agent_id.trim())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn create_game_creator_agent_session(
|
||
project_path: String,
|
||
agent_id: String,
|
||
title: String,
|
||
) -> Result<AgentConversationSessionListResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
enforce_project_permission_policy(root, "conversation.write")?;
|
||
let _lock = acquire_project_write_lock(root, "conversation.write")?;
|
||
create_game_creator_agent_session_at(root, agent_id.trim(), title.trim())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn fork_game_creator_agent_session(
|
||
project_path: String,
|
||
agent_id: String,
|
||
source_session_id: String,
|
||
title: String,
|
||
) -> Result<AgentConversationSessionListResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
enforce_project_permission_policy(root, "conversation.write")?;
|
||
let _lock = acquire_project_write_lock(root, "conversation.write")?;
|
||
fork_game_creator_agent_session_at(
|
||
root,
|
||
agent_id.trim(),
|
||
source_session_id.trim(),
|
||
title.trim(),
|
||
)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn set_active_game_creator_agent_session(
|
||
project_path: String,
|
||
agent_id: String,
|
||
session_id: String,
|
||
) -> Result<AgentConversationSessionListResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
enforce_project_permission_policy(root, "conversation.write")?;
|
||
let _lock = acquire_project_write_lock(root, "conversation.write")?;
|
||
set_active_game_creator_agent_session_at(root, agent_id.trim(), session_id.trim())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn archive_game_creator_agent_session(
|
||
project_path: String,
|
||
agent_id: String,
|
||
session_id: String,
|
||
) -> Result<AgentConversationSessionListResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
enforce_project_permission_policy(root, "conversation.write")?;
|
||
let _lock = acquire_project_write_lock(root, "conversation.write")?;
|
||
archive_game_creator_agent_session_at(root, agent_id.trim(), session_id.trim())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn read_local_conversation(
|
||
project_path: String,
|
||
agent_id: Option<String>,
|
||
session_id: Option<String>,
|
||
) -> Result<LocalConversationResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
read_local_conversation_for_session_at(root, agent_id.as_deref(), session_id.as_deref())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn read_direct_project_conversation(
|
||
project_path: String,
|
||
) -> Result<LocalConversationResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
read_direct_project_chat_history_at(root)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn append_local_conversation_message(
|
||
project_path: String,
|
||
agent_id: Option<String>,
|
||
session_id: Option<String>,
|
||
message: LocalConversationMessage,
|
||
message_id: Option<String>,
|
||
) -> Result<LocalConversationResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.write")?;
|
||
let _lock = acquire_project_write_lock(root, "conversation.write")?;
|
||
match message_id.as_deref() {
|
||
Some(message_id) => append_local_conversation_message_for_session_idempotent_at(
|
||
root,
|
||
agent_id.as_deref(),
|
||
session_id.as_deref(),
|
||
message,
|
||
message_id,
|
||
),
|
||
None => append_local_conversation_message_for_session_at(
|
||
root,
|
||
agent_id.as_deref(),
|
||
session_id.as_deref(),
|
||
message,
|
||
),
|
||
}
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn append_direct_project_conversation_message(
|
||
project_path: String,
|
||
message: LocalConversationMessage,
|
||
message_id: Option<String>,
|
||
) -> Result<LocalConversationResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.write")?;
|
||
let item =
|
||
direct_project_local_message_item(&message.role, &message.content, message_id.as_deref())?;
|
||
append_direct_project_history_item_at(root, &item)?;
|
||
read_direct_project_chat_history_at(root)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn build_local_project_index(
|
||
project_path: String,
|
||
) -> Result<LocalProjectIndexResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "project.index")?;
|
||
let _lock = acquire_project_write_lock(root, "project.index")?;
|
||
build_local_project_index_at(root)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn create_local_project_checkpoint(
|
||
project_path: String,
|
||
) -> Result<LocalProjectCheckpointResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "project.checkpoint")?;
|
||
let _lock = acquire_project_write_lock(root, "project.checkpoint")?;
|
||
create_local_project_checkpoint_at(root)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn export_local_project_package(
|
||
project_path: String,
|
||
) -> Result<LocalProjectExportPackageResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "project.export_package")?;
|
||
let _lock = acquire_project_write_lock(root, "project.export_package")?;
|
||
advance_agent_runtime_project_revision_locked(root)?;
|
||
export_local_project_package_at(root)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn list_local_project_export_packages(
|
||
project_path: String,
|
||
) -> Result<LocalProjectExportPackagesResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "project.export_list")?;
|
||
list_local_project_export_packages_at(root)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn diff_local_project_checkpoint(
|
||
project_path: String,
|
||
checkpoint_id: String,
|
||
) -> Result<LocalProjectDiffResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "project.diff")?;
|
||
diff_local_project_checkpoint_at(root, checkpoint_id.trim())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn restore_local_project_checkpoint(
|
||
project_path: String,
|
||
checkpoint_id: String,
|
||
) -> Result<LocalProjectRestoreResult, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "project.restore")?;
|
||
let _lock = acquire_project_write_lock(root, "project.restore")?;
|
||
advance_agent_runtime_project_revision_locked(root)?;
|
||
restore_local_project_checkpoint_at(root, checkpoint_id.trim())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn read_project_permission_policy(
|
||
project_path: String,
|
||
) -> Result<ProjectPermissionPolicyView, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "project.policy_read")?;
|
||
read_project_permission_policy_at(root)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn write_project_permission_policy(
|
||
project_path: String,
|
||
policy: ProjectPermissionPolicy,
|
||
) -> Result<ProjectPermissionPolicyView, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "project.policy_write")?;
|
||
let _lock = acquire_project_write_lock(root, "project.policy_write")?;
|
||
write_project_permission_policy_at(root, policy)
|
||
}
|