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
## 目的 资源画布支持引擎(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
5900 lines
230 KiB
Rust
5900 lines
230 KiB
Rust
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"), ¤t)?;
|
||
fs::remove_file(&absolute_path).map_err(|remove_error| {
|
||
format!(
|
||
"删除未完成 UI 设计资源失败:{}: {remove_error}",
|
||
absolute_path.display()
|
||
)
|
||
})
|
||
})();
|
||
return match rollback {
|
||
Ok(()) => Err(error),
|
||
Err(rollback_error) => Err(format!(
|
||
"UI 设计资源初始化失败:{error};reconciliation-required: 回滚未完成:{rollback_error}"
|
||
)),
|
||
};
|
||
}
|
||
advance_agent_runtime_project_revision_locked(root).map_err(|error| {
|
||
format!("reconciliation-required: UI 设计资源已创建,但项目 revision 未能推进:{error}")
|
||
})?;
|
||
let manifest = read_existing_manifest_for_project(root)?;
|
||
let revision = read_game_creator_agent_runtime_project_revision(root)?.revision;
|
||
Ok(CreateUiDesignResourceResult {
|
||
asset,
|
||
manifest,
|
||
committed_project_revision: revision,
|
||
})
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) 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, ®istered.id, ®istered.local_path)
|
||
.expect("find registered font");
|
||
let (font, bytes) = read_registered_ui_editor_font(root, entry).expect("read font");
|
||
|
||
assert_eq!(font.asset_id.as_str(), registered.id);
|
||
assert_eq!(font.path, relative_path);
|
||
assert_eq!(font.content_sha256.len(), 64);
|
||
assert!(!bytes.is_empty());
|
||
assert!(find_registered_ui_editor_font(&manifest, "missing", relative_path).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn local_font_import_copies_registers_and_reuses_identical_content() {
|
||
let project = tempfile::tempdir().expect("project tempdir");
|
||
let root = project.path();
|
||
init_local_game_project_at(root, "font-project", "Font Project")
|
||
.expect("initialize project");
|
||
let source_path = fixture_font_path().to_string_lossy().into_owned();
|
||
let revision_before = read_game_creator_agent_runtime_project_revision(root)
|
||
.expect("read initial revision")
|
||
.revision;
|
||
|
||
let first = import_ui_editor_local_fonts(
|
||
root.to_string_lossy().into_owned(),
|
||
vec![source_path.clone()],
|
||
)
|
||
.expect("import font");
|
||
assert_eq!(first.assets.len(), 1);
|
||
let imported = &first.assets[0];
|
||
assert!(imported.local_path.starts_with("assets/fonts/"));
|
||
assert!(imported.local_path.ends_with("-fusion-pixel.ttf"));
|
||
assert!(root.join(&imported.local_path).is_file());
|
||
let manifest = read_existing_manifest_for_project(root).expect("read manifest");
|
||
assert_eq!(
|
||
manifest
|
||
.assets
|
||
.iter()
|
||
.filter(|asset| is_ui_editor_font_manifest_asset(asset))
|
||
.count(),
|
||
1
|
||
);
|
||
assert!(manifest
|
||
.assets
|
||
.iter()
|
||
.any(|asset| { asset.id == imported.id && asset.local_path == imported.local_path }));
|
||
let revision_after_first = read_game_creator_agent_runtime_project_revision(root)
|
||
.expect("read imported revision")
|
||
.revision;
|
||
assert_eq!(revision_after_first, revision_before + 1);
|
||
|
||
let second =
|
||
import_ui_editor_local_fonts(root.to_string_lossy().into_owned(), vec![source_path])
|
||
.expect("reimport identical font");
|
||
assert_eq!(second, first);
|
||
let manifest = read_existing_manifest_for_project(root).expect("read reused manifest");
|
||
assert_eq!(
|
||
manifest
|
||
.assets
|
||
.iter()
|
||
.filter(|asset| is_ui_editor_font_manifest_asset(asset))
|
||
.count(),
|
||
1
|
||
);
|
||
assert_eq!(
|
||
read_game_creator_agent_runtime_project_revision(root)
|
||
.expect("read reused revision")
|
||
.revision,
|
||
revision_after_first
|
||
);
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn registered_font_reader_rejects_symlink() {
|
||
use std::os::unix::fs::symlink;
|
||
|
||
let project = tempfile::tempdir().expect("project tempdir");
|
||
let root = project.path();
|
||
let relative_path = "assets/fonts/linked.ttf";
|
||
let target = root.join(relative_path);
|
||
fs::create_dir_all(target.parent().expect("font parent")).expect("create font parent");
|
||
symlink(fixture_font_path(), &target).expect("create font symlink");
|
||
let entry = GameCreationAppAssetManifestEntry {
|
||
id: "font-linked".to_string(),
|
||
kind: "font".to_string(),
|
||
media_type: "font/ttf".to_string(),
|
||
local_path: relative_path.to_string(),
|
||
image_sequence_frames: None,
|
||
image_sequence_duration_ms: None,
|
||
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 Creator(3.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)
|
||
}
|