Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/commands.rs
T
kdletters d5743cd4b8
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
资源画布支持引擎资源预览与模型交互预览 (#413)
## 目的

资源画布支持引擎(Cocos Creator)资源的**只读预览**:能被发现、登记、进入画布,并按类型出预览。

## 主要改动

- 发现层识别 Cocos 资源并区分 `model` / `binary` 两个发现类别;`.meta` 等导入侧车文件仍只可发现
- 登记层按扩展名写入**既有** canonical kind(模型/场景/预制体 → `scene`,动画 → `character-animation`,材质/特效 → `code`,图集与容器 → `document`),不新增 manifest 契约字段
- 引擎工程的 `library/` / `temp/` / `profiles/` / `local/` 不再进入发现结果(仅当目录确实是 Cocos 工程),同名目录在非引擎工程里照常列出
- 资源画布新增三个卡面分支:模型缩略图(整页共用一个 WebGL 上下文)、结构摘要(Cocos 序列化资源)、类型卡(客户端解不了的容器)
- 新增模型放大预览浮层:左键旋转 / 右键或中键平移 / 滚轮缩放 / 复位视角
- 模型预览上限 32 MiB;缩略图按布局尺寸 2 倍超采样,缓存键改用稳定身份
- 引擎图像容器(tga/tif/tiff/hdr)在原生侧转码成 PNG 后复用既有图片预览链路
- 修复资源卡状态边框吃掉内容盒导致的卡面与角标位移
- 同步 Agent 提示词与 AGC 技能文档,并补里程碑与踩坑文档

## 验证

- `cargo check`、`cargo fmt --check`
- Rust 定向:`cargo test … cocos` 9 条、`resource_inspect::tests` 7 条、`agent_asset_import_tests` 9 条、生成目录过滤用例
- 前端:`resource` + `project` 套件 52 文件 / 546 用例;`appSurface` 450 通过 / 20 跳过;app `tsc --noEmit`
- `npm run check:encoding`、`git diff --check`、`npm run check:doc-index`
- 真机:在真实客户端内打开本地 Cocos 夹具工程,逐栏核对模型缩略图 / 结构摘要 / TGA 转码 / 类型卡,并量过卡片在指针移开、悬停、选中三态下几何完全一致

## 未验证

- 模型缩略图与放大预览的真机视觉只覆盖自带夹具工程;多文件 glTF(外部 .bin/贴图)与超大模型仍是类型卡

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/413
2026-09-17 20:28:39 +08:00

5900 lines
230 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::{
direct_codex_canonical_project_identity, read_direct_project_chat_history_at,
read_direct_project_last_item_id_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;
// Project naming is an optional homepage enhancement. It must never hold the
// actual project creation flow behind the normal (potentially three-minute)
// generation timeout.
const AUTOMATIC_PROJECT_NAME_TIMEOUT_MS: u64 = 15_000;
const AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT: &str =
include_str!("../prompts/automatic-project-name.md");
// 聊天输入区的 AI 润色复用同一条短文本生成通道:只做一次单轮改写,
// 计费由平台 LLM 路由(/api/llm/chat/completions、/api/llm/responses)侧完成。
const LOCAL_PROJECT_PROMPT_POLISH_MAX_PROMPT_CHARS: usize = 4_000;
const LOCAL_PROJECT_PROMPT_POLISH_MAX_CONTEXT_CHARS: usize = 1_000;
const LOCAL_PROJECT_PROMPT_POLISH_MAX_OUTPUT_TOKENS: u32 = 2_048;
const LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT: &str =
include_str!("../prompts/local-project-prompt-polish.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 = tokio::time::timeout(
std::time::Duration::from_millis(AUTOMATIC_PROJECT_NAME_TIMEOUT_MS),
crate::agent::direct_game_creator_home_codex_chat(
AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT.trim().to_string(),
user_prompt,
),
)
.await
.map_err(|_| {
format!(
"自动项目命名超时(超过 {} 秒)",
AUTOMATIC_PROJECT_NAME_TIMEOUT_MS / 1_000
)
})??;
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 = tokio::time::timeout(
std::time::Duration::from_millis(AUTOMATIC_PROJECT_NAME_TIMEOUT_MS),
client.run(request),
)
.await
.map_err(|_| {
format!(
"自动项目命名超时(超过 {} 秒)",
AUTOMATIC_PROJECT_NAME_TIMEOUT_MS / 1_000
)
})?
.map_err(|error| format!("自动项目命名失败:{error}"))?;
Ok(normalize_suggested_project_name(response.text.trim()))
}
pub(crate) fn build_local_project_prompt_polish_prompt(
prompt: &str,
context: Option<&str>,
) -> Result<String, String> {
let prompt = prompt.trim();
if prompt.is_empty() {
return Err("待润色的内容为空,无法润色".to_string());
}
let mut user_prompt: String = prompt
.chars()
.take(LOCAL_PROJECT_PROMPT_POLISH_MAX_PROMPT_CHARS)
.collect();
if let Some(context) = context.map(str::trim).filter(|value| !value.is_empty()) {
let context: String = context
.chars()
.take(LOCAL_PROJECT_PROMPT_POLISH_MAX_CONTEXT_CHARS)
.collect();
user_prompt.push_str("\n\n当前项目上下文:\n");
user_prompt.push_str(&context);
}
Ok(user_prompt)
}
/// 只接受非空文本:空回复或纯空白一律视为润色失败,由调用方保留原文。
fn normalize_polished_prompt(value: &str) -> Option<String> {
let value = value.trim();
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
async fn request_local_project_prompt_polish(
prompt: &str,
context: Option<&str>,
) -> Result<String, String> {
let user_prompt = build_local_project_prompt_polish_prompt(prompt, context)?;
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(
LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT.trim().to_string(),
user_prompt,
)
.await?;
return normalize_polished_prompt(&reply)
.ok_or_else(|| "AI 润色没有返回可用文本".to_string());
}
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(
LOCAL_PROJECT_PROMPT_POLISH_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(LOCAL_PROJECT_PROMPT_POLISH_MAX_OUTPUT_TOKENS)
.with_web_search(false);
let response = client
.run(request)
.await
.map_err(|error| format!("AI 润色失败:{error}"))?;
normalize_polished_prompt(&response.text).ok_or_else(|| "AI 润色没有返回可用文本".to_string())
}
#[cfg(test)]
mod local_project_prompt_polish_tests {
use super::*;
#[test]
fn rejects_empty_prompt() {
assert!(build_local_project_prompt_polish_prompt(" ", None).is_err());
}
#[test]
fn appends_optional_project_context() {
let prompt =
build_local_project_prompt_polish_prompt("做个跳跃游戏", Some(" 像素风 ")).unwrap();
assert_eq!(prompt, "做个跳跃游戏\n\n当前项目上下文:\n像素风");
let without_context =
build_local_project_prompt_polish_prompt("做个跳跃游戏", None).unwrap();
assert_eq!(without_context, "做个跳跃游戏");
let blank_context =
build_local_project_prompt_polish_prompt("做个跳跃游戏", Some(" ")).unwrap();
assert_eq!(blank_context, "做个跳跃游戏");
}
#[test]
fn truncates_prompt_and_context_to_their_limits() {
let long_prompt = "字".repeat(LOCAL_PROJECT_PROMPT_POLISH_MAX_PROMPT_CHARS + 32);
let long_context = "文".repeat(LOCAL_PROJECT_PROMPT_POLISH_MAX_CONTEXT_CHARS + 32);
let prompt =
build_local_project_prompt_polish_prompt(&long_prompt, Some(&long_context)).unwrap();
let (body, context) = prompt.split_once("\n\n当前项目上下文:\n").unwrap();
assert_eq!(
body.chars().count(),
LOCAL_PROJECT_PROMPT_POLISH_MAX_PROMPT_CHARS
);
assert_eq!(
context.chars().count(),
LOCAL_PROJECT_PROMPT_POLISH_MAX_CONTEXT_CHARS
);
}
#[test]
fn empty_model_reply_is_not_accepted_as_polished_text() {
assert_eq!(normalize_polished_prompt(" \n "), None);
assert_eq!(
normalize_polished_prompt(" 做个像素风跳跃游戏 \n").as_deref(),
Some("做个像素风跳跃游戏")
);
}
}
#[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
}
}
}
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 = "projects";
fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result<PathBuf, String> {
app.path()
.app_data_dir()
.map(|app_data_root| {
// Automatic workspaces are AGC-managed data. Keeping them below
// the hardened per-user app-data root avoids applying the strict
// private-DACL gate to a user Documents directory whose inherited
// ACL AGC is not allowed to rewrite.
app_data_root.join(AUTOMATIC_PROJECTS_DIRECTORY_NAME)
})
.map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))
}
pub(crate) fn create_automatic_local_game_project_at(
projects_root: &Path,
requested_name: Option<&str>,
planning: bool,
) -> 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(|| {
let prefix = if planning {
"策划项目"
} else {
"GameAgent 项目"
};
format!("{prefix} {short_id}")
});
let project_root = projects_root.join(format!("gameagent-{short_id}"));
match fs::create_dir(&project_root) {
Ok(()) => {
let result = (|| {
harden_new_game_creator_private_path(&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>,
planning: Option<bool>,
) -> Result<InitLocalProjectResult, String> {
create_automatic_local_game_project_at(
&automatic_local_game_projects_root(&app)?,
name.as_deref(),
planning.unwrap_or(false),
)
}
#[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 import_local_cocos_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_cocos_project_root(root)?.is_none() {
return Err(
"所选目录不是有效的 Cocos Creator 项目(需要 package.json.creator.version 和 assets/"
.to_string(),
);
}
let _lock = acquire_project_write_lock(root, "project.create")?;
import_local_cocos_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) async fn inspect_local_project_directory(
project_path: String,
) -> Result<LocalProjectDirectoryStatus, String> {
tauri::async_runtime::spawn_blocking(move || inspect_local_project_directory_sync(project_path))
.await
.map_err(|error| format!("检查项目目录后台任务失败:{error}"))?
}
pub(crate) fn inspect_local_project_directory_sync(
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)?;
let cocos_project_root = discover_local_cocos_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,
is_cocos_project: cocos_project_root.is_some(),
cocos_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) async fn get_local_game_manifest(
project_path: String,
command_id: Option<String>,
) -> Result<GameCreationAppManifest, String> {
tauri::async_runtime::spawn_blocking(move || {
get_local_game_manifest_sync(project_path, command_id)
})
.await
.map_err(|error| format!("读取项目 manifest 后台任务失败:{error}"))?
}
pub(crate) fn get_local_game_manifest_sync(
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)
}
/// 读取资源画布的持久化布局(`.agent/workbench/resource-layouts/*.json`)。
///
/// 权限门与紧邻的 [`read_local_project_resource_graph`] 同口径:同一块资源画布、同一条渲染读路径,
/// 用的都是 `asset.list` 的 auto 口径。`asset.list` 在 `GAME_CREATION_APP_COMMANDS` 里是
/// `GameCreationAppPermission::Auto`auto 口径默认就放行;只有项目策略显式把 `asset.list` 写进
/// `denyCommands` 或 `confirmCommands` 才拒绝。这里必须用 auto 而不是普通读门,是因为这条读路径
/// 由画布装载直接触发、没有可插入的确认交互——要求确认等同于拒绝,而普通读门会静默放行。
#[tauri::command]
pub(crate) fn read_local_project_resource_canvas_layout(
project_path: String,
mode: ProjectResourceCanvasLayoutMode,
) -> Result<ProjectResourceCanvasLayout, String> {
let root = Path::new(project_path.trim());
enforce_project_auto_permission_policy(root, "asset.list")?;
read_project_resource_canvas_layout_at(root, 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)
}
/// 保存资源画布的持久化布局。
///
/// 权限位取 `asset.register`,依据是同批「改动项目内资源相关持久化数据」的既有用法:本文件里
/// [`update_local_project_resource_classification`]、[`delete_local_project_asset`]、
/// [`rename_local_project_asset`] 全部用 `enforce_project_permission_policy(root, "asset.register")`
/// 被改写的分类写入本身也用 `acquire_project_write_lock(root, "asset.register")`(见
/// `project/manifest.rs` 的 `update_manifest_asset_classification_at`)。这里刻意用普通写门而不是
/// auto 口径:`asset.register` 默认为 `Confirm`auto 口径会把它当成「必须确认却无人确认」而拒绝,
/// 直接打死默认路径;拖动排版本来就是可以在 UI 里弹确认的用户动作。
#[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> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
update_project_resource_canvas_layout_at(
root,
mode,
&expected_project_id,
expected_revision,
positions,
)
}
/// 读取引用了某个素材的项目版本,供删除弹窗提示「被哪些版本使用」。
#[tauri::command]
pub(crate) fn read_local_project_asset_references(
input: ReadLocalProjectAssetReferencesInput,
) -> Result<ReadLocalProjectAssetReferencesResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.list")?;
read_manifest_asset_references_at(root, &input.asset_id)
}
/// 删除一个素材登记,并按用户确认决定是否连带删除引用它的游戏版本。
///
/// 素材不可变:只摘掉 manifest 登记,磁盘文件保留。
#[tauri::command]
pub(crate) fn delete_local_project_asset(
input: DeleteLocalProjectAssetInput,
) -> Result<DeleteLocalProjectAssetResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
delete_manifest_asset_at(
root,
&input.expected_project_id,
input.expected_project_revision,
&input.asset_id,
input.delete_referenced_versions,
)
}
/// 读取某个版本引用的源素材可用的替换候选,并给出后端权威的三项兼容性结论。
///
/// 只读:不改 manifest、不推进 revision。候选渲染但禁用,不在前端重算判据。
#[tauri::command]
pub(crate) fn read_local_project_version_resource_replacement_candidates(
input: ReadLocalProjectVersionReplacementCandidatesInput,
) -> Result<ReadLocalProjectVersionReplacementCandidatesResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.list")?;
read_local_project_version_replacement_candidates_at(
root,
&input.source_version_id,
&input.source_resource_id,
)
}
/// 用另一个已登记素材替换某个版本引用的素材:改 manifest 绑定,并**追加下一迭代版本**。
///
/// 可运行版本不可变(既有版本记录一个字节都不改)、不动资源文件、不建文件副本;
/// 三项兼容性必须同时为 true,否则拒绝并说明哪一项不等。CAS 失败时 manifest 与 revision 都不变。
#[tauri::command]
pub(crate) fn replace_local_project_version_resource(
input: ReplaceLocalProjectVersionResourceInput,
) -> Result<ReplaceLocalProjectVersionResourceResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
replace_local_project_version_resource_at(
root,
&input.expected_project_id,
input.expected_project_revision,
&input.source_version_id,
&input.source_resource_id,
&input.replacement_resource_id,
)
}
/// 重命名一个已登记素材:磁盘文件改名 + 更新 manifest 的 `localPath`,资产 `id` 不变。
///
/// 只允许在资产当前所在目录内改名,扩展名必须一致,同目录不得已有同名文件;manifest 写失败
/// 时把文件改回原名,不留半成品。与删除 / 分类更新同口径:持项目写锁后按 `expectedProjectId`
/// 与当前 revision 做 CAS,失败时磁盘、manifest 与 revision 都不动。
#[tauri::command]
pub(crate) fn rename_local_project_asset(
input: RenameLocalProjectAssetInput,
) -> Result<RenameLocalProjectAssetResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
rename_local_project_asset_at(
root,
&input.expected_project_id,
input.expected_project_revision,
&input.asset_id,
&input.new_file_name,
)
}
#[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());
}
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())
{
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 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) async fn polish_local_project_prompt(
prompt: String,
context: Option<String>,
) -> Result<String, String> {
request_local_project_prompt_polish(prompt.trim(), context.as_deref())
.await
.map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320))
}
#[tauri::command]
pub(crate) fn read_platform_account_session_state(
) -> crate::platform_session::PlatformSessionWriteState {
crate::platform_session::current_platform_session_write_state()
}
#[tauri::command]
pub(crate) async fn install_platform_account_session(
user_id: String,
access_token: String,
api_base_url: String,
identity_generation: u64,
revision: u64,
) -> Result<(), String> {
tokio::task::spawn_blocking(move || {
validate_platform_session_input(
&user_id,
&access_token,
&api_base_url,
identity_generation,
revision,
)?;
install_external_agent_runner_platform_session(
&user_id,
&access_token,
&api_base_url,
identity_generation,
revision,
)?;
install_platform_session(
&user_id,
&access_token,
&api_base_url,
identity_generation,
revision,
)
})
.await
.map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))?
}
#[tauri::command]
pub(crate) async fn clear_platform_account_session(
identity_generation: u64,
revision: u64,
) -> Result<(), String> {
tokio::task::spawn_blocking(move || {
shutdown_game_creator_codex_app_servers()?;
clear_external_agent_runner_platform_session(identity_generation, revision)?;
clear_platform_session(identity_generation, revision);
Ok(())
})
.await
.map_err(|error| format!("清除本地运行时会话任务意外终止:{error}"))?
}
#[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.llm.custom_enabled = current.llm.custom_enabled;
config.selected_model_id = current.selected_model_id;
config.selected_model_is_default = current.selected_model_is_default;
persist_game_creator_app_config(config, overlays, false)
}
#[tauri::command]
pub(crate) fn cancel_direct_codex_turn(
project_path: String,
client_turn_id: Option<String>,
) -> Result<DirectTurnCancelView, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "agent.kill")?;
cancel_direct_codex_turn_at(root, client_turn_id.as_deref())
}
#[tauri::command]
pub(crate) fn select_game_creator_reasoning_effort(
effort: String,
) -> Result<GameCreatorAppConfigView, String> {
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
.lock()
.map_err(|_| "配置写入锁不可用")?;
let effort = game_creator_llm_reasoning_effort_name(&effort, "llm.reasoningEffort")?;
let (mut config, overlays) = load_game_creator_app_config_for_write()?;
config.llm.reasoning_effort = effort;
persist_game_creator_app_config(config, overlays, false)
}
#[tauri::command]
pub(crate) fn select_game_creator_model(
model_id: String,
is_default: bool,
) -> Result<GameCreatorAppConfigView, String> {
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
.lock()
.map_err(|_| "配置写入锁不可用")?;
let (mut config, overlays) = load_game_creator_app_config_for_write()?;
if config.llm.custom_enabled {
if !config.llm.visible_models.contains(&model_id) {
return Err("所选模型未勾选或已移除,请刷新模型列表".into());
}
} else 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());
}
config.selected_model_id = model_id;
config.selected_model_is_default = is_default;
persist_game_creator_app_config(config, overlays, true)
}
#[tauri::command]
pub(crate) async fn discover_game_creator_llm_models(
llm: GameCreatorLlmConfig,
) -> Result<Vec<String>, String> {
if !load_game_creator_app_config()?.llm.custom_enabled {
return Err("请先在本地配置中开启 llm.customEnabled".to_string());
}
fetch_custom_llm_models(&llm).await
}
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 !model_only
|| matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault")
{
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) fn update_local_project_resource_classification(
input: UpdateLocalProjectResourceClassificationInput,
) -> Result<UpdateLocalProjectResourceClassificationResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
update_manifest_asset_classification_at(
root,
&input.expected_project_id,
input.expected_project_revision,
&input.asset_id,
&input.category,
input.tags,
)
}
#[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,
category: game_creation_app_asset_category_for_kind("font"),
tags: Vec::new(),
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 = crate::tests::canonical_test_tempdir("agent-local-import-");
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("image"));
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());
// P0 回归:本地导入的图片只能落中性的 `image`(→ `unclassified` → 「待归类」)。
// 曾经写成 `ui`,经 `ui → ui-design → ui-interaction` 把任意图片钉死在「UI 交互」栏,
// 且落盘 `category` 成了非 `unclassified` 值后读时自愈永远救不回来。
// 把 `agent_local_project_file_type` 的图片分支改回 `"ui"` 必须让本用例变红。
let manifest: serde_json::Value = serde_json::from_str(
&fs::read_to_string(root.join(".agent/manifest.json")).expect("read imported manifest"),
)
.expect("parse imported manifest");
let imported_assets = manifest["assets"].as_array().expect("assets array");
let png_rows = imported_assets
.iter()
.filter(|asset| asset["mediaType"] == "image/png")
.collect::<Vec<_>>();
assert_eq!(png_rows.len(), 2);
for asset in &png_rows {
assert_eq!(asset["kind"], "image", "{asset}");
assert_eq!(asset["category"], "unclassified", "{asset}");
}
assert!(
!imported_assets
.iter()
.any(|asset| asset["kind"] == "ui" || asset["category"] == "ui-interaction"),
"本地导入不得产出 `ui` / `ui-interaction`{manifest}"
);
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 = crate::tests::canonical_test_tempdir("agent-local-import-");
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 = crate::tests::canonical_test_tempdir("agent-local-import-");
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 = crate::tests::canonical_test_tempdir("agent-local-import-");
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.dat"), 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.dat".to_string()])
.is_err()
);
assert!(
import_local_project_assets_for_agent(root, &["assets/broken.js".to_string()]).is_err()
);
// `.bin` 是引擎的 BufferAsset 载体,属于「已识别但只能出类型卡」的一类:
// 登记必须成功,否则引擎工程里的 BufferAsset 永远进不了资源画布。
fs::write(root.join("assets/blob.bin"), [0x00, 0x01, 0x02]).expect("write buffer asset");
let imported =
import_local_project_assets_for_agent(root, &["assets/blob.bin".to_string()])
.expect("import engine buffer asset");
assert_eq!(imported.assets.len(), 1);
assert_eq!(imported.assets[0].asset_kind.as_deref(), Some("document"));
}
/// Cocos Creator 资源登记:模型、动画、序列化资源与引擎容器都要能进 manifest,
/// 且 `kind` 只落在**既有 canonical 词表**里(不新增契约值,旧客户端仍能读 manifest)。
///
/// 变异验证:把任一扩展名从 `agent_local_project_file_type` 删掉即变红。
#[test]
fn local_project_asset_import_registers_cocos_creator_assets() {
// 工程自带助手:canonicalize + 目录 owner 归当前用户,避免 `%TEMP%` 临时目录
// 在 Windows owner 校验下直接失败。
let project = crate::tests::canonical_test_tempdir("cocos-asset-import-");
let root = project.path();
init_local_game_project_at(root, "cocos-import", "Cocos import")
.expect("initialize project");
for directory in [
"model", "anim", "scene", "mtl", "shader", "atlas", "map", "tex", "audio",
] {
fs::create_dir_all(root.join("assets").join(directory)).expect("create assets subdir");
}
let mut glb = b"glTF".to_vec();
glb.extend_from_slice(&[2, 0, 0, 0, 12, 0, 0, 0]);
let mut fbx = b"Kaydara FBX Binary \x00".to_vec();
fbx.extend_from_slice(&[0; 16]);
for (path, bytes) in [
("assets/model/hero.glb", glb),
("assets/model/hero.fbx", fbx),
(
"assets/anim/walk.anim",
b"[{\"__type__\":\"cc.AnimationClip\"}]".to_vec(),
),
(
"assets/anim/graph.animgraph",
b"{\"__type__\":\"cc.animation.AnimationGraph\"}".to_vec(),
),
(
"assets/scene/main.scene",
b"[{\"__type__\":\"cc.SceneAsset\"}]".to_vec(),
),
(
"assets/scene/enemy.prefab",
b"[{\"__type__\":\"cc.Prefab\"}]".to_vec(),
),
(
"assets/mtl/hero.mtl",
b"{\"__type__\":\"cc.Material\"}".to_vec(),
),
("assets/shader/glow.effect", b"CCEffect %{\n}".to_vec()),
(
"assets/atlas/hero.plist",
b"<?xml version=\"1.0\"?><plist/>".to_vec(),
),
(
"assets/map/level.tmx",
b"<?xml version=\"1.0\"?><map/>".to_vec(),
),
("assets/tex/hero.texture", vec![0xff, 0x00, 0x01]),
("assets/audio/voice.pcm", vec![0x00, 0x01, 0x02]),
] {
fs::write(root.join(path), bytes).expect("write cocos asset");
}
let relative_paths = [
"assets/model/hero.glb",
"assets/model/hero.fbx",
"assets/anim/walk.anim",
"assets/anim/graph.animgraph",
"assets/scene/main.scene",
"assets/scene/enemy.prefab",
"assets/mtl/hero.mtl",
"assets/shader/glow.effect",
"assets/atlas/hero.plist",
"assets/map/level.tmx",
"assets/tex/hero.texture",
"assets/audio/voice.pcm",
]
.map(str::to_string)
.to_vec();
let imported =
import_local_project_assets_for_agent(root, &relative_paths).expect("import cocos");
let kinds = imported
.assets
.iter()
.map(|asset| (asset.local_path.as_str(), asset.asset_kind.as_deref()))
.collect::<BTreeMap<_, _>>();
assert_eq!(kinds.get("assets/model/hero.glb"), Some(&Some("scene")));
assert_eq!(kinds.get("assets/model/hero.fbx"), Some(&Some("scene")));
assert_eq!(
kinds.get("assets/anim/walk.anim"),
Some(&Some("character-animation"))
);
assert_eq!(
kinds.get("assets/anim/graph.animgraph"),
Some(&Some("character-animation"))
);
assert_eq!(kinds.get("assets/scene/main.scene"), Some(&Some("scene")));
assert_eq!(kinds.get("assets/scene/enemy.prefab"), Some(&Some("scene")));
assert_eq!(kinds.get("assets/mtl/hero.mtl"), Some(&Some("code")));
assert_eq!(kinds.get("assets/shader/glow.effect"), Some(&Some("code")));
assert_eq!(
kinds.get("assets/atlas/hero.plist"),
Some(&Some("document"))
);
// 瓦片地图与场景/预制体同栏(`scene`),不是「文档」:它描述的是可摆放的地图。
assert_eq!(kinds.get("assets/map/level.tmx"), Some(&Some("scene")));
assert_eq!(
kinds.get("assets/tex/hero.texture"),
Some(&Some("document"))
);
assert_eq!(kinds.get("assets/audio/voice.pcm"), Some(&Some("audio")));
// 非 UTF-8 的 `.prefab` 会被 `document` 分支拒绝:结构化文本资源必须是 UTF-8,
// 否则「结构预览」拿到的是一堆乱码。
fs::write(root.join("assets/scene/broken.prefab"), [0xff, 0xfe])
.expect("write invalid prefab");
assert!(import_local_project_assets_for_agent(
root,
&["assets/scene/broken.prefab".to_string()]
)
.is_err());
}
/// 平台导入(账户素材库 / 网页项目画布)落盘的 `kind`:**有真实类型就用真实类型**,
/// 判不出才退回中性 `image`(→ 「待归类」),**永远不许回退成常量 `ui`**。
///
/// 变异验证:把 `imported_platform_asset_kind` 改成忽略入参、返回 `"ui"` 的实现,
/// 本用例必须变红(`character` / `scene` / 空值 / 未知值四组断言都会失败)。
#[test]
fn imported_platform_asset_kind_prefers_payload_type_over_ui_default() {
assert_eq!(imported_platform_asset_kind(Some("character")), "character");
assert_eq!(imported_platform_asset_kind(Some("scene")), "scene");
assert_eq!(
imported_platform_asset_kind(Some("character-animation")),
"character-animation"
);
assert_eq!(imported_platform_asset_kind(Some("icon-spec")), "icon-spec");
// 平台确实说它是 UI 设计稿时,才允许落 `ui-design`(→ 「UI 交互」);
// 大写 `UI` 与旧值 `ui` 都要归一到同一口径,但**不允许由我们替它默认**。
assert_eq!(imported_platform_asset_kind(Some("UI")), "ui-design");
assert_eq!(imported_platform_asset_kind(Some("ui")), "ui-design");
// 缺失 / 空白 / 未知值一律落 canonical `image` 兜底(→ 「待归类」)。
assert_eq!(imported_platform_asset_kind(None), "image");
assert_eq!(imported_platform_asset_kind(Some(" ")), "image");
assert_eq!(imported_platform_asset_kind(Some("mystery")), "image");
// `kind` 是外部输入,原型链键必须走 `image` 兜底,不能取到原型上的值。
assert_eq!(imported_platform_asset_kind(Some("__proto__")), "image");
assert_eq!(imported_platform_asset_kind(Some("constructor")), "image");
}
/// 反查门禁:两处平台素材导入必须继续用 `imported_platform_asset_kind` 解析 `kind`
/// 不允许回退成写死的字面量。
///
/// 断言的是**调用点原文**而不是函数被提及的次数——本测试自身也在同一文件里,
/// 按名字计数会把测试代码算进去,门禁就假绿了。
#[test]
fn platform_asset_import_sites_resolve_kind_instead_of_hardcoding_it() {
let source = include_str!("commands.rs");
for call in [
"imported_platform_asset_kind(record.asset_kind",
"imported_platform_asset_kind(json_string_field(",
] {
assert!(
source.contains(call),
"平台导入必须用平台给的 `assetKind` 解析 manifest `kind`,不得回退成常量:{call}"
);
}
for hardcoded in [
"register_local_asset_entry(\n root,\n &local_path,\n \"ui\",",
"register_local_asset_entry(\n root,\n &local_path,\n \"uploaded\",",
] {
assert!(
!source.contains(hardcoded),
"平台导入不得再把 manifest `kind` 写成常量字面量:{hardcoded}"
);
}
}
}
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,
};
// 图片的登记 `kind` 只能是内容证据能支撑的中性值:图片就是 `image`
// canonical `image` 派生 `unclassified`,落「待归类」,由用户/后续流程再定).
//
// 绝不许把 `ui` 当"图片的默认类型"`ui → ui-design → ui-interaction` 会让任意
// PNG 钉死在「UI 交互」栏,而"是不是 UI 素材"跟"扩展名是不是 .png"毫无关系。
// 这种错值还不可恢复——读时自愈只在落盘 `category` 是 `unclassified` 且该 `kind`
// 能派生出非 `unclassified` 分类时才生效,这里 `kind` 本身就是错的,自愈只会把
// 错值放大成显式的 `ui-interaction`。
media_type.map(|media_type| AgentLocalProjectFileType {
category: "image",
asset_kind: "image",
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,
}),
/*
* Cocos Creator3.8.8)资源:三维模型、动画、材质/特效、场景/预制体、图集与
* 压缩纹理容器。登记边界只做两件事:给出可判定的 `asset_kind` 与**预览通道**
* 能支撑的 `media_type`。
*
* - `document`Cocos 自己序列化的文本/JSON(要过 UTF-8 校验),卡面按结构预览;
* - `binary`:客户端无法解码的容器(模型、压缩纹理、Spine 二进制等),只要求非空;
* - `image`:可用原生解码转码成 PNG 再预览的图像容器(tga/tif/tiff/hdr/exr)。
*
* 这些扩展名必须与 `agent/direct_tool_bridge.rs::bridge_project_file_class` 和
* `agent/generation/prompt_context.rs::prompt_context_media_type` 同步,否则会出现
* 「发现得了、登记不了」或「登记得了、Agent 看不见」的分叉。
*/
"scene" | "fire" | "prefab" | "terrain" => Some(AgentLocalProjectFileType {
category: "document",
asset_kind: "scene",
media_type: "application/json",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"tmx" => Some(AgentLocalProjectFileType {
category: "document",
asset_kind: "scene",
media_type: "application/xml",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"anim" | "animation" | "animgraph" | "animgraphvari" | "animask" => {
Some(AgentLocalProjectFileType {
category: "document",
asset_kind: "character-animation",
media_type: "application/json",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
})
}
"mtl" | "material" | "pmtl" => Some(AgentLocalProjectFileType {
category: "document",
asset_kind: "code",
media_type: "application/json",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"effect" | "chunk" => Some(AgentLocalProjectFileType {
category: "document",
asset_kind: "code",
media_type: "text/plain",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"plist" => Some(AgentLocalProjectFileType {
category: "document",
asset_kind: "document",
media_type: "application/xml",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"labelatlas" | "pac" => Some(AgentLocalProjectFileType {
category: "document",
asset_kind: "document",
media_type: "application/json",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"fnt" | "atlas" => Some(AgentLocalProjectFileType {
category: "document",
asset_kind: "document",
media_type: "text/plain",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"glb" => Some(AgentLocalProjectFileType {
category: "binary",
asset_kind: "scene",
media_type: "model/gltf-binary",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"gltf" => Some(AgentLocalProjectFileType {
category: "binary",
asset_kind: "scene",
media_type: "model/gltf+json",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"fbx" => Some(AgentLocalProjectFileType {
category: "binary",
asset_kind: "scene",
media_type: "application/octet-stream",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"mesh" | "skeleton" => Some(AgentLocalProjectFileType {
// Cocos 的 `.mesh` / `.skeleton` 是网格与骨骼的实例化数据,多数工程里是
// JSON、但也存在二进制变体,因此只按「非空」校验;能不能当文本预览由
// 结构化预览读取自己判定(非 UTF-8 时降级成类型卡)。
category: "binary",
asset_kind: "scene",
media_type: "application/json",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"dbbin" | "bin" | "skel" | "texture" | "cubemap" | "rt" => {
Some(AgentLocalProjectFileType {
category: "binary",
asset_kind: "document",
media_type: "application/octet-stream",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
})
}
"psd" | "znt" => Some(AgentLocalProjectFileType {
category: "binary",
asset_kind: "image",
media_type: "application/octet-stream",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"tga" => Some(AgentLocalProjectFileType {
category: "image",
asset_kind: "image",
media_type: "image/x-tga",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"tif" | "tiff" => Some(AgentLocalProjectFileType {
category: "image",
asset_kind: "image",
media_type: "image/tiff",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"hdr" => Some(AgentLocalProjectFileType {
category: "image",
asset_kind: "image",
media_type: "image/vnd.radiance",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"exr" => Some(AgentLocalProjectFileType {
category: "image",
asset_kind: "image",
media_type: "image/x-exr",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"pcm" => Some(AgentLocalProjectFileType {
category: "audio",
asset_kind: "audio",
media_type: "audio/pcm",
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 })
}
/// 平台素材导入落盘的 manifest `kind`:**有真实类型就用真实类型**,判不出才退回中性的
/// `image`(派生 `unclassified` → 「待归类」)。
///
/// 这里绝不允许回退成 `ui`:账户素材库记录与网页项目画布响应本来就带 `assetKind`
/// `AgentEditorAssetRecord::asset_kind` / 响应字段 `assetKind`),把它换成常量等于丢掉
/// 唯一一条"这东西是什么"的事实,并把角色立绘、怪物、道具、场景图一并钉进「UI 交互」栏
/// `ui → ui-design → ui-interaction`)。落盘 `category` 一旦是非 `unclassified` 值,
/// 读时自愈永远救不回来,所以宁可写 `image`(待归类)也不能写假 `ui`。
fn imported_platform_asset_kind(platform_kind: Option<&str>) -> String {
match platform_kind.map(str::trim).filter(|kind| !kind.is_empty()) {
// 平台侧词汇可能与 canonical 目录不完全一致(例如大写 `UI`),统一过 canonical 归一,
// 保证写入侧只产出 canonical `kind`。
Some(kind) => {
shared_contracts::game_creation_app::canonical_game_creation_app_asset_kind(kind)
.to_string()
}
None => "image".to_string(),
}
}
/// 按账户素材 `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_platform_session_identity_lease(&session.identity()))
.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",
),
};
// 账户素材记录自带 `assetKind`:它就是"这东西是什么"的权威来源,必须落进 manifest,
// 不许再用常量 `ui` 顶掉它(见 `imported_platform_asset_kind`)。
let asset_kind = imported_platform_asset_kind(record.asset_kind.as_deref());
let registered = register_local_asset_entry(
root,
&local_path,
&asset_kind,
&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);
// 平台响应里的 `assetKind` 同样是"这东西是什么"的权威来源,必须落进 manifest;
// 缺失才退回 `image`(待归类),绝不用常量 `ui`(见 `imported_platform_asset_kind`)。
let asset_kind =
imported_platform_asset_kind(json_string_field(&asset, "assetKind").as_deref());
let registered = register_local_asset_entry(
root,
&local_path,
&asset_kind,
&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)
}
/// 栏目画布底部工具栏使用的「无源生成图片类素材」入参边界。
/// kind 目录由 `canvas_generation::PLATFORM_ART_ASSET_GENERATION_KINDS` 统一维护,
/// 这里只收口比例、尺寸与文本长度,且与 agent 侧同一条通道保持同一量级。
const LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS: usize = 32_000;
const LOCAL_PROJECT_ASSET_MAX_ASSET_NAME_CHARS: usize = 120;
const LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS: usize = 512;
const LOCAL_PROJECT_ASSET_ASPECT_RATIOS: &[&str] = &["1:1", "2:3", "3:2", "9:16", "16:9"];
const LOCAL_PROJECT_ASSET_IMAGE_SIZES: &[&str] = &["0.5K", "1K", "2K"];
const LOCAL_PROJECT_ASSET_DEFAULT_ASSET_NAME: &str = "AI 生成素材";
/// 已通过校验、可直接交给参数化生成通道的一次请求。
#[derive(Debug, Eq, PartialEq)]
pub(crate) struct LocalProjectAssetGenerationRequest {
pub(crate) root: PathBuf,
pub(crate) prompt: String,
pub(crate) options: PlatformArtAssetGenerationOptions,
}
fn local_project_asset_prompt(prompt: &str) -> Result<String, String> {
let prompt = prompt.trim();
if prompt.is_empty() {
return Err("生成要求不能为空".to_string());
}
// 工具栏是多行输入框,换行与制表符是合法正文;其余控制字符一律拒绝。
if prompt.chars().count() > LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS
|| prompt
.chars()
.any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
{
return Err("生成要求超出安全边界".to_string());
}
Ok(prompt.to_string())
}
fn local_project_asset_single_line(
value: Option<&str>,
max_chars: usize,
label: &str,
) -> Result<Option<String>, String> {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
if value.chars().count() > max_chars || value.chars().any(char::is_control) {
return Err(format!("{label}超出安全边界"));
}
Ok(Some(value.to_string()))
}
fn local_project_asset_choice(
value: Option<&str>,
allowed: &[&str],
default: &str,
label: &str,
) -> Result<String, String> {
match value.map(str::trim).filter(|value| !value.is_empty()) {
None => Ok(default.to_string()),
Some(value) if allowed.contains(&value) => Ok(value.to_string()),
Some(value) => Err(format!("{label}不受支持:{value}")),
}
}
/// 收口 GUI 入参并装配参数化生成通道的 options。
///
/// 这里不做输出路径的 assets/ 归属与防覆盖校验:那是生成通道内
/// `prepare_platform_art_asset_output_path_for_mode` 的职责,重复一份只会产生第二个口径。
pub(crate) fn prepare_local_project_asset_generation(
project_path: &str,
kind: &str,
prompt: &str,
aspect_ratio: Option<&str>,
image_size: Option<&str>,
asset_name: Option<&str>,
output_path: Option<&str>,
) -> Result<LocalProjectAssetGenerationRequest, String> {
let project_path = project_path.trim();
if project_path.is_empty() {
return Err("项目路径不能为空".to_string());
}
let asset_kind = normalize_platform_art_asset_generation_kind(kind)
.ok_or_else(|| format!("素材类型不受支持:{}", kind.trim()))?;
Ok(LocalProjectAssetGenerationRequest {
root: PathBuf::from(project_path),
prompt: local_project_asset_prompt(prompt)?,
options: PlatformArtAssetGenerationOptions {
output_path: local_project_asset_single_line(
output_path,
LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS,
"输出路径",
)?,
aspect_ratio: local_project_asset_choice(
aspect_ratio,
LOCAL_PROJECT_ASSET_ASPECT_RATIOS,
"1:1",
"图片比例",
)?,
image_size: local_project_asset_choice(
image_size,
LOCAL_PROJECT_ASSET_IMAGE_SIZES,
"1K",
"图片尺寸",
)?,
asset_kind: asset_kind.to_string(),
asset_label: local_project_asset_single_line(
asset_name,
LOCAL_PROJECT_ASSET_MAX_ASSET_NAME_CHARS,
"素材名称",
)?
.unwrap_or_else(|| LOCAL_PROJECT_ASSET_DEFAULT_ASSET_NAME.to_string()),
replace_existing: false,
slice_count: None,
// 切分模式没有默认值:GUI 快速编辑只按自由排布生成图集,因此仅在 art-spritesheet
// 时显式声明连通域切分;等分网格或固定槽位需求由外部 API 显式传 grid + gridX/gridY。
slice_mode: (asset_kind == "art-spritesheet")
.then(|| "connected-components".to_string()),
grid_x: None,
grid_y: None,
},
})
}
/// 栏目画布底部工具栏的「无源生成图片类素材」IPC。
///
/// 与 `generate_platform_art_asset` 一样只返回 `UploadLocalAssetResult`id/localPath/
/// absolutePath/manifestPath),前端据此拿到登记后的资源身份并用 manifestPath 刷新 manifest。
/// 生成、计费、幂等账本、下载校验、manifest/revision 登记与本地预览全部转调
/// `generate_platform_art_asset_with_options_at`,本命令不复制任何生成逻辑。
///
/// 与 agent 侧参数化通道一致,这里同时要求 `asset.register`:生成结果必定写入 manifest。
#[tauri::command]
pub(crate) async fn generate_local_project_asset(
project_path: String,
kind: String,
prompt: String,
aspect_ratio: Option<String>,
image_size: Option<String>,
asset_name: Option<String>,
output_path: Option<String>,
) -> Result<UploadLocalAssetResult, String> {
let request = prepare_local_project_asset_generation(
&project_path,
&kind,
&prompt,
aspect_ratio.as_deref(),
image_size.as_deref(),
asset_name.as_deref(),
output_path.as_deref(),
)?;
enforce_project_permission_policy(&request.root, "canvas.asset_generate")?;
enforce_project_permission_policy(&request.root, "asset.register")?;
let generated = generate_platform_art_asset_with_options_at(
&request.root,
&request.prompt,
&[],
&request.options,
)
.await?;
Ok(generated.asset)
}
#[cfg(test)]
mod local_project_asset_generation_tests {
use super::*;
fn prepare(kind: &str, prompt: &str) -> Result<LocalProjectAssetGenerationRequest, String> {
prepare_local_project_asset_generation("/tmp/project", kind, prompt, None, None, None, None)
}
#[test]
fn toolbar_kinds_all_reach_the_shared_generation_channel() {
for kind in [
"image",
"character",
"spec",
"icon-spec",
"ui-prototype",
"art-spritesheet",
] {
let request = prepare(kind, "像素月光厨房主角").expect("toolbar kind is supported");
assert_eq!(request.root, PathBuf::from("/tmp/project"));
assert_eq!(request.prompt, "像素月光厨房主角");
// `spec` 只放行到权威类型;其余 kind 原样进入参数化通道。
let expected = if kind == "spec" { "icon-spec" } else { kind };
assert_eq!(request.options.asset_kind, expected);
assert!(!request.options.replace_existing);
assert_eq!(request.options.slice_count, None);
}
}
#[test]
fn omitted_options_fall_back_to_the_channel_defaults() {
let request = prepare("image", "像素月光厨房主角").expect("defaults");
assert_eq!(request.options.aspect_ratio, "1:1");
assert_eq!(request.options.image_size, "1K");
assert_eq!(request.options.asset_label, "AI 生成素材");
assert_eq!(request.options.output_path, None);
let explicit = prepare_local_project_asset_generation(
" /tmp/project ",
" art-spritesheet ",
" 像素图集 ",
Some("16:9"),
Some("2K"),
Some(" 主角图集 "),
Some(" assets/hero.png "),
)
.expect("explicit options");
assert_eq!(explicit.root, PathBuf::from("/tmp/project"));
assert_eq!(explicit.prompt, "像素图集");
assert_eq!(explicit.options.asset_kind, "art-spritesheet");
assert_eq!(explicit.options.aspect_ratio, "16:9");
assert_eq!(explicit.options.image_size, "2K");
assert_eq!(explicit.options.asset_label, "主角图集");
assert_eq!(
explicit.options.output_path.as_deref(),
Some("assets/hero.png")
);
}
#[test]
fn toolbar_prompts_keep_multi_line_text_but_reject_other_control_characters() {
let multiline =
prepare("character", "第一行要求\n第二行要求\t制表符").expect("multi-line prompt");
assert!(multiline.prompt.contains('\n'));
let error = prepare("character", "带控制字符\u{7}的要求").expect_err("control character");
assert_eq!(error, "生成要求超出安全边界");
}
#[test]
fn invalid_toolbar_arguments_are_rejected_before_any_generation() {
assert_eq!(
prepare_local_project_asset_generation("", "image", "要求", None, None, None, None)
.expect_err("empty project path"),
"项目路径不能为空"
);
assert_eq!(
prepare("image", " ").expect_err("empty prompt"),
"生成要求不能为空"
);
assert_eq!(
prepare("game-art", "要求").expect_err("unverified kind"),
"素材类型不受支持:game-art"
);
assert_eq!(
prepare(
"spec",
&"x".repeat(LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS + 1)
)
.expect_err("oversized prompt"),
"生成要求超出安全边界"
);
assert_eq!(
prepare_local_project_asset_generation(
"/tmp/project",
"image",
"要求",
Some("4:3"),
None,
None,
None
)
.expect_err("unsupported ratio"),
"图片比例不受支持:4:3"
);
assert_eq!(
prepare_local_project_asset_generation(
"/tmp/project",
"image",
"要求",
None,
Some("4K"),
None,
None
)
.expect_err("unsupported size"),
"图片尺寸不受支持:4K"
);
assert_eq!(
prepare_local_project_asset_generation(
"/tmp/project",
"image",
"要求",
None,
None,
Some("坏\u{7}名字"),
None
)
.expect_err("control character in asset name"),
"素材名称超出安全边界"
);
assert_eq!(
prepare_local_project_asset_generation(
"/tmp/project",
"image",
"要求",
None,
None,
None,
Some(&"a".repeat(LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS + 1))
)
.expect_err("oversized output path"),
"输出路径超出安全边界"
);
}
}
#[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) fn save_local_project_asset_file(
input: SaveLocalProjectAssetFileInput,
) -> Result<SaveLocalProjectAssetFileResult, String> {
save_local_project_asset_file_at(input)
}
#[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())?;
// 资源画布会对每张卡片各读一次 manifest;这条读取只做「是否已登记」判定,
// 走带完整文件身份复核的进程内缓存,避免每次预览重复读取并解析整个 manifest。
let manifest = read_manifest_cached_for_preview(&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())?;
// 资源画布会对每张卡片各读一次 manifest;这条读取只做「是否已登记」判定,
// 走带完整文件身份复核的进程内缓存,避免每次预览重复读取并解析整个 manifest。
let manifest = read_manifest_cached_for_preview(&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()?;
let mut preview =
load_local_project_text_preview_with_cancellation(root, &normalized_path, cancellation)?;
if normalized_path.to_ascii_lowercase().ends_with(".json") {
preview.ui_design_asset_id = manifest.assets.iter().find_map(|asset| {
(asset.local_path == normalized_path
&& ui_editor::persistence::is_valid_ui_design_json(
&preview.content,
&manifest.project_id,
&asset.id,
))
.then(|| asset.id.clone())
});
}
cancellation.check()?;
Ok(preview)
}
/**
* 读取引擎(Cocos)序列化资源的**只读结构预览**。
*
* 与文本预览分开的理由:`.prefab` / `.scene` / `.anim` / `.effect` 这些扩展名不属于
* 「可编辑文本资源」白名单(那份名单同时服务 UI 编辑器),把它们并进去会顺手改变
* UI 编辑链路的准入;这里只服务资源画布的卡面预览,且允许非 UTF-8 的二进制变体
* 降级成类型卡(`content: null`),不把「不能预览」报成错误。
*/
#[tauri::command]
pub(crate) async fn read_local_project_structured_preview(
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
project_path: String,
relative_path: String,
scope_id: String,
request_id: String,
) -> Result<LocalProjectStructuredPreview, String> {
preview_manager
.run(&scope_id, &request_id, move |cancellation| {
read_local_project_structured_preview_at(&project_path, &relative_path, cancellation)
})
.await
}
pub(crate) fn read_local_project_structured_preview_at(
project_path: &str,
relative_path: &str,
cancellation: &ProjectResourcePreviewScopeCancellation,
) -> Result<LocalProjectStructuredPreview, 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_cached_for_preview(&root.join(".agent/manifest.json"))?;
cancellation.check()?;
let registered_media_type = manifest
.assets
.iter()
.find(|asset| asset.local_path == normalized_path)
.map(|asset| asset.media_type.clone());
let is_registered_structured = registered_media_type.as_deref().is_some_and(|media_type| {
is_supported_project_structured_resource(&normalized_path, media_type)
}) || manifest.tasks.iter().any(|task| {
task.status == GameCreationAppTaskStatus::Completed
&& task.artifacts.iter().any(|path| path == &normalized_path)
&& is_supported_project_structured_resource(&normalized_path, "")
});
if !is_registered_structured {
return Err("只能读取当前项目已登记的引擎资源".to_string());
}
cancellation.check()?;
load_local_project_structured_preview_with_cancellation(
root,
&normalized_path,
registered_media_type.as_deref().unwrap_or(""),
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())?;
// 资源画布会对每张卡片各读一次 manifest;这条读取只做「是否已登记」判定,
// 走带完整文件身份复核的进程内缓存,避免每次预览重复读取并解析整个 manifest。
let manifest = read_manifest_cached_for_preview(&root.join(".agent/manifest.json"))?;
cancellation.check()?;
let kind = match category.trim() {
"art" => ProjectMediaPreviewKind::Art,
"audio" => ProjectMediaPreviewKind::Audio,
"model" => ProjectMediaPreviewKind::Model,
_ => return Err("媒体预览类别只支持 art、audio 或 model".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)
}
ProjectMediaPreviewKind::Model => {
is_supported_project_model_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, "")
}
ProjectMediaPreviewKind::Model => {
is_supported_project_model_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_game_creator_agent_runtime_project_write_lock_with_wait(
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) async fn read_local_conversation(
project_path: String,
agent_id: Option<String>,
session_id: Option<String>,
) -> Result<LocalConversationResult, String> {
tauri::async_runtime::spawn_blocking(move || {
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())
})
.await
.map_err(|error| format!("读取项目对话后台任务失败:{error}"))?
}
#[tauri::command]
pub(crate) async fn read_direct_project_conversation(
project_path: String,
) -> Result<LocalConversationResult, String> {
tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
read_direct_project_chat_history_at(root)
})
.await
.map_err(|error| format!("读取 DirectProject 历史后台任务失败:{error}"))?
}
#[tauri::command]
pub(crate) async fn read_agent_runtime_error_detail(
project_path: String,
detail_ref: String,
) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
let relative = detail_ref.trim();
let Some(file_name) = relative.strip_prefix(".agent/runtime/errors/") else {
return Err("错误诊断引用不在项目错误目录内".to_string());
};
if file_name.is_empty()
|| file_name.contains(['/', '\\'])
|| file_name.contains("..")
|| !file_name.ends_with(".json")
{
return Err("错误诊断引用格式无效".to_string());
}
let path = root.join(relative);
prepare_game_creator_private_path_for_read(&path, false, "统一错误诊断")?;
let bytes = std::fs::read(&path).map_err(|error| format!("读取错误诊断失败:{error}"))?;
if bytes.len() > 16 * 1024 {
return Err("错误诊断超过读取上限".to_string());
}
let text = String::from_utf8(bytes).map_err(|_| "错误诊断不是 UTF-8 文本".to_string())?;
Ok(redact_agent_runtime_error(root, &text, 16 * 1024))
})
.await
.map_err(|error| format!("读取统一错误诊断后台任务失败:{error}"))?
}
#[tauri::command]
pub(crate) async fn read_direct_tool_calls(
project_path: String,
) -> Result<Vec<DirectToolCall>, String> {
tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
read_direct_tool_calls_at(root)
})
.await
.map_err(|error| format!("读取工具调用历史后台任务失败:{error}"))?
}
#[tauri::command]
pub(crate) async fn read_direct_turn_stream(
project_path: String,
) -> Result<Vec<DirectTurnStreamItem>, String> {
tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
read_direct_turn_stream_at(root)
})
.await
.map_err(|error| format!("读取回合流历史后台任务失败:{error}"))?
}
#[tauri::command]
pub(crate) fn list_game_creator_direct_active_turns(
) -> Result<Vec<DirectActiveTurnSnapshot>, String> {
list_direct_active_turns()
}
#[tauri::command]
pub(crate) async fn subscribe_direct_project_thread(
project_path: String,
) -> Result<DirectThreadSubscriptionBootstrap, String> {
tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
let (canonical_root, _) = direct_codex_canonical_project_identity(root)?;
let thread_root = canonical_root
.to_str()
.and_then(|value| value.strip_prefix("\\\\?\\"))
.map(Path::new)
.unwrap_or(canonical_root.as_path());
let thread_id = thread_root.to_string_lossy().into_owned();
let mut bootstrap = subscribe_direct_thread(&thread_id);
if bootstrap.last_completed_item_id.is_none() {
bootstrap.last_completed_item_id = read_direct_project_last_item_id_at(root)?;
}
Ok(bootstrap)
})
.await
.map_err(|error| format!("订阅 DirectProject 线程后台任务失败:{error}"))?
}
#[tauri::command]
pub(crate) fn consume_direct_project_thread(
subscription_id: String,
) -> Result<DirectThreadConsumeResult, String> {
consume_direct_thread(subscription_id.trim())
}
#[tauri::command]
pub(crate) async fn read_direct_project_history_slice(
project_path: String,
before_item_id: Option<String>,
limit: Option<usize>,
messages_only: Option<bool>,
) -> Result<DirectThreadHistorySlice, String> {
tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
let read_slice = if messages_only.unwrap_or(false) {
read_direct_project_chat_items_slice_at
} else {
read_direct_project_history_items_slice_at
};
let (items, has_more, item_timestamps) =
read_slice(root, before_item_id.as_deref(), limit.unwrap_or(20))?;
let oldest_item_id = items
.first()
.and_then(|item| item.get("id"))
.and_then(serde_json::Value::as_str)
.filter(|id| !id.is_empty())
.map(str::to_string);
Ok(DirectThreadHistorySlice {
items,
has_more,
item_timestamps,
oldest_item_id,
})
})
.await
.map_err(|error| format!("读取 DirectProject 历史切片后台任务失败:{error}"))?
}
#[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)
}