Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/commands.rs
T
lhk229 08c5917490
Project CI / Frontend tests (pull_request) Successful in 3m5s
Project CI / Backend tests (pull_request) Failing after 3m52s
Project CI / Native shell tests (pull_request) Failing after 4m5s
Project CI / Repository checks (pull_request) Failing after 1m8s
修复配置覆盖层写入失败后的部分更新
提前序列化配置变更并在多文件写入失败时逆序回滚
保留单文件保存路径且不增加外部诊断或保存后回读
补充覆盖层写入失败回归测试及配置保存文档
2026-09-09 04:34:57 +00:00

4930 lines
187 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"), &current)?;
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, &registered.id, &registered.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)
}