Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/commands.rs
T
suzmii daef9f6cc9 修复素材删除幂等、重命名 CAS 与 manifest 预览缓存一致性
修复素材删除幂等、重命名 CAS 与 manifest 预览缓存一致性(PR #316 review)

- assets.rs:2048-2049 [bug · medium] 已被修复:删除登记在「身份 + revision 两项 CAS 都已通过」后遇到素材已不在 manifest 时按 no-op 成功收敛并照常推进 revision,不再报「项目资源不存在」;新增 `asset_delete_retry_converges_when_only_the_revision_advance_failed` 复现「manifest 已提交、revision 未推进」并验证重试收敛(原「不存在的素材必须报错」断言相应改为幂等语义,前端只消费 assetId/committedProjectRevision)。
- assets.rs:2035-2042 [performance · low] 已被修复:引用集合只算一次并用 `HashSet<String>` 做成员判定,`retain` 不再是 O(V²);版本守卫闭包按约定在写入前另行求值、拿不到该可变借用,故两次扫描保留并已在注释里说明。
- commands.rs:1032 [maintainability · medium] 已被修复:`RenameLocalProjectAssetInput` 补 `expectedProjectId` / `expectedProjectRevision`,`rename_local_project_asset_at` 与删除/分类更新同口径做两段身份复核 + revision CAS,失败时磁盘、manifest、revision 全不动;新增 CAS 用例(陈旧 revision / 跨项目身份 / 空身份)。注意:前端 `confirmResourceRename` 必须同时补传这两个字段(TS 侧由前端 owner 落地)。
- asset_rename.rs:84 [bug · low] 已被修复:新增 `asset_frame_directory_matches`,目录段在 Windows/macOS 按大小写不敏感比较(Linux 保持敏感),与文件名比较同口径;新增 `Assets/hero.png` 帧对齐用例。
- asset_rename.rs:249-250 [bug · medium] 已被修复:revision 推进失败改为报 `reconciliation-required: 素材已改名…且 manifest 已落盘,但项目 revision 未能推进`,明确这是「已提交但对账未完成」而非普通失败;同时新增的 CAS 使后续命令必须按新 revision 重试,重试会命中同名 no-op 分支收敛。
- tests/asset_rename.rs:257-263 [maintainability · medium] 已被修复:删除 `RenameLocalProjectAssetFaultStage` 枚举与生产签名上的 `fault` 参数,故障注入改用 `#[cfg(test)]` 的 `.agent/runtime/test-fail-next-asset-rename-manifest-write` 标记文件(与 agent_db 既有约定一致),生产 API 面不再能被推上「只回滚、不写 manifest」的路径。
- manifest.rs:1386 [bug · medium] 已被修复:预览缓存失效点下沉到唯一的装盘公共体 `write_manifest_locked_with_version_guard`(install 成功后立即失效,含安装后回读不一致的失败路径),`mutate_manifest_at_allowing_version_removals` 与绑定改写通道一并覆盖;`write_manifest_with_lock_hook` 里的重复调用已移除。
- manifest.rs:1480-1482 [bug · medium] 已被修复:填充侧改为「先取身份快照 → 读取 → 复核快照一致才入缓存」,不再可能出现「旧内容 + 新文件身份」的缓存条目。
- manifest.rs:1135 [security · low] 已被修复:新增 `normalize_manifest_asset_tags`,在共享归一化之后按数量(16)与单标签长度(32 字符)失败关闭;超长标签不截断,避免与共享标签库静默分叉。
- manifest.rs:1180-1181 [bug · medium] 已被修复:审计改为幂等——前后分类与标签完全相同时不追加记录,重试不会再写出 `previousCategory == category` 的假变更审计(保留「manifest 落盘后、推进 revision 前追加」这一既有位置约定)。
- manifest.rs:1140 [maintainability · low] 已被修复:分类写入的项目写锁标签由 `asset.register` 改为 `asset.classification.update`,争用诊断不再误报成素材登记。
- 测试:新增 preview_cache_tests(命中/TOCTOU 不入缓存/身份漂移丢弃/主写入通道显式失效)、分类重复写入不留假审计、标签上下界拒绝;7 条变异验证全部 CAUGHT。
2026-09-12 20:20:12 +08:00

4988 lines
194 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
use crate::agent::read_direct_project_chat_history_at;
use crate::ui_editor::resource::font::FontAsset;
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, HashSet};
const UI_EDITOR_FONT_MAX_FILE_SIZE: u64 = 8 * 1024 * 1024;
const UI_EDITOR_FONT_MAX_TOTAL_SIZE: u64 = 32 * 1024 * 1024;
const UI_EDITOR_FONT_MAX_COUNT: usize = 64;
const UI_EDITOR_IMAGE_MAX_FILE_SIZE: u64 = 20 * 1024 * 1024;
const UI_EDITOR_IMAGE_MAX_TOTAL_SIZE: u64 = 256 * 1024 * 1024;
const UI_EDITOR_IMAGE_MAX_COUNT: usize = 100;
// Agent 查询/导入账户素材使用比 UI 更窄的投影和同一批次上限。原始 objectKey、
// imageSrc 与签名地址只在本文件的客户端下载阶段存在,绝不进入 Agent observation。
const AGENT_EDITOR_ASSET_LIBRARY_MAX_ITEMS: usize = 500;
const AGENT_EDITOR_ASSET_ID_MAX_CHARS: usize = 512;
const AUTOMATIC_PROJECT_NAME_MAX_PROMPT_CHARS: usize = 8_000;
const AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS: u32 = 64;
const AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT: &str =
include_str!("../prompts/automatic-project-name.md");
// 聊天输入区的 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 = crate::agent::direct_game_creator_home_codex_chat(
AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT.trim().to_string(),
user_prompt,
)
.await?;
return Ok(normalize_suggested_project_name(reply.trim()));
}
let mut llm = app_config.llm.clone();
llm.max_retries = 0;
let client = build_game_creator_llm_client_from_llm_config(&llm, "llm")?;
let request =
LlmRunRequest::single_turn(AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT.trim(), user_prompt)
.with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?)
.with_model(llm.model.clone())
.with_request_timeout_ms(llm.request_timeout_ms)
.with_max_output_tokens(AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS)
.with_web_search(false);
let response = client
.run(request)
.await
.map_err(|error| format!("自动项目命名失败:{error}"))?;
Ok(normalize_suggested_project_name(response.text.trim()))
}
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 = "Genarrative GameAgent";
fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result<PathBuf, String> {
app.path()
.document_dir()
.map(|documents_root| documents_root.join(AUTOMATIC_PROJECTS_DIRECTORY_NAME))
.map_err(|error| format!("无法读取系统文档目录:{error}"))
}
pub(crate) fn create_automatic_local_game_project_at(
projects_root: &Path,
requested_name: Option<&str>,
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 = (|| {
prepare_game_creator_private_path_for_read(
&project_root,
true,
"自动项目目录",
)?;
enforce_project_permission_policy(&project_root, "project.create")?;
let _lock = acquire_project_write_lock(&project_root, "project.create")?;
init_local_game_project_at(
&project_root,
&format!("gameagent-{workspace_id}"),
&project_name,
)
})();
if result.is_err() {
let _ = fs::remove_dir_all(&project_root);
}
return result;
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => {
return Err(format!(
"创建自动工作区失败:{}: {error}",
project_root.display()
));
}
}
}
Err("自动工作区命名冲突,请重试".to_string())
}
#[tauri::command]
pub(crate) fn create_automatic_local_game_project(
app: tauri::AppHandle,
name: Option<String>,
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 is_local_project_directory_non_empty(project_path: String) -> Result<bool, String> {
let root = Path::new(project_path.trim());
if root.as_os_str().is_empty() {
return Err("项目目录不能为空".to_string());
}
if !root.is_absolute() {
return Err("项目目录必须是绝对路径".to_string());
}
if project_path_has_control_chars(root) {
return Err("项目目录不能包含控制字符".to_string());
}
crate::prepare_game_creator_project_root_for_read(root, true, "项目目录")?;
if !root.exists() {
return Ok(false);
}
if !root.is_dir() {
return Err("项目目录已存在但不是文件夹".to_string());
}
fs::read_dir(root)
.map_err(|error| format!("读取项目目录失败:{}: {error}", root.display()))?
.next()
.transpose()
.map_err(|error| format!("读取项目目录失败:{}: {error}", root.display()))
.map(|entry| entry.is_some())
}
#[tauri::command]
pub(crate) fn inspect_local_project_directory(
project_path: String,
) -> Result<LocalProjectDirectoryStatus, String> {
let root = Path::new(project_path.trim());
if root.as_os_str().is_empty() {
return Err("项目目录不能为空".to_string());
}
if !root.is_absolute() {
return Err("项目目录必须是绝对路径".to_string());
}
if project_path_has_control_chars(root) {
return Err("项目目录不能包含控制字符".to_string());
}
match fs::symlink_metadata(root) {
Ok(metadata) if metadata.is_dir() => {
crate::prepare_game_creator_project_root_for_read(root, true, "项目目录")?;
}
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
crate::prepare_game_creator_project_root_for_read(root, true, "项目目录")?;
}
_ => {}
}
let recent_run_trace = recent_game_creator_run_trace(root);
let godot_project_root = discover_local_godot_project_root(root)?;
Ok(LocalProjectDirectoryStatus {
project_path: root.to_string_lossy().into_owned(),
exists: root.exists(),
is_directory: root.is_dir(),
is_game_creator_project: is_game_creator_project_directory(root),
is_godot_project: godot_project_root.is_some(),
godot_project_root,
project_name: game_creator_project_name(root),
modified_at: project_directory_modified_at(root),
manifest_error: game_creator_project_manifest_error(root),
recent_run_status: recent_run_trace.as_ref().map(|trace| trace.status.clone()),
recent_run_stop_reason: recent_run_trace.map(|trace| trace.stop_reason),
})
}
fn project_directory_modified_at(root: &Path) -> Option<u64> {
root.metadata()
.ok()?
.modified()
.ok()?
.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
}
pub(crate) fn is_game_creator_project_directory(root: &Path) -> bool {
if !root.is_dir() {
return false;
}
let manifest_path = root.join(".agent/manifest.json");
read_manifest(&manifest_path).is_ok()
}
pub(crate) fn game_creator_project_name(root: &Path) -> Option<String> {
let manifest_path = root.join(".agent/manifest.json");
let manifest = read_manifest(&manifest_path).ok()?;
let name = manifest.name.trim();
if name.is_empty() {
None
} else {
Some(name.to_string())
}
}
pub(crate) fn game_creator_project_manifest_error(root: &Path) -> Option<String> {
if !root.is_dir() {
return None;
}
let manifest_path = root.join(".agent/manifest.json");
if !manifest_storage_exists(&manifest_path).unwrap_or(true) {
return None;
}
read_manifest(&manifest_path).err()
}
pub(crate) fn recent_game_creator_run_trace(root: &Path) -> Option<GameCreationAgentRunTrace> {
let trace_path = root.join(".agent/run.latest.json");
let content =
crate::read_game_creator_private_file_to_string(&trace_path, "最近运行状态", 256 * 1024)
.ok()?;
serde_json::from_str::<GameCreationAgentRunTrace>(&content).ok()
}
#[tauri::command]
pub(crate) async fn pick_local_project_directory(
app: tauri::AppHandle,
initial_path: Option<String>,
) -> Result<Option<String>, String> {
let (sender, receiver) = tokio::sync::oneshot::channel();
let mut dialog = app.dialog().file().set_title("选择游戏项目目录");
if let Some(initial_path) = initial_path
.as_deref()
.map(str::trim)
.filter(|path| !path.is_empty())
{
let initial_path = Path::new(initial_path);
if initial_path.is_absolute() && !project_path_has_control_chars(initial_path) {
let starting_directory = closest_existing_project_picker_directory(initial_path);
if let Some(starting_directory) = starting_directory {
dialog = dialog.set_directory(starting_directory);
}
}
}
if let Some(window) = app.get_webview_window("client") {
dialog = dialog.set_parent(&window);
}
dialog.pick_folder(move |path| {
let _ = sender.send(path);
});
let Some(path) = receiver
.await
.map_err(|_| "项目目录选择器意外关闭".to_string())?
else {
return Ok(None);
};
let path = path
.into_path()
.map_err(|error| format!("读取项目目录失败:{error}"))?;
#[cfg(windows)]
crate::register_game_creator_user_selected_path(&path, true);
// The native picker is the explicit user-selection boundary. Prepare the
// selected root before returning it so inspect/create/open never races the
// first ACL read.
if let Err(error) =
crate::prepare_game_creator_project_root_for_read(&path, true, "用户选择项目目录")
{
#[cfg(windows)]
crate::revoke_game_creator_user_selected_path(&path);
return Err(error);
}
Ok(Some(path.to_string_lossy().into_owned()))
}
#[tauri::command]
pub(crate) async fn pick_local_file(app: tauri::AppHandle) -> Result<Option<String>, String> {
let (sender, receiver) = tokio::sync::oneshot::channel();
let mut dialog = app.dialog().file().set_title("选择本地文件");
if let Some(window) = app.get_webview_window("client") {
dialog = dialog.set_parent(&window);
}
dialog.pick_file(move |path| {
let _ = sender.send(path);
});
let Some(path) = receiver
.await
.map_err(|_| "本地文件选择器意外关闭".to_string())?
else {
return Ok(None);
};
let path = path
.into_path()
.map_err(|error| format!("读取本地文件失败:{error}"))?;
#[cfg(windows)]
crate::register_game_creator_user_selected_path(&path, false);
if let Err(error) =
crate::prepare_game_creator_user_selected_path_for_read(&path, false, "用户选择文件")
{
#[cfg(windows)]
crate::revoke_game_creator_user_selected_path(&path);
return Err(error);
}
Ok(Some(path.to_string_lossy().into_owned()))
}
#[tauri::command]
pub(crate) fn open_local_project_directory(
app: tauri::AppHandle,
project_path: String,
) -> Result<(), String> {
let path = validated_local_project_directory_path(project_path.trim())?;
app.opener()
.open_path(path.to_string_lossy().into_owned(), None::<&str>)
.map_err(|error| format!("打开项目目录失败:{error}"))
}
pub(crate) fn validated_local_project_directory_path(
project_path: &str,
) -> Result<PathBuf, String> {
let path = Path::new(project_path);
if path.as_os_str().is_empty() {
return Err("项目目录不能为空".to_string());
}
if !path.is_absolute() {
return Err("项目目录必须是绝对路径".to_string());
}
if project_path_has_control_chars(path) {
return Err("项目目录不能包含控制字符".to_string());
}
if !path.exists() {
return Err("项目目录不存在".to_string());
}
if !path.is_dir() {
return Err("项目路径不是文件夹".to_string());
}
crate::prepare_game_creator_project_root_for_read(path, true, "项目目录")?;
Ok(path.to_path_buf())
}
/// Open the approved Fast GDD Markdown in whatever application the OS has
/// registered for it.
///
/// The GDD is the one product artifact the 立项策划 lane hands back, and it is
/// already on disk — `plan.submit_gdd` renders `game/fast_gdd.md` and the
/// approval receipt re-renders it with the approved header. This command only
/// hands that existing path to the shell; it never creates or rewrites it.
#[tauri::command]
pub(crate) fn open_local_project_plan_gdd_markdown(
app: tauri::AppHandle,
project_path: String,
) -> Result<(), String> {
let path = validated_local_project_plan_gdd_markdown_path(project_path.trim())?;
app.opener()
.open_path(path.to_string_lossy().into_owned(), None::<&str>)
.map_err(|error| format!("打开 Fast GDD 文件失败:{error}"))
}
pub(crate) fn validated_local_project_plan_gdd_markdown_path(
project_path: &str,
) -> Result<PathBuf, String> {
let root = validated_local_project_directory_path(project_path)?;
// `resolve_local_project_path` 是项目内路径的唯一安全入口:它做根校验、相对路径
// 归一化,并逐段拒绝符号链接。这里的相对路径是常量,但仍然走它——GDD 的渲染侧
// `planning_storage`)用的也是同一个解析器,两边对「项目内的这个文件」必须是
// 同一个判定,不能一边解析一边拼字符串。
let path = resolve_local_project_path(&root, PLAN_FAST_GDD_PATH)?;
match fs::symlink_metadata(&path) {
Ok(metadata) if metadata.file_type().is_file() => Ok(path),
Ok(_) => Err("Fast GDD 产物不是普通文件".to_string()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
Err("Fast GDD 产物尚未生成,请先完成立项策划审批".to_string())
}
Err(error) => Err(format!("读取 Fast GDD 产物失败:{error}")),
}
}
#[cfg(test)]
mod plan_gdd_markdown_path_tests {
use super::*;
fn fixture() -> tempfile::TempDir {
let temporary = tempfile::tempdir().expect("create GDD path fixture");
crate::project::init_local_game_project_at(
&temporary.path().join("project"),
"gdd-open",
"打开 GDD 产物",
)
.expect("initialize GDD path fixture");
temporary
}
#[test]
fn resolves_the_rendered_markdown_under_the_project_root() {
let temporary = fixture();
let root = temporary.path().join("project");
fs::create_dir_all(root.join("game")).expect("create game directory");
fs::write(root.join(PLAN_FAST_GDD_PATH), "# Fast GDD").expect("render markdown");
let resolved =
validated_local_project_plan_gdd_markdown_path(&root.to_string_lossy().into_owned())
.expect("resolve rendered markdown");
assert_eq!(resolved, root.join(PLAN_FAST_GDD_PATH));
}
#[test]
fn refuses_to_open_a_markdown_that_has_not_been_rendered_yet() {
// 恢复态下 `plan.submit_gdd` 的 Markdown 渲染可能还没落盘。这时按钮必须给出
// 明确原因,而不是把一个不存在的路径丢给 shell 由系统弹一个无从解释的错误。
let temporary = fixture();
let root = temporary.path().join("project");
let error =
validated_local_project_plan_gdd_markdown_path(&root.to_string_lossy().into_owned())
.expect_err("missing markdown must fail closed");
assert!(error.contains("尚未生成"), "unexpected error: {error}");
}
#[test]
fn refuses_a_project_path_that_is_not_an_initialized_project() {
let temporary = tempfile::tempdir().expect("create bare fixture");
let error = validated_local_project_plan_gdd_markdown_path(
&temporary.path().to_string_lossy().into_owned(),
)
.expect_err("a directory without .agent is not a project root");
assert!(!error.is_empty());
}
#[test]
fn refuses_a_relative_project_path() {
let error = validated_local_project_plan_gdd_markdown_path("relative/project")
.expect_err("relative project path must fail");
assert!(error.contains("绝对路径"), "unexpected error: {error}");
}
}
#[tauri::command]
pub(crate) fn get_local_game_manifest(
project_path: String,
command_id: Option<String>,
) -> Result<GameCreationAppManifest, String> {
let root = Path::new(project_path.trim());
let command_id = command_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("project.status");
if !matches!(
command_id,
"project.status" | "asset.list" | "task.list" | "agent.audit"
) {
return Err(format!("不支持通过 manifest 执行命令:{command_id}"));
}
enforce_project_permission_policy(root, command_id)?;
read_manifest_for_project_with_godot_root_calibration(root)
}
/// 读取资源画布的持久化布局(`.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_generation() -> u64 {
current_platform_session_generation()
}
#[tauri::command]
pub(crate) fn install_platform_account_session(
user_id: String,
access_token: String,
api_base_url: String,
generation: u64,
) -> Result<(), String> {
validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?;
install_external_agent_runner_platform_session(
&user_id,
&access_token,
&api_base_url,
generation,
)?;
install_platform_session(&user_id, &access_token, &api_base_url, generation)
}
#[tauri::command]
pub(crate) fn clear_platform_account_session(generation: u64) -> Result<(), String> {
shutdown_game_creator_codex_app_servers()?;
clear_external_agent_runner_platform_session(generation)?;
clear_platform_session(generation);
Ok(())
}
#[tauri::command]
pub(crate) fn read_game_creator_app_config() -> Result<GameCreatorAppConfigView, String> {
game_creator_app_config_view(load_game_creator_app_config()?)
}
static GAME_CREATOR_CONFIG_WRITE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[tauri::command]
pub(crate) fn write_game_creator_app_config(
mut config: GameCreatorAppConfig,
) -> Result<GameCreatorAppConfigView, String> {
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
.lock()
.map_err(|_| "配置写入锁不可用")?;
let (current, overlays) = load_game_creator_app_config_for_write()?;
config.selected_model_id = current.selected_model_id;
config.selected_model_is_default = current.selected_model_is_default;
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(|_| "配置写入锁不可用")?;
if model_id.is_empty()
|| model_id.len() > 64
|| !model_id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
{
return Err("模型标识无效".into());
}
let (mut config, overlays) = load_game_creator_app_config_for_write()?;
config.selected_model_id = model_id;
config.selected_model_is_default = is_default;
persist_game_creator_app_config(config, overlays, true)
}
fn persist_game_creator_app_config(
config: GameCreatorAppConfig,
overlays: Vec<(PathBuf, serde_json::Value)>,
model_only: bool,
) -> Result<GameCreatorAppConfigView, String> {
let config = normalize_game_creator_app_config(config)?;
let path = writable_game_creator_config_path()?;
let content = serialize_game_creator_app_config_for_renderer_write(&config)?;
let mut writes = vec![(path, format!("{content}\n"))];
let saved: serde_json::Value = serde_json::from_str(&content)
.map_err(|error| format!("解析已序列化客户端配置失败:{error}"))?;
for (overlay_path, mut overlay) in overlays {
let previous = overlay.clone();
if let Some(fields) = overlay.as_object_mut() {
for (key, value) in fields.iter_mut() {
if matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault")
== model_only
{
if let Some(saved_value) = saved.get(key) {
// 仅同步已有覆盖项;其它字段继续保留原有覆盖语义。
if key == "agentLlm" {
*value = saved_value.clone();
} else {
update_existing_config_overlay_fields(value, saved_value);
}
}
}
}
}
if overlay != previous {
let content = serde_json::to_string_pretty(&overlay)
.map_err(|error| format!("序列化客户端覆盖配置失败:{error}"))?;
writes.push((overlay_path, format!("{content}\n")));
}
}
crate::config::write_game_creator_config_batch(&writes)?;
// `config` is already normalized and is exactly what was persisted.
// Avoid reloading it here: a reload repeats the Windows private-path and
// ACL checks and made saving the settings panel appear to hang.
game_creator_app_config_view(config)
}
fn update_existing_config_overlay_fields(
overlay: &mut serde_json::Value,
saved: &serde_json::Value,
) {
if let (Some(fields), Some(saved_fields)) = (overlay.as_object_mut(), saved.as_object()) {
for (key, value) in fields {
if let Some(saved_value) = saved_fields.get(key) {
update_existing_config_overlay_fields(value, saved_value);
}
}
} else if !overlay.is_null() {
*overlay = saved.clone();
}
}
#[tauri::command]
pub(crate) fn upload_local_asset(
project_path: String,
file_name: String,
media_type: String,
bytes: Vec<u8>,
) -> Result<UploadLocalAssetResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "asset.upload")?;
let _lock = acquire_project_write_lock(root, "asset.upload")?;
advance_agent_runtime_project_revision_locked(root)?;
upload_local_asset_at(root, file_name.trim(), media_type.trim(), &bytes)
}
#[tauri::command]
pub(crate) fn register_local_asset(
project_path: String,
local_path: String,
kind: String,
media_type: String,
source_kind: String,
canvas_project_id: Option<String>,
resource_id: Option<String>,
asset_object_id: Option<String>,
task_id: Option<String>,
prompt: Option<String>,
model: Option<String>,
) -> Result<UploadLocalAssetResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
let _lock = acquire_project_write_lock(root, "asset.register")?;
advance_agent_runtime_project_revision_locked(root)?;
register_local_asset_at(
root,
local_path.trim(),
kind.trim(),
media_type.trim(),
source_kind.trim(),
GameCreationAppAssetSource {
kind: parse_asset_source_kind(source_kind.trim())?,
canvas_project_id: trim_optional_string(canvas_project_id),
resource_id: trim_optional_string(resource_id),
asset_object_id: trim_optional_string(asset_object_id),
task_id: trim_optional_string(task_id),
prompt: trim_optional_string(prompt),
model: trim_optional_string(model),
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
)
}
#[tauri::command]
pub(crate) fn create_ui_design_resource(
project_path: String,
expected_project_id: String,
) -> Result<CreateUiDesignResourceResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
let _lock = acquire_project_write_lock(root, "asset.register")?;
let manifest = read_existing_manifest_for_project(root)?;
if manifest.project_id != expected_project_id.trim() {
return Err("project-identity-conflict".to_string());
}
let next_index = manifest
.assets
.iter()
.filter(|asset| asset.kind == "UI")
.count()
+ 1;
let resource_name = format!("UI 设计 {next_index}");
let relative_path = format!("ui/{resource_name}.json");
let absolute_path = resolve_local_project_path(root, &relative_path)?;
if let Some(parent) = absolute_path.parent() {
ensure_game_creator_private_directory_tree(parent, "UI 资源目录")?;
prepare_game_creator_private_path_for_read(parent, true, "UI 资源目录")?;
}
if prepare_game_creator_private_path_for_read(&absolute_path, false, "UI 资源")? {
return Err("UI 设计资源路径已存在,拒绝覆盖".to_string());
}
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
}
let mut file = options
.open(&absolute_path)
.map_err(|error| format!("创建 UI 资源失败:{}: {error}", absolute_path.display()))?;
if let Err(error) = harden_new_game_creator_private_path(&absolute_path, false, "UI 资源") {
drop(file);
let _ = fs::remove_file(&absolute_path);
return Err(error);
}
file.sync_all()
.map_err(|error| format!("同步 UI 资源失败:{}: {error}", absolute_path.display()))?;
drop(file);
let asset = match register_local_asset_at(
root,
&relative_path,
"UI",
"application/json",
"generated",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
canvas_project_id: None,
resource_id: Some(format!("ui:{next_index}")),
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
) {
Ok(asset) => asset,
Err(error) => {
let rollback = (|| {
write_manifest(&root.join(".agent/manifest.json"), &manifest)?;
fs::remove_file(&absolute_path).map_err(|remove_error| {
format!(
"删除未完成 UI 设计资源失败:{}: {remove_error}",
absolute_path.display()
)
})
})();
return match rollback {
Ok(()) => Err(error),
Err(rollback_error) => Err(format!(
"UI 设计资源登记失败:{error}reconciliation-required: 回滚未完成:{rollback_error}"
)),
};
}
};
if let Err(error) = ui_editor::persistence::initialize_ui_design_state_at(
root,
expected_project_id.trim(),
&asset.id,
) {
let rollback = (|| {
let mut current = read_existing_manifest_for_project(root)?;
current.assets.retain(|entry| entry.id != asset.id);
write_manifest(&root.join(".agent/manifest.json"), &current)?;
fs::remove_file(&absolute_path).map_err(|remove_error| {
format!(
"删除未完成 UI 设计资源失败:{}: {remove_error}",
absolute_path.display()
)
})
})();
return match rollback {
Ok(()) => Err(error),
Err(rollback_error) => Err(format!(
"UI 设计资源初始化失败:{error}reconciliation-required: 回滚未完成:{rollback_error}"
)),
};
}
advance_agent_runtime_project_revision_locked(root).map_err(|error| {
format!("reconciliation-required: UI 设计资源已创建,但项目 revision 未能推进:{error}")
})?;
let manifest = read_existing_manifest_for_project(root)?;
let revision = read_game_creator_agent_runtime_project_revision(root)?.revision;
Ok(CreateUiDesignResourceResult {
asset,
manifest,
committed_project_revision: revision,
})
}
#[tauri::command]
pub(crate) fn update_local_project_resource_classification(
input: UpdateLocalProjectResourceClassificationInput,
) -> Result<UpdateLocalProjectResourceClassificationResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
update_manifest_asset_classification_at(
root,
&input.expected_project_id,
input.expected_project_revision,
&input.asset_id,
&input.category,
input.tags,
)
}
#[tauri::command]
pub(crate) async fn derive_local_project_resource(
input: DeriveLocalProjectResourceInput,
) -> Result<DeriveLocalProjectResourceResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
derive_local_project_resource_at(input).await
}
#[tauri::command]
pub(crate) fn list_pending_local_project_resource_edits(
input: ListPendingLocalProjectResourceEditsInput,
) -> Result<Vec<PendingLocalProjectResourceEdit>, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "file.list")?;
list_pending_local_project_resource_edits_at(input)
}
#[tauri::command]
pub(crate) async fn resume_local_project_resource_edit(
input: ResumeLocalProjectResourceEditInput,
) -> Result<DeriveLocalProjectResourceResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
resume_local_project_resource_edit_at(input).await
}
#[tauri::command]
pub(crate) async fn request_local_project_resource_edit_service_identity_confirmation(
input: RequestResourceEditServiceIdentityConfirmationInput,
) -> Result<ResourceEditServiceIdentityConfirmation, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
request_resource_edit_service_identity_confirmation_at(input).await
}
#[tauri::command]
pub(crate) async fn confirm_local_project_resource_edit_service_identity(
input: ConfirmResourceEditServiceIdentityInput,
) -> Result<ConfirmResourceEditServiceIdentityResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
confirm_resource_edit_service_identity_at(input).await
}
#[tauri::command]
pub(crate) async fn archive_failed_local_project_resource_edit(
input: ArchiveFailedLocalProjectResourceEditInput,
) -> Result<ArchiveFailedLocalProjectResourceEditResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
archive_failed_local_project_resource_edit_at(input).await
}
#[tauri::command]
pub(crate) fn normalize_local_project_raster_resource(
input: NormalizeLocalProjectRasterResourceInput,
) -> Result<NormalizeLocalProjectRasterResourceResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
normalize_local_project_raster_resource_at(input)
}
#[tauri::command]
pub(crate) fn import_canvas_asset(
project_path: String,
local_path: String,
kind: String,
media_type: String,
canvas_project_id: String,
resource_id: Option<String>,
asset_object_id: Option<String>,
task_id: Option<String>,
prompt: Option<String>,
model: Option<String>,
) -> Result<UploadLocalAssetResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "canvas.asset_import")?;
let _lock = acquire_project_write_lock(root, "canvas.asset_import")?;
advance_agent_runtime_project_revision_locked(root)?;
import_canvas_asset_at(
root,
local_path.trim(),
kind.trim(),
media_type.trim(),
canvas_project_id.trim(),
trim_optional_string(resource_id),
trim_optional_string(asset_object_id),
trim_optional_string(task_id),
trim_optional_string(prompt),
trim_optional_string(model),
)
}
#[tauri::command]
pub(crate) fn import_canvas_export(
project_path: String,
export_path: String,
canvas_project_id: String,
) -> Result<ImportCanvasExportResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "canvas.export_import")?;
let _lock = acquire_project_write_lock(root, "canvas.export_import")?;
advance_agent_runtime_project_revision_locked(root)?;
import_canvas_export_at(
root,
Path::new(export_path.trim()),
canvas_project_id.trim(),
)
}
#[tauri::command]
pub(crate) async fn sync_canvas_project_assets(
project_path: String,
canvas_project_id: String,
api_base_url: Option<String>,
api_key: Option<String>,
) -> Result<SyncCanvasProjectAssetsResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "canvas.project_sync")?;
let _lock = acquire_project_write_lock(root, "canvas.project_sync")?;
advance_agent_runtime_project_revision_locked(root)?;
sync_canvas_project_assets_at(root, canvas_project_id.trim(), api_base_url, api_key).await
}
pub(crate) fn import_ui_editor_local_files(
project_path: String,
source_paths: Vec<String>,
) -> Result<LocalImportResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "asset.upload")?;
let _lock = acquire_project_write_lock(root, "asset.upload")?;
if source_paths.len() > UI_EDITOR_IMAGE_MAX_COUNT {
return Err(format!("一次最多选择 {UI_EDITOR_IMAGE_MAX_COUNT} 张图片"));
}
// V1 有意采用增量导入:每个文件独立写入并登记,后续失败不回滚此前成功项。
// 调用方必须以返回结果和 manifest 为准重建已提交集合;整批原子语义需另行定义 transaction/reconciliation 合同。
let mut inputs = Vec::new();
let mut total_size = 0u64;
for source in source_paths {
let path = Path::new(source.trim());
crate::prepare_game_creator_user_selected_path_for_read(path, false, "本地图片")?;
let metadata = fs::symlink_metadata(path).map_err(|e| format!("读取本地图片失败:{e}"))?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err("只能导入普通图片文件".to_string());
}
if metadata.len() > UI_EDITOR_IMAGE_MAX_FILE_SIZE {
return Err(format!(
"图片超过 {} 字节限制",
UI_EDITOR_IMAGE_MAX_FILE_SIZE
));
}
let bytes = fs::read(path).map_err(|e| format!("读取本地图片失败:{e}"))?;
if bytes.len() as u64 > UI_EDITOR_IMAGE_MAX_FILE_SIZE {
return Err(format!(
"图片超过 {} 字节限制",
UI_EDITOR_IMAGE_MAX_FILE_SIZE
));
}
total_size = total_size
.checked_add(bytes.len() as u64)
.filter(|size| *size <= UI_EDITOR_IMAGE_MAX_TOTAL_SIZE)
.ok_or_else(|| {
format!(
"图片批次总量超过 {} 字节限制",
UI_EDITOR_IMAGE_MAX_TOTAL_SIZE
)
})?;
let media_type = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
"image/png"
} else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
"image/jpeg"
} else if bytes.starts_with(b"RIFF") && bytes.len() > 12 && &bytes[8..12] == b"WEBP" {
"image/webp"
} else {
return Err("本地文件不是受支持的 PNG/JPEG/WEBP 图片".to_string());
};
let name = path
.file_name()
.and_then(|v| v.to_str())
.unwrap_or("image")
.to_string();
inputs.push((name, media_type.to_string(), bytes));
}
let mut assets = Vec::with_capacity(inputs.len());
for (name, media, bytes) in inputs {
let asset = upload_local_asset_at(root, &name, &media, &bytes)?;
// 参考 save_ui_design_state_at:只有文件和 manifest 写入成功后才推进 revision。
// 每个成功项目独立提交,后续失败不会让已落盘项目停留在旧 revision。
advance_agent_runtime_project_revision_locked(root).map_err(|error| {
format!("reconciliation-required: 图片已导入,但项目 revision 未能推进:{error}")
})?;
assets.push(ImportedAsset {
id: asset.id,
local_path: asset.local_path,
asset_kind: None,
});
}
Ok(LocalImportResult { assets })
}
fn is_ui_editor_font_manifest_asset(asset: &GameCreationAppAssetManifestEntry) -> bool {
let media_type = asset.media_type.trim().to_ascii_lowercase();
let extension = Path::new(&asset.local_path)
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
matches!(
media_type.as_str(),
"font/ttf" | "font/otf" | "font/woff" | "font/woff2"
) || matches!(extension.as_str(), "ttf" | "otf" | "woff" | "woff2")
}
fn read_registered_ui_editor_font(
root: &Path,
asset: &GameCreationAppAssetManifestEntry,
) -> Result<(FontAsset, Vec<u8>), String> {
if !is_ui_editor_font_manifest_asset(asset) {
return Err("项目资产不是受支持的字体候选".to_string());
}
let target = resolve_local_project_path(root, &asset.local_path)?;
prepare_game_creator_private_path_for_read(&target, false, "项目字体")?;
let metadata = fs::symlink_metadata(&target).map_err(|_| "读取项目字体失败".to_string())?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err("项目字体必须是普通文件".to_string());
}
if metadata.len() > UI_EDITOR_FONT_MAX_FILE_SIZE {
return Err("项目字体超过 8 MiB 限制".to_string());
}
let bytes = fs::read(&target).map_err(|_| "读取项目字体失败".to_string())?;
let source_file_name = target
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("font")
.to_string();
let mut font = FontAsset::from_verified_bytes(
asset.id.clone(),
asset.local_path.clone(),
source_file_name,
&bytes,
)?;
if let Some(copied_name) = target.file_name().and_then(|value| value.to_str()) {
let hash_prefix = format!("{}-", font.content_sha256);
if let Some(original_name) = copied_name.strip_prefix(&hash_prefix) {
if !original_name.is_empty() {
font.metadata.source_file_name = original_name.to_string();
}
}
}
Ok((font, bytes))
}
fn find_registered_ui_editor_font<'a>(
manifest: &'a GameCreationAppManifest,
asset_id: &str,
relative_path: &str,
) -> Result<&'a GameCreationAppAssetManifestEntry, String> {
manifest
.assets
.iter()
.find(|asset| asset.id == asset_id && asset.local_path == relative_path)
.ok_or_else(|| "字体不是当前项目已登记资产".to_string())
}
#[tauri::command]
pub(crate) fn prepare_ui_editor_project_fonts(
project_path: String,
assets: Vec<ImportedAsset>,
) -> Result<Vec<FontAsset>, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "asset.list")?;
if assets.len() > UI_EDITOR_FONT_MAX_COUNT {
return Err("一次最多选择 64 个字体面".to_string());
}
let manifest = read_existing_manifest_for_project(root)?;
let mut fonts = Vec::with_capacity(assets.len());
for selected in assets {
let asset = find_registered_ui_editor_font(&manifest, &selected.id, &selected.local_path)?;
fonts.push(read_registered_ui_editor_font(root, asset)?.0);
}
Ok(fonts)
}
pub(crate) fn import_ui_editor_local_fonts(
project_path: String,
source_paths: Vec<String>,
) -> Result<LocalImportResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "asset.upload")?;
let _lock = acquire_project_write_lock(root, "asset.upload")?;
if source_paths.len() > UI_EDITOR_FONT_MAX_COUNT {
return Err("一次最多选择 64 个字体面".to_string());
}
let mut inputs = Vec::new();
let mut input_hashes = std::collections::BTreeSet::new();
for source in source_paths {
let path = Path::new(source.trim());
crate::prepare_game_creator_user_selected_path_for_read(path, false, "本地字体")?;
let metadata = fs::symlink_metadata(path).map_err(|_| "读取本地字体失败".to_string())?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err("只能导入普通字体文件".to_string());
}
if metadata.len() > UI_EDITOR_FONT_MAX_FILE_SIZE {
return Err("字体超过 8 MiB 限制".to_string());
}
let bytes = fs::read(path).map_err(|_| "读取本地字体失败".to_string())?;
let source_file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("font")
.to_string();
let validated = FontAsset::from_verified_bytes(
"font-validation",
"assets/fonts/validation.ttf",
source_file_name.clone(),
&bytes,
)?;
if input_hashes.insert(validated.content_sha256.clone()) {
inputs.push((source_file_name, bytes, validated));
}
}
let manifest = read_existing_manifest_for_project(root)?;
let mut existing_by_hash = std::collections::BTreeMap::new();
let mut existing_total_size = 0u64;
let mut existing_count = 0usize;
for asset in manifest
.assets
.iter()
.filter(|asset| is_ui_editor_font_manifest_asset(asset))
{
let Ok((font, bytes)) = read_registered_ui_editor_font(root, asset) else {
continue;
};
existing_total_size = existing_total_size.saturating_add(bytes.len() as u64);
existing_count += 1;
existing_by_hash
.entry(font.content_sha256.clone())
.or_insert(font);
}
let new_inputs = inputs
.iter()
.filter(|(_, _, font)| !existing_by_hash.contains_key(&font.content_sha256))
.collect::<Vec<_>>();
let new_total_size = new_inputs.iter().fold(0u64, |total, (_, bytes, _)| {
total.saturating_add(bytes.len() as u64)
});
if existing_count.saturating_add(new_inputs.len()) > UI_EDITOR_FONT_MAX_COUNT {
return Err("项目字体面最多 64 个".to_string());
}
if existing_total_size.saturating_add(new_total_size) > UI_EDITOR_FONT_MAX_TOTAL_SIZE {
return Err("项目字体总量超过 32 MiB 限制".to_string());
}
if !new_inputs.is_empty() {
let font_root = root.join("assets/fonts");
ensure_game_creator_private_directory_tree(&font_root, "项目字体目录")?;
prepare_game_creator_private_path_for_read(&font_root, true, "项目字体目录")?;
}
// 字体批次同样是增量提交合同:已经复制并登记的字体在后续失败时保留。
let mut result = Vec::with_capacity(inputs.len());
for (source_file_name, bytes, validated) in inputs {
if let Some(existing) = existing_by_hash.get(&validated.content_sha256) {
result.push(existing.clone());
continue;
}
let safe_stem = sanitize_file_name(
Path::new(&source_file_name)
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("font"),
);
let relative_path = format!(
"assets/fonts/{}-{safe_stem}.{}",
validated.content_sha256,
validated.metadata.format.extension()
);
let target = resolve_local_project_path(root, &relative_path)?;
if prepare_game_creator_private_path_for_read(&target, false, "项目字体")? {
return Err(format!("项目字体目标已存在但未登记:{}", target.display()));
}
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
}
let mut file = options
.open(&target)
.map_err(|error| format!("写入项目字体失败:{}: {error}", target.display()))?;
if let Err(error) = harden_new_game_creator_private_path(&target, false, "项目字体") {
drop(file);
let _ = fs::remove_file(&target);
return Err(error);
}
file.write_all(&bytes)
.and_then(|_| file.sync_all())
.map_err(|error| format!("写入项目字体失败:{}: {error}", target.display()))?;
drop(file);
let registered = register_local_asset_entry(
root,
&relative_path,
"font",
validated.metadata.format.media_type(),
"font",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
)?;
let font = FontAsset::from_verified_bytes(
registered.id,
registered.local_path,
source_file_name,
&bytes,
)?;
existing_by_hash.insert(font.content_sha256.clone(), font.clone());
result.push(font);
// 参考 save_ui_design_state_at:登记成功后才推进 revision;失败项不会伪造提交版本。
advance_agent_runtime_project_revision_locked(root).map_err(|error| {
format!("reconciliation-required: 字体已复制并登记,但项目 revision 未能推进:{error}")
})?;
}
Ok(LocalImportResult {
assets: result
.into_iter()
.map(|font| ImportedAsset {
id: font.asset_id.as_str().to_string(),
local_path: font.path,
asset_kind: Some("font".to_string()),
})
.collect(),
})
}
fn read_checked_ui_editor_font(
project_path: &str,
asset_id: &str,
relative_path: &str,
expected_sha256: &str,
) -> Result<(FontAsset, Vec<u8>), String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "file.read")?;
let manifest = read_existing_manifest_for_project(root)?;
let asset = find_registered_ui_editor_font(&manifest, asset_id, relative_path)?;
let (font, bytes) = read_registered_ui_editor_font(root, asset)?;
if font.content_sha256 != expected_sha256 {
return Err("字体文件内容与资源摘要不一致".to_string());
}
Ok((font, bytes))
}
#[tauri::command]
pub(crate) fn read_ui_editor_font_bytes(
project_path: String,
asset_id: String,
relative_path: String,
expected_sha256: String,
) -> Result<tauri::ipc::Response, String> {
read_checked_ui_editor_font(
&project_path,
asset_id.trim(),
relative_path.trim(),
expected_sha256.trim(),
)
.map(|(_, bytes)| tauri::ipc::Response::new(bytes))
}
#[tauri::command]
pub(crate) fn check_ui_editor_font_glyph_coverage(
project_path: String,
asset_id: String,
relative_path: String,
expected_sha256: String,
content: String,
) -> Result<bool, String> {
let (font, bytes) = read_checked_ui_editor_font(
&project_path,
asset_id.trim(),
relative_path.trim(),
expected_sha256.trim(),
)?;
font.has_missing_glyphs(&bytes, &content)
}
#[cfg(test)]
mod ui_editor_font_tests {
use super::*;
fn fixture_font_path() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../public/fusion-pixel.ttf")
}
#[test]
fn registered_font_reader_requires_manifest_identity_and_verified_bytes() {
let project = tempfile::tempdir().expect("project tempdir");
let root = project.path();
init_local_game_project_at(root, "font-project", "Font Project")
.expect("initialize project");
let relative_path = "assets/fonts/fusion-pixel.ttf";
let target = root.join(relative_path);
fs::create_dir_all(target.parent().expect("font parent")).expect("create font parent");
fs::copy(fixture_font_path(), &target).expect("copy font fixture");
let registered = register_local_asset_entry(
root,
relative_path,
"font",
"font/ttf",
"font",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
)
.expect("register font");
let manifest = read_existing_manifest_for_project(root).expect("read manifest");
let entry =
find_registered_ui_editor_font(&manifest, &registered.id, &registered.local_path)
.expect("find registered font");
let (font, bytes) = read_registered_ui_editor_font(root, entry).expect("read font");
assert_eq!(font.asset_id.as_str(), registered.id);
assert_eq!(font.path, relative_path);
assert_eq!(font.content_sha256.len(), 64);
assert!(!bytes.is_empty());
assert!(find_registered_ui_editor_font(&manifest, "missing", relative_path).is_err());
}
#[test]
fn local_font_import_copies_registers_and_reuses_identical_content() {
let project = tempfile::tempdir().expect("project tempdir");
let root = project.path();
init_local_game_project_at(root, "font-project", "Font Project")
.expect("initialize project");
let source_path = fixture_font_path().to_string_lossy().into_owned();
let revision_before = read_game_creator_agent_runtime_project_revision(root)
.expect("read initial revision")
.revision;
let first = import_ui_editor_local_fonts(
root.to_string_lossy().into_owned(),
vec![source_path.clone()],
)
.expect("import font");
assert_eq!(first.assets.len(), 1);
let imported = &first.assets[0];
assert!(imported.local_path.starts_with("assets/fonts/"));
assert!(imported.local_path.ends_with("-fusion-pixel.ttf"));
assert!(root.join(&imported.local_path).is_file());
let manifest = read_existing_manifest_for_project(root).expect("read manifest");
assert_eq!(
manifest
.assets
.iter()
.filter(|asset| is_ui_editor_font_manifest_asset(asset))
.count(),
1
);
assert!(manifest
.assets
.iter()
.any(|asset| { asset.id == imported.id && asset.local_path == imported.local_path }));
let revision_after_first = read_game_creator_agent_runtime_project_revision(root)
.expect("read imported revision")
.revision;
assert_eq!(revision_after_first, revision_before + 1);
let second =
import_ui_editor_local_fonts(root.to_string_lossy().into_owned(), vec![source_path])
.expect("reimport identical font");
assert_eq!(second, first);
let manifest = read_existing_manifest_for_project(root).expect("read reused manifest");
assert_eq!(
manifest
.assets
.iter()
.filter(|asset| is_ui_editor_font_manifest_asset(asset))
.count(),
1
);
assert_eq!(
read_game_creator_agent_runtime_project_revision(root)
.expect("read reused revision")
.revision,
revision_after_first
);
}
#[cfg(unix)]
#[test]
fn registered_font_reader_rejects_symlink() {
use std::os::unix::fs::symlink;
let project = tempfile::tempdir().expect("project tempdir");
let root = project.path();
let relative_path = "assets/fonts/linked.ttf";
let target = root.join(relative_path);
fs::create_dir_all(target.parent().expect("font parent")).expect("create font parent");
symlink(fixture_font_path(), &target).expect("create font symlink");
let entry = GameCreationAppAssetManifestEntry {
id: "font-linked".to_string(),
kind: "font".to_string(),
media_type: "font/ttf".to_string(),
local_path: relative_path.to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("font"),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
};
assert_eq!(
read_registered_ui_editor_font(root, &entry).expect_err("reject symlink"),
"项目文件路径不能包含符号链接或 Windows reparse point"
);
}
}
#[cfg(test)]
mod remote_asset_path_tests {
use super::*;
#[test]
fn remote_asset_paths_distinguish_ids_that_sanitize_to_the_same_name() {
let slash_path = remote_asset_local_path("a/b", "png");
let colon_path = remote_asset_local_path("a:b", "png");
assert_ne!(slash_path, colon_path);
assert_eq!(slash_path, remote_asset_local_path("a/b", "png"));
assert!(slash_path.starts_with("assets/uploads/remote-a_b-"));
assert!(slash_path.ends_with(".png"));
}
#[test]
fn duplicate_remote_asset_destination_is_rejected() {
let mut destinations = HashSet::new();
let local_path = remote_asset_local_path("stable-id", "webp");
reserve_remote_asset_destination(&mut destinations, &local_path)
.expect("reserve first destination");
assert_eq!(
reserve_remote_asset_destination(&mut destinations, &local_path)
.expect_err("reject duplicate destination"),
format!("平台素材目标路径重复:{local_path}")
);
}
}
#[cfg(test)]
mod agent_asset_import_tests {
use super::*;
fn tiny_png() -> Vec<u8> {
vec![0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']
}
#[test]
fn account_library_parser_filters_non_static_media_and_projects_safe_metadata() {
let payload = serde_json::json!({
"data": {
"library": {
"folders": [{"folderId": "folder-1", "label": "UI"}],
"assets": [
{
"assetId": "image-1",
"folderId": "folder-1",
"label": "按钮",
"imageSrc": "/assets/button.png",
"objectKey": "private/button.png",
"assetKind": "icon",
"width": 64,
"height": 64
},
{
"assetId": "video-1",
"imageSrc": "/video.mp4",
"assetKind": "video"
},
{
"assetId": "audio-1",
"imageSrc": "/audio.mp3",
"sourceType": "audio"
},
{
"assetId": "sequence-1",
"imageSrc": "/frame.png",
"imageSequenceFrames": []
}
]
}
}
});
let records = parse_agent_editor_asset_library(&payload).expect("parse library");
assert_eq!(records.len(), 1);
assert_eq!(records[0].asset_id, "image-1");
assert_eq!(records[0].folder_label.as_deref(), Some("UI"));
assert_eq!(records[0].object_key.as_deref(), Some("private/button.png"));
}
#[test]
fn project_resource_parser_requires_matching_project_and_filters_sequences() {
let payload = serde_json::json!({
"ok": true,
"data": {
"project": {
"projectId": "project-1",
"resources": [
{
"resourceId": "resource-1",
"imageSrc": "/resource.png",
"objectKey": "private/resource.png",
"assetKind": "scene",
"width": 320,
"height": 180
},
{
"resourceId": "resource-video",
"imageSrc": "/video.mp4",
"assetKind": "video"
},
{
"resourceId": "resource-sequence",
"imageSrc": "/frame.png",
"imageSequenceFrames": []
}
]
}
}
});
let records = parse_agent_editor_project_resources(&payload, "project-1")
.expect("parse project resources");
assert_eq!(records.len(), 1);
assert_eq!(records[0].asset_id, "resource-1");
assert_eq!(records[0].origin, AgentEditorAssetOrigin::ProjectCanvas);
assert_eq!(records[0].canvas_project_id.as_deref(), Some("project-1"));
assert!(parse_agent_editor_project_resources(&payload, "project-other").is_err());
}
#[test]
fn local_project_asset_import_registers_multiple_types_and_is_idempotent() {
let project = tempfile::tempdir().expect("create project directory");
let root = project.path();
init_local_game_project_at(root, "agent-local-import", "Agent local import")
.expect("initialize project");
fs::create_dir_all(root.join("assets")).expect("create assets directory");
fs::create_dir_all(root.join("game")).expect("create game directory");
fs::write(root.join("assets/in-place.png"), tiny_png()).expect("write in-place image");
fs::write(root.join("game/copied.png"), tiny_png()).expect("write game image");
fs::write(root.join("assets/theme.mp3"), b"fake-mp3").expect("write audio");
fs::write(root.join("assets/intro.mp4"), b"fake-mp4").expect("write video");
fs::write(root.join("game/index.html"), b"<html></html>").expect("write code");
fs::write(root.join("assets/design.md"), b"# design").expect("write document");
let revision_before = read_game_creator_agent_runtime_project_revision(root)
.expect("read initial revision")
.revision;
let first = import_local_project_assets_for_agent(
root,
&[
"assets/in-place.png".to_string(),
"game/copied.png".to_string(),
"assets/theme.mp3".to_string(),
"assets/intro.mp4".to_string(),
"game/index.html".to_string(),
"assets/design.md".to_string(),
],
)
.expect("import local assets");
assert_eq!(first.assets.len(), 6);
assert_eq!(first.assets[0].local_path, "assets/in-place.png");
assert!(first.assets[1]
.local_path
.starts_with("assets/uploads/local-"));
assert!(first.assets[1].local_path.ends_with(".png"));
assert_eq!(first.assets[0].asset_kind.as_deref(), Some("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 = tempfile::tempdir().expect("create project directory");
let root = project.path();
init_local_game_project_at(root, "agent-local-import", "Agent local import")
.expect("initialize project");
assert!(import_local_project_assets_for_agent(
root,
&[root.join("image.png").to_string_lossy().into_owned()]
)
.is_err());
assert!(
import_local_project_assets_for_agent(root, &[".AGENT/manifest.json".to_string()])
.is_err()
);
}
#[test]
fn local_project_asset_import_rejects_hidden_and_build_tree_sources() {
let project = tempfile::tempdir().expect("create project directory");
let root = project.path();
init_local_game_project_at(root, "agent-local-import", "Agent local import")
.expect("initialize project");
for (index, directory) in [
".git",
".codex",
"node_modules",
"target",
"dist",
"build",
"coverage",
]
.into_iter()
.enumerate()
{
let relative = format!("{directory}/image-{index}.png");
let source = root.join(&relative);
fs::create_dir_all(source.parent().expect("source parent"))
.expect("create forbidden source directory");
fs::write(&source, tiny_png()).expect("write forbidden source image");
assert!(
import_local_project_assets_for_agent(root, &[relative.clone()]).is_err(),
"forbidden source path should be rejected: {relative}"
);
}
}
#[test]
fn local_project_asset_import_rejects_unknown_and_invalid_text_files() {
let project = tempfile::tempdir().expect("create project directory");
let root = project.path();
init_local_game_project_at(root, "agent-local-import", "Agent local import")
.expect("initialize project");
fs::create_dir_all(root.join("assets")).expect("create assets directory");
fs::write(root.join("assets/unknown.bin"), b"bytes").expect("write unknown file");
fs::write(root.join("assets/broken.js"), [0xff, 0xfe]).expect("write invalid source");
assert!(
import_local_project_assets_for_agent(root, &["assets/unknown.bin".to_string()])
.is_err()
);
assert!(
import_local_project_assets_for_agent(root, &["assets/broken.js".to_string()]).is_err()
);
}
/// 平台导入(账户素材库 / 网页项目画布)落盘的 `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,
}),
_ => 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_validated_platform_session_fingerprint(
&session.user_id,
&session.api_base_url,
session.generation,
&format!("{:x}", Sha256::digest(session.access_token.as_bytes())),
)
})
.transpose()?;
let _lock = acquire_project_write_lock(root, "canvas.asset_import")?;
access.validate_frozen_session()?;
let manifest = read_existing_manifest_for_project(root)?;
let mut imported = Vec::with_capacity(downloads.len());
for (record, media_type, local_path, bytes) in downloads {
let target = resolve_local_project_path(root, &local_path)?;
if let Some(existing) = manifest.assets.iter().find(|asset| {
asset.local_path == local_path
|| asset.source.resource_id.as_deref() == Some(record.asset_id.as_str())
}) {
imported.push(ImportedAsset {
id: existing.id.clone(),
local_path: existing.local_path.clone(),
asset_kind: Some(existing.kind.clone()),
});
continue;
}
if target.exists() {
prepare_game_creator_private_path_for_read(&target, false, "账户图片目标")?;
return Err(format!("账户图片目标已存在但尚未登记:{local_path}"));
}
if let Some(parent) = target.parent() {
ensure_game_creator_private_directory_tree(parent, "账户图片导入目录")?;
prepare_game_creator_private_path_for_read(parent, true, "账户图片导入目录")?;
}
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
}
let mut file = options
.open(&target)
.map_err(|error| format!("写入账户图片失败:{}: {error}", target.display()))?;
if let Err(error) = harden_new_game_creator_private_path(&target, false, "账户图片目标")
{
drop(file);
let _ = fs::remove_file(&target);
return Err(error);
}
file.write_all(&bytes)
.and_then(|_| file.sync_all())
.map_err(|error| format!("写入账户图片失败:{}: {error}", target.display()))?;
drop(file);
let (source_kind, canvas_project_id, generation_route) = match record.origin {
AgentEditorAssetOrigin::AccountLibrary => (
GameCreationAppAssetSourceKind::Canvas,
None,
"editor.asset-library.agent-import",
),
AgentEditorAssetOrigin::ProjectCanvas => (
GameCreationAppAssetSourceKind::Canvas,
record.canvas_project_id.clone(),
"editor.project-canvas.agent-import",
),
};
// 账户素材记录自带 `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)
}
#[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()?;
load_local_project_text_preview_with_cancellation(root, &normalized_path, cancellation)
}
#[tauri::command]
pub(crate) async fn read_local_project_media_preview(
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
project_path: String,
relative_path: String,
category: String,
scope_id: String,
request_id: String,
) -> Result<LocalProjectMediaPreview, String> {
preview_manager
.run(&scope_id, &request_id, move |cancellation| {
read_local_project_media_preview_at(
&project_path,
&relative_path,
&category,
cancellation,
)
})
.await
}
pub(crate) fn read_local_project_media_preview_at(
project_path: &str,
relative_path: &str,
category: &str,
cancellation: &ProjectResourcePreviewScopeCancellation,
) -> Result<LocalProjectMediaPreview, String> {
cancellation.check()?;
let root = Path::new(project_path.trim());
enforce_project_auto_permission_policy(root, "file.read")?;
cancellation.check()?;
let normalized_path = normalize_relative_path(relative_path.trim())?;
// 资源画布会对每张卡片各读一次 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,
_ => return Err("媒体预览类别只支持 art 或 audio".to_string()),
};
let is_registered_media = manifest.assets.iter().any(|asset| {
asset.local_path == normalized_path
&& match kind {
ProjectMediaPreviewKind::Art => {
is_supported_project_art_media_resource(&asset.local_path, &asset.media_type)
}
ProjectMediaPreviewKind::Audio => {
is_supported_project_audio_resource(&asset.local_path, &asset.media_type)
}
}
}) || manifest.tasks.iter().any(|task| {
task.status == GameCreationAppTaskStatus::Completed
&& task.artifacts.iter().any(|path| path == &normalized_path)
&& match kind {
ProjectMediaPreviewKind::Art => {
is_supported_project_art_media_resource(&normalized_path, "")
}
ProjectMediaPreviewKind::Audio => {
is_supported_project_audio_resource(&normalized_path, "")
}
}
});
if !is_registered_media {
return Err("只能预览当前项目已登记的媒体资源".to_string());
}
cancellation.check()?;
load_local_project_media_preview_with_cancellation(root, &normalized_path, kind, cancellation)
}
#[tauri::command]
pub(crate) fn cancel_local_project_resource_preview_scope(
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
scope_id: String,
) -> Result<(), String> {
preview_manager.cancel_scope(&scope_id)
}
#[tauri::command]
pub(crate) fn write_local_project_file(
project_path: String,
relative_path: String,
content: String,
) -> Result<LocalProjectFileMutationResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "file.write")?;
let _lock = acquire_project_write_lock(root, "file.write")?;
advance_agent_runtime_project_revision_locked(root)?;
write_local_project_file_at(root, relative_path.trim(), &content)
}
#[tauri::command]
pub(crate) fn delete_local_project_file(
project_path: String,
relative_path: String,
) -> Result<LocalProjectFileMutationResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "file.delete")?;
let _lock = acquire_project_write_lock(root, "file.delete")?;
advance_agent_runtime_project_revision_locked(root)?;
delete_local_project_file_at(root, relative_path.trim())
}
#[tauri::command]
pub(crate) fn read_local_game_memory(
project_path: String,
scope: String,
) -> Result<LocalGameMemoryResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "memory.read")?;
read_local_game_memory_at(root, scope.trim())
}
#[tauri::command]
pub(crate) fn read_local_agent_memory(
project_path: String,
task_id: String,
) -> Result<LocalAgentMemoryResult, String> {
let root = Path::new(project_path.trim());
let task_id = normalize_game_creator_runtime_agent_id(task_id.trim())?;
enforce_project_permission_policy(root, "memory.read")?;
read_local_agent_memory_at(root, &task_id)
}
#[tauri::command]
pub(crate) fn write_local_agent_memory(
project_path: String,
task_id: String,
content: String,
) -> Result<LocalAgentMemoryResult, String> {
let root = Path::new(project_path.trim());
let task_id = normalize_game_creator_runtime_agent_id(task_id.trim())?;
enforce_project_permission_policy(root, "memory.write")?;
let _lock = acquire_project_write_lock(root, "memory.write")?;
advance_agent_runtime_project_revision_locked(root)?;
write_local_agent_memory_at(root, &task_id, &content)
}
#[tauri::command]
pub(crate) fn write_local_game_memory(
project_path: String,
scope: String,
content: String,
) -> Result<LocalGameMemoryResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "memory.write")?;
let _lock = acquire_project_write_lock(root, "memory.write")?;
advance_agent_runtime_project_revision_locked(root)?;
write_local_game_memory_at(root, scope.trim(), &content)
}
#[tauri::command]
pub(crate) fn delete_local_game_memory(
project_path: String,
scope: String,
) -> Result<LocalGameMemoryResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "memory.delete")?;
let _lock = acquire_project_write_lock(root, "memory.delete")?;
advance_agent_runtime_project_revision_locked(root)?;
delete_local_game_memory_at(root, scope.trim())
}
#[tauri::command]
pub(crate) fn list_game_creator_agent_sessions(
project_path: String,
agent_id: String,
) -> Result<AgentConversationSessionListResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
list_game_creator_agent_sessions_at(root, agent_id.trim())
}
#[tauri::command]
pub(crate) fn create_game_creator_agent_session(
project_path: String,
agent_id: String,
title: String,
) -> Result<AgentConversationSessionListResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
enforce_project_permission_policy(root, "conversation.write")?;
let _lock = acquire_project_write_lock(root, "conversation.write")?;
create_game_creator_agent_session_at(root, agent_id.trim(), title.trim())
}
#[tauri::command]
pub(crate) fn fork_game_creator_agent_session(
project_path: String,
agent_id: String,
source_session_id: String,
title: String,
) -> Result<AgentConversationSessionListResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
enforce_project_permission_policy(root, "conversation.write")?;
let _lock = acquire_project_write_lock(root, "conversation.write")?;
fork_game_creator_agent_session_at(
root,
agent_id.trim(),
source_session_id.trim(),
title.trim(),
)
}
#[tauri::command]
pub(crate) fn set_active_game_creator_agent_session(
project_path: String,
agent_id: String,
session_id: String,
) -> Result<AgentConversationSessionListResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
enforce_project_permission_policy(root, "conversation.write")?;
let _lock = acquire_project_write_lock(root, "conversation.write")?;
set_active_game_creator_agent_session_at(root, agent_id.trim(), session_id.trim())
}
#[tauri::command]
pub(crate) fn archive_game_creator_agent_session(
project_path: String,
agent_id: String,
session_id: String,
) -> Result<AgentConversationSessionListResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
enforce_project_permission_policy(root, "conversation.write")?;
let _lock = acquire_project_write_lock(root, "conversation.write")?;
archive_game_creator_agent_session_at(root, agent_id.trim(), session_id.trim())
}
#[tauri::command]
pub(crate) fn read_local_conversation(
project_path: String,
agent_id: Option<String>,
session_id: Option<String>,
) -> Result<LocalConversationResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
read_local_conversation_for_session_at(root, agent_id.as_deref(), session_id.as_deref())
}
#[tauri::command]
pub(crate) fn read_direct_project_conversation(
project_path: String,
) -> Result<LocalConversationResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
read_direct_project_chat_history_at(root)
}
#[tauri::command]
pub(crate) fn append_local_conversation_message(
project_path: String,
agent_id: Option<String>,
session_id: Option<String>,
message: LocalConversationMessage,
message_id: Option<String>,
) -> Result<LocalConversationResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.write")?;
let _lock = acquire_project_write_lock(root, "conversation.write")?;
match message_id.as_deref() {
Some(message_id) => append_local_conversation_message_for_session_idempotent_at(
root,
agent_id.as_deref(),
session_id.as_deref(),
message,
message_id,
),
None => append_local_conversation_message_for_session_at(
root,
agent_id.as_deref(),
session_id.as_deref(),
message,
),
}
}
#[tauri::command]
pub(crate) fn append_direct_project_conversation_message(
project_path: String,
message: LocalConversationMessage,
message_id: Option<String>,
) -> Result<LocalConversationResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.write")?;
let item =
direct_project_local_message_item(&message.role, &message.content, message_id.as_deref())?;
append_direct_project_history_item_at(root, &item)?;
read_direct_project_chat_history_at(root)
}
#[tauri::command]
pub(crate) fn build_local_project_index(
project_path: String,
) -> Result<LocalProjectIndexResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.index")?;
let _lock = acquire_project_write_lock(root, "project.index")?;
build_local_project_index_at(root)
}
#[tauri::command]
pub(crate) fn create_local_project_checkpoint(
project_path: String,
) -> Result<LocalProjectCheckpointResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.checkpoint")?;
let _lock = acquire_project_write_lock(root, "project.checkpoint")?;
create_local_project_checkpoint_at(root)
}
#[tauri::command]
pub(crate) fn export_local_project_package(
project_path: String,
) -> Result<LocalProjectExportPackageResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.export_package")?;
let _lock = acquire_project_write_lock(root, "project.export_package")?;
advance_agent_runtime_project_revision_locked(root)?;
export_local_project_package_at(root)
}
#[tauri::command]
pub(crate) fn list_local_project_export_packages(
project_path: String,
) -> Result<LocalProjectExportPackagesResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.export_list")?;
list_local_project_export_packages_at(root)
}
#[tauri::command]
pub(crate) fn diff_local_project_checkpoint(
project_path: String,
checkpoint_id: String,
) -> Result<LocalProjectDiffResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.diff")?;
diff_local_project_checkpoint_at(root, checkpoint_id.trim())
}
#[tauri::command]
pub(crate) fn restore_local_project_checkpoint(
project_path: String,
checkpoint_id: String,
) -> Result<LocalProjectRestoreResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.restore")?;
let _lock = acquire_project_write_lock(root, "project.restore")?;
advance_agent_runtime_project_revision_locked(root)?;
restore_local_project_checkpoint_at(root, checkpoint_id.trim())
}
#[tauri::command]
pub(crate) fn read_project_permission_policy(
project_path: String,
) -> Result<ProjectPermissionPolicyView, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.policy_read")?;
read_project_permission_policy_at(root)
}
#[tauri::command]
pub(crate) fn write_project_permission_policy(
project_path: String,
policy: ProjectPermissionPolicy,
) -> Result<ProjectPermissionPolicyView, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.policy_write")?;
let _lock = acquire_project_write_lock(root, "project.policy_write")?;
write_project_permission_policy_at(root, policy)
}