Compare commits
131 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb76aadb48 | |||
| c20e28bed0 | |||
| 8c74e5f97c | |||
| 9e59abde53 | |||
| 6a11032084 | |||
| 7d0f5c68fe | |||
| f49ff61ddc | |||
| 8f96b05f6e | |||
| 93ddb841cd | |||
| c9413fae0c | |||
| ecca7050b5 | |||
| 7fe1fd9b64 | |||
| 94fc4c6779 | |||
| 994209fc33 | |||
| fed17ea08c | |||
| 9ce31f905e | |||
| 746b96e71d | |||
| 6496601d12 | |||
| 0035d16efa | |||
| 56c6e0fecc | |||
| f09f5f9e9c | |||
| f2f8a9af72 | |||
| 422a71d321 | |||
| 00963c5f67 | |||
| 09cd47748c | |||
| 3d709ea1db | |||
| 9121e89798 | |||
| 9939a1bca0 | |||
| f247a5106a | |||
| f32445d700 | |||
| a74cd6c8b6 | |||
| bd36587e0a | |||
| 3508957eda | |||
| 3379e6009e | |||
| d851aa83ff | |||
| 2c07396ef7 | |||
| 4d0cd425e1 | |||
| 30e93c6c43 | |||
| 26f1a7b19c | |||
| dbbca47078 | |||
| b2477c8ca9 | |||
| b0d7121265 | |||
| 89678173f5 | |||
| b96f5e1476 | |||
| f8aa87c71c | |||
| 48700083a3 | |||
| 1c9b92e791 | |||
| 33ed85ba54 | |||
| 824934df41 | |||
| 912254afa0 | |||
| 53afc85319 | |||
| e1044546ad | |||
| 16748821e3 | |||
| 69741e5e13 | |||
| c1ffb2386b | |||
| 691d0a8ce6 | |||
| d526439508 | |||
| a571c6e225 | |||
| 3f5d3b43af | |||
| c936268337 | |||
| 4a5b2ad524 | |||
| 4582231b78 | |||
| a23728e6c2 | |||
| f84d0ea4d0 | |||
| 91396f0615 | |||
| 4c935ba219 | |||
| 4fb0de2c94 | |||
| 05d4d6d1d3 | |||
| 3ea94b27d4 | |||
| 9fd5b99972 | |||
| 1abbbadb07 | |||
| b460fea02c | |||
| 91f335468a | |||
| ed50ad122f | |||
| cca4881e13 | |||
| 0914703032 | |||
| a655ee1b1e | |||
| 9a3313fe56 | |||
| 04ab7ee0da | |||
| bafae0f127 | |||
| 985db358a7 | |||
| 2c5ad3903e | |||
| 85f0f6a57b | |||
| 79cf932cba | |||
| 6d3d24df5e | |||
| 8ed7e201d1 | |||
| 30580814d7 | |||
| 10e28b038c | |||
| aba896bf65 | |||
| bf03a18676 | |||
| 57a5b642fc | |||
| f7374b2101 | |||
| a0f3cc21a6 | |||
| c82a684004 | |||
| 08e20dc7c5 | |||
| e726dc41b8 | |||
| 5f36ab8081 | |||
| cc569e7e1e | |||
| 664c8fbaf0 | |||
| a42452106c | |||
| b3989b0d5c | |||
| 46f27e2bde | |||
| b7a659cdfe | |||
| 36747b3ece | |||
| 8a47cdfc1b | |||
| 5550a7b28c | |||
| 7bd67f2b3a | |||
| ea71540413 | |||
| 4e0ce1902b | |||
| e2a50088c9 | |||
| 6de536ecbd | |||
| 1347b4a743 | |||
| d5411eae62 | |||
| 0a5080dbd9 | |||
| c5bbb864f5 | |||
| ccb3a3ab6a | |||
| 879e732b04 | |||
| cac3e19632 | |||
| cd17ae8123 | |||
| 41c9ee7db4 | |||
| ba718e3d4b | |||
| 65ffd4cc69 | |||
| 5bcaca7233 | |||
| 820fbfd988 | |||
| 069248b296 | |||
| 9c85a9501e | |||
| 816844065b | |||
| 8323b13fd3 | |||
| f883d4bf2e | |||
| 019b1761f4 | |||
| f339b383f5 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "main",
|
||||
"description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件。",
|
||||
"description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件;允许弹出原生打开/保存对话框用于素材上传与导出。",
|
||||
"windows": ["client"],
|
||||
"permissions": [
|
||||
"clipboard-manager:allow-read-image",
|
||||
@@ -21,6 +21,7 @@
|
||||
]
|
||||
},
|
||||
"opener:default",
|
||||
"dialog:allow-open"
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
你是游戏创作需求的润色助手。把用户给创作 Agent 的一段需求改写成更清晰、可执行的中文需求。要求:保留用户的原始意图、玩法、美术方向、数值与限制条件,不得新增或删除需求点,不得替用户做决定,不得写成方案书或任务清单。只输出润色后的需求正文,不要解释、不要引言、不要 Markdown 标记、不要引号包裹、不要重复用户原文;无法润色时原样输出用户输入。
|
||||
@@ -16,6 +16,7 @@ mod direct_codex_attachments;
|
||||
mod direct_codex_audit;
|
||||
mod direct_project_history;
|
||||
mod direct_project_turn_history;
|
||||
mod direct_codex_references;
|
||||
mod direct_runtime;
|
||||
mod direct_tool_bridge;
|
||||
mod direct_tools_mcp;
|
||||
@@ -42,6 +43,7 @@ pub(crate) use direct_codex_attachments::*;
|
||||
pub(crate) use direct_codex_audit::*;
|
||||
pub(crate) use direct_project_history::*;
|
||||
pub(crate) use direct_project_turn_history::*;
|
||||
pub(crate) use direct_codex_references::*;
|
||||
pub(crate) use direct_runtime::*;
|
||||
pub(crate) use direct_tool_bridge::*;
|
||||
pub(crate) use direct_tools_mcp::*;
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
|
||||
const MAX_DIRECT_CODEX_REFERENCE_ID_CHARS: usize = 200;
|
||||
const MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS: usize = 160;
|
||||
const MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS: usize = 32;
|
||||
const MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS: usize = 80;
|
||||
const MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS: usize = 240;
|
||||
|
||||
const DIRECT_CODEX_REFERENCE_HEADER: &str =
|
||||
"[本轮用户引用素材:以下均为当前项目已确认的安全引用。请使用稳定资源 ID 和项目相对路径读取,不要读取或输出其它路径。]";
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "type")]
|
||||
pub(crate) enum DirectCodexTurnReference {
|
||||
#[serde(rename = "resource")]
|
||||
Resource(DirectCodexResourceReference),
|
||||
#[serde(rename = "runtime-region")]
|
||||
RuntimeRegion(DirectCodexRuntimeRegionReference),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DirectCodexResourceReference {
|
||||
pub(crate) resource_id: String,
|
||||
#[serde(default)]
|
||||
pub(crate) label: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) source: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DirectCodexRuntimeRegionReference {
|
||||
#[serde(default)]
|
||||
pub(crate) label: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) run_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) version_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) element_tag: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) element_role: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) width: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) height: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) resource_ids: Vec<String>,
|
||||
}
|
||||
|
||||
fn sanitize_reference_text(value: &str, max_chars: usize) -> Option<String> {
|
||||
let sanitized = value
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|character| !character.is_control())
|
||||
.take(max_chars)
|
||||
.collect::<String>();
|
||||
if sanitized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_reference_label(value: Option<&str>) -> Option<String> {
|
||||
value.and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS))
|
||||
}
|
||||
|
||||
fn sanitize_reference_source(value: Option<&str>) -> Option<String> {
|
||||
let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS)?;
|
||||
if value
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'))
|
||||
{
|
||||
Some(value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_reference_element(value: Option<&str>) -> Option<String> {
|
||||
let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS)?;
|
||||
if value
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'))
|
||||
{
|
||||
Some(value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_reference_dimension(value: Option<f64>) -> Option<u32> {
|
||||
value
|
||||
.filter(|value| value.is_finite() && *value > 0.0)
|
||||
.map(|value| value.round().clamp(1.0, 100_000.0) as u32)
|
||||
}
|
||||
|
||||
fn asset_display_label(asset: &GameCreationAppAssetManifestEntry) -> String {
|
||||
let basename = asset
|
||||
.local_path
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or(&asset.id);
|
||||
let without_extension = basename
|
||||
.rsplit_once('.')
|
||||
.map(|(name, _)| name)
|
||||
.unwrap_or(basename)
|
||||
.trim();
|
||||
if without_extension.is_empty() {
|
||||
asset.id.clone()
|
||||
} else {
|
||||
without_extension
|
||||
.chars()
|
||||
.filter(|character| !character.is_control())
|
||||
.take(MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_resource_reference_id(value: &str) -> Result<String, String> {
|
||||
let resource_id = value.trim();
|
||||
if resource_id.is_empty()
|
||||
|| resource_id.chars().count() > MAX_DIRECT_CODEX_REFERENCE_ID_CHARS
|
||||
|| resource_id.chars().any(char::is_control)
|
||||
{
|
||||
return Err("引用的素材 ID 无效,请移除后重新选择".to_string());
|
||||
}
|
||||
Ok(resource_id.to_string())
|
||||
}
|
||||
|
||||
fn render_resource_reference_line(
|
||||
manifest: &GameCreationAppManifest,
|
||||
reference: &DirectCodexResourceReference,
|
||||
) -> Result<String, String> {
|
||||
let resource_id = validate_resource_reference_id(&reference.resource_id)?;
|
||||
let asset = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.id == resource_id)
|
||||
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
|
||||
let local_path = sanitize_attachment_local_path(&asset.local_path)
|
||||
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
|
||||
let label = sanitize_reference_label(reference.label.as_deref())
|
||||
.unwrap_or_else(|| asset_display_label(asset));
|
||||
let source = sanitize_reference_source(reference.source.as_deref())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
Ok(format!(
|
||||
"- 素材 ID:{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}",
|
||||
asset.kind, asset.media_type
|
||||
))
|
||||
}
|
||||
|
||||
fn render_runtime_region_reference_line(
|
||||
manifest: &GameCreationAppManifest,
|
||||
reference: &DirectCodexRuntimeRegionReference,
|
||||
) -> Result<String, String> {
|
||||
let label = sanitize_reference_label(reference.label.as_deref())
|
||||
.unwrap_or_else(|| "运行画面区域".to_string());
|
||||
let run_id = sanitize_reference_source(reference.run_id.as_deref());
|
||||
let version_id = sanitize_reference_source(reference.version_id.as_deref());
|
||||
let element_tag = sanitize_reference_element(reference.element_tag.as_deref());
|
||||
let element_role = sanitize_reference_element(reference.element_role.as_deref());
|
||||
let text = reference
|
||||
.text
|
||||
.as_deref()
|
||||
.and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS));
|
||||
let width = sanitize_reference_dimension(reference.width);
|
||||
let height = sanitize_reference_dimension(reference.height);
|
||||
|
||||
let mut related_resource_ids = Vec::new();
|
||||
for resource_id in &reference.resource_ids {
|
||||
let resource_id = validate_resource_reference_id(resource_id)?;
|
||||
if !manifest.assets.iter().any(|asset| asset.id == resource_id) {
|
||||
return Err("运行画面引用的素材已变化,请重新点选".to_string());
|
||||
}
|
||||
related_resource_ids.push(resource_id);
|
||||
}
|
||||
|
||||
let mut parts = vec![format!("名称:{label}")];
|
||||
if let Some(run_id) = run_id {
|
||||
parts.push(format!("运行标识:{run_id}"));
|
||||
}
|
||||
if let Some(version_id) = version_id {
|
||||
parts.push(format!("版本标识:{version_id}"));
|
||||
}
|
||||
if let Some(element_tag) = element_tag {
|
||||
parts.push(format!("元素:{element_tag}"));
|
||||
}
|
||||
if let Some(element_role) = element_role {
|
||||
parts.push(format!("角色:{element_role}"));
|
||||
}
|
||||
if let Some(text) = text {
|
||||
parts.push(format!("文本摘要:{text}"));
|
||||
}
|
||||
if let (Some(width), Some(height)) = (width, height) {
|
||||
parts.push(format!("尺寸:{width}x{height}"));
|
||||
}
|
||||
if !related_resource_ids.is_empty() {
|
||||
parts.push(format!("关联素材 ID:{}", related_resource_ids.join(",")));
|
||||
}
|
||||
Ok(format!("- 运行画面区域:{}", parts.join(";")))
|
||||
}
|
||||
|
||||
pub(crate) fn render_direct_codex_references_section(
|
||||
root: &Path,
|
||||
references: &[DirectCodexTurnReference],
|
||||
) -> Result<Option<String>, String> {
|
||||
if references.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if references.len() > MAX_DIRECT_CODEX_REFERENCES {
|
||||
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
|
||||
}
|
||||
let manifest = read_manifest_for_project(root)?;
|
||||
let mut lines = Vec::with_capacity(references.len());
|
||||
for reference in references {
|
||||
lines.push(match reference {
|
||||
DirectCodexTurnReference::Resource(reference) => {
|
||||
render_resource_reference_line(&manifest, reference)?
|
||||
}
|
||||
DirectCodexTurnReference::RuntimeRegion(reference) => {
|
||||
render_runtime_region_reference_line(&manifest, reference)?
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(Some(
|
||||
std::iter::once(DIRECT_CODEX_REFERENCE_HEADER.to_string())
|
||||
.chain(lines)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture_project() -> tempfile::TempDir {
|
||||
let directory = tempfile::tempdir().expect("temp project");
|
||||
let root = directory.path();
|
||||
std::fs::create_dir_all(root.join(".agent")).expect("create agent dir");
|
||||
let mut manifest = new_game_creation_app_manifest("project-1", "测试项目");
|
||||
manifest.assets.push(GameCreationAppAssetManifestEntry {
|
||||
id: "asset-hero".to_string(),
|
||||
kind: "character".to_string(),
|
||||
media_type: "image/png".to_string(),
|
||||
local_path: "assets/hero.png".to_string(),
|
||||
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(),
|
||||
},
|
||||
image_sequence_frames: None,
|
||||
image_sequence_duration_ms: None,
|
||||
category: game_creation_app_asset_category_for_kind("character"),
|
||||
tags: Vec::new(),
|
||||
});
|
||||
write_manifest(&root.join(".agent/manifest.json"), &manifest).expect("write manifest");
|
||||
directory
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_reference_uses_manifest_identity_and_never_accepts_client_paths() {
|
||||
let project = fixture_project();
|
||||
let reference: DirectCodexTurnReference = serde_json::from_str(
|
||||
r#"{"type":"resource","resourceId":"asset-hero","label":"主角","source":"asset-picker","localPath":"C:\\secret.png"}"#,
|
||||
)
|
||||
.expect("reference json");
|
||||
let section = render_direct_codex_references_section(
|
||||
project.path(),
|
||||
std::slice::from_ref(&reference),
|
||||
)
|
||||
.expect("render")
|
||||
.expect("section");
|
||||
assert!(section.contains("素材 ID:asset-hero"));
|
||||
assert!(section.contains("名称:主角"));
|
||||
assert!(section.contains("项目路径:assets/hero.png"));
|
||||
assert!(!section.contains("C:\\secret.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_resource_fails_closed() {
|
||||
let project = fixture_project();
|
||||
let reference: DirectCodexTurnReference = serde_json::from_str(
|
||||
r#"{"type":"resource","resourceId":"asset-missing","label":"不存在"}"#,
|
||||
)
|
||||
.expect("reference json");
|
||||
let error = render_direct_codex_references_section(
|
||||
project.path(),
|
||||
std::slice::from_ref(&reference),
|
||||
)
|
||||
.expect_err("missing resource");
|
||||
assert_eq!(error, "引用的素材已不存在,请移除后重新选择");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_region_keeps_only_safe_summary_and_existing_resource_ids() {
|
||||
let project = fixture_project();
|
||||
let reference: DirectCodexTurnReference = serde_json::from_str(
|
||||
r#"{"type":"runtime-region","label":"开始按钮","runId":"run-1","elementTag":"button","elementRole":"button","text":"开始游戏","width":120.4,"height":40.2,"resourceIds":["asset-hero"],"html":"<button onclick=secret>"}"#,
|
||||
)
|
||||
.expect("reference json");
|
||||
let section = render_direct_codex_references_section(
|
||||
project.path(),
|
||||
std::slice::from_ref(&reference),
|
||||
)
|
||||
.expect("render")
|
||||
.expect("section");
|
||||
assert!(section.contains("运行画面区域:名称:开始按钮"));
|
||||
assert!(section.contains("文本摘要:开始游戏"));
|
||||
assert!(section.contains("尺寸:120x40"));
|
||||
assert!(section.contains("关联素材 ID:asset-hero"));
|
||||
assert!(!section.contains("onclick"));
|
||||
assert!(!section.contains("secret"));
|
||||
}
|
||||
}
|
||||
@@ -1776,7 +1776,7 @@ fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &st
|
||||
if normalized.contains("permission-denied") || normalized.contains("http 403") {
|
||||
return "当前陶泥儿账号可能没有访问该资源的权限,请检查账号后重试";
|
||||
}
|
||||
if error.contains(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) {
|
||||
if error.contains("项目正在被其他写操作占用") {
|
||||
return "当前项目仍有写入正在结束,请稍后再次发送该需求";
|
||||
}
|
||||
if error.contains("身份不唯一")
|
||||
@@ -4414,6 +4414,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
creation_type: Option<String>,
|
||||
client_turn_id: Option<String>,
|
||||
attachments: Option<Vec<DirectCodexTurnAttachment>>,
|
||||
references: Option<Vec<DirectCodexTurnReference>>,
|
||||
) -> Result<String, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
|
||||
@@ -4428,16 +4429,33 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
&prompt,
|
||||
attachments.as_deref().unwrap_or_default(),
|
||||
);
|
||||
let user_prompt = match render_direct_codex_user_prompt(
|
||||
&prompt,
|
||||
attachments.as_deref().unwrap_or_default(),
|
||||
) {
|
||||
let attachments = attachments.unwrap_or_default();
|
||||
let references = references.unwrap_or_default();
|
||||
let mut user_prompt = match render_direct_codex_user_prompt(&prompt, &attachments) {
|
||||
Ok(prompt) => prompt,
|
||||
Err(_) if !references.is_empty() && prompt.trim().is_empty() => String::new(),
|
||||
Err(error) => {
|
||||
audit.finish(false);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if let Some(reference_section) = match render_direct_codex_references_section(root, &references)
|
||||
{
|
||||
Ok(section) => section,
|
||||
Err(error) => {
|
||||
audit.finish(false);
|
||||
return Err(error);
|
||||
}
|
||||
} {
|
||||
if !user_prompt.trim().is_empty() {
|
||||
user_prompt.push_str("\n\n");
|
||||
}
|
||||
user_prompt.push_str(&reference_section);
|
||||
}
|
||||
if user_prompt.trim().is_empty() {
|
||||
audit.finish(false);
|
||||
return Err("聊天内容不能为空".to_string());
|
||||
}
|
||||
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
||||
root,
|
||||
&user_prompt,
|
||||
|
||||
@@ -1161,6 +1161,8 @@ fn bridge_registered_resource(
|
||||
"localPath": asset.local_path,
|
||||
"kind": asset.kind,
|
||||
"mediaType": asset.media_type,
|
||||
"category": asset.category,
|
||||
"tags": asset.tags,
|
||||
"canvasProjectId": asset.source.canvas_project_id,
|
||||
"resourceId": asset.source.resource_id,
|
||||
"assetObjectId": asset.source.asset_object_id,
|
||||
@@ -1473,14 +1475,7 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value {
|
||||
return Err("工具参数 content 不能包含 NUL".to_string());
|
||||
}
|
||||
reject_command_output_wrapper(content)?;
|
||||
// Direct 写入原本用零等待取锁:任何重叠都在 24-42ms 内直接被判成"别人正在写",
|
||||
// 而 `file.write / file.patch / file.delete` 等写入口用的是约 10 秒有界等待。
|
||||
// 这是用户直接触发、失败即整轮无法落盘的项目写入通道,必须和其它写入口同语义:
|
||||
// 短暂重叠排队等成功,只有预算耗尽才报出带持锁方身份的错误。
|
||||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"direct-codex.file.write",
|
||||
)?;
|
||||
let _lock = acquire_project_write_lock(root, "direct-codex.file.write")?;
|
||||
let written = write_local_project_file_at(root, &path, content)?;
|
||||
let revision = advance_agent_runtime_project_revision_locked(root)?;
|
||||
Ok::<_, String>(json!({
|
||||
@@ -1500,27 +1495,6 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// 项目写锁的有界等待是同步轮询(2_000 × 5ms,最多约 10 秒)。handler 是 async,
|
||||
/// 直接在 handler 里走完整条写路径会占住一个 tokio worker:争用窗口内同一轮并行写多个
|
||||
/// 文件时会有多个 worker 被占,而这条 bridge 与只读端点、UI 命令共享同一个 runtime,
|
||||
/// 正是 Issue #318 现场"只读工具全部正常"这条诊断特征会被破坏的情形。
|
||||
/// 因此整条写路径挪进阻塞线程池,等待语义与错误文案都不变。
|
||||
async fn bridge_write_file_in_blocking_pool(root: PathBuf, arguments: Value) -> Value {
|
||||
let task_root = root.clone();
|
||||
match tokio::task::spawn_blocking(move || bridge_write_file(&task_root, &arguments)).await {
|
||||
Ok(result) => result,
|
||||
Err(error) => bridge_tool_result(
|
||||
redact_agent_runtime_error(
|
||||
&root,
|
||||
&format!("agc_write_file 阻塞任务未返回:{error}"),
|
||||
480,
|
||||
),
|
||||
Vec::new(),
|
||||
true,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn bridge_safe_account_asset_projection(asset: &Value) -> Option<Value> {
|
||||
let asset_id = asset.get("assetId").and_then(Value::as_str)?;
|
||||
if asset_id.trim().is_empty() {
|
||||
@@ -2335,9 +2309,7 @@ async fn handle_direct_tool_bridge(
|
||||
bridge_list_registered_assets(&state.root, &request.arguments)
|
||||
}
|
||||
"agc_list_project_files" => bridge_list_project_files(&state.root, &request.arguments),
|
||||
"agc_write_file" => {
|
||||
bridge_write_file_in_blocking_pool(state.root.clone(), request.arguments).await
|
||||
}
|
||||
"agc_write_file" => bridge_write_file(&state.root, &request.arguments),
|
||||
"agc_list_account_assets" => bridge_list_account_assets(&state, &request.arguments).await,
|
||||
"agc_import_account_assets" => {
|
||||
bridge_import_account_assets(&state, &request.arguments).await
|
||||
@@ -2742,185 +2714,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #318 第 1 条验收:同一轮里并行的多个文件写必须排队成功,
|
||||
/// 而不是互相报"项目正在被其他写操作占用"。
|
||||
#[test]
|
||||
fn bridge_write_file_serializes_parallel_writes_in_one_round() {
|
||||
let temporary = tempfile::tempdir().expect("create parallel direct write root");
|
||||
init_local_game_project_at(temporary.path(), "direct-parallel", "Direct 并行写入测试")
|
||||
.expect("initialize parallel direct write root");
|
||||
let root = temporary.path().to_path_buf();
|
||||
let paths = (0..4)
|
||||
.map(|index| format!("game/parallel-{index}.js"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let results = std::thread::scope(|scope| {
|
||||
let handles = paths
|
||||
.iter()
|
||||
.map(|path| {
|
||||
let root = root.clone();
|
||||
let path = path.clone();
|
||||
scope.spawn(move || {
|
||||
let result = bridge_write_file(
|
||||
&root,
|
||||
&json!({ "path": path, "content": format!("// {path}\n") }),
|
||||
);
|
||||
(path, result)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
handles
|
||||
.into_iter()
|
||||
.map(|handle| handle.join().expect("parallel direct write must not panic"))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
for (path, result) in &results {
|
||||
assert_eq!(
|
||||
result.get("isError").and_then(Value::as_bool),
|
||||
Some(false),
|
||||
"parallel direct write of {path} must succeed: {result}"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join(path)).expect("read parallel direct write"),
|
||||
format!("// {path}\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #318 第 1 条验收:App 自己另一条写通道正在写该项目时,
|
||||
/// Direct 写入必须等待后成功,而不是在 24-42ms 内被判成"别人正在写"。
|
||||
#[test]
|
||||
fn bridge_write_file_waits_for_a_short_same_process_project_writer() {
|
||||
let temporary = tempfile::tempdir().expect("create contended direct write root");
|
||||
init_local_game_project_at(temporary.path(), "direct-contended", "Direct 写入等待测试")
|
||||
.expect("initialize contended direct write root");
|
||||
let root = temporary.path().to_path_buf();
|
||||
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
|
||||
let holder_barrier = std::sync::Arc::clone(&barrier);
|
||||
let holder_root = root.clone();
|
||||
let holder = std::thread::spawn(move || {
|
||||
let lock = acquire_project_write_lock(&holder_root, "concurrent-writer")
|
||||
.expect("acquire a short-lived project writer");
|
||||
holder_barrier.wait();
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
drop(lock);
|
||||
});
|
||||
barrier.wait();
|
||||
|
||||
let result = bridge_write_file(
|
||||
&root,
|
||||
&json!({ "path": "game/waited.js", "content": "// waited\n" }),
|
||||
);
|
||||
|
||||
holder.join().expect("join the short-lived project writer");
|
||||
assert_eq!(
|
||||
result.get("isError").and_then(Value::as_bool),
|
||||
Some(false),
|
||||
"the direct write must wait out a short same-process writer: {result}"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("game/waited.js")).expect("read waited direct write"),
|
||||
"// waited\n"
|
||||
);
|
||||
}
|
||||
|
||||
/// 有界等待是同步轮询(最多约 10 秒),而 handler 是 async:等待必须挪到阻塞线程池,
|
||||
/// 否则会占住 runtime worker。本用例用默认的 current_thread runtime——handler 一旦同步
|
||||
/// 阻塞,同一 runtime 上的心跳任务就完全停摆,因此在写入等待期间检查心跳即可区分。
|
||||
#[tokio::test]
|
||||
async fn bridge_write_file_waits_on_the_blocking_pool_instead_of_a_runtime_worker() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
let temporary = tempfile::tempdir().expect("create blocking pool write root");
|
||||
init_local_game_project_at(temporary.path(), "direct-pool", "Direct 阻塞池测试")
|
||||
.expect("initialize blocking pool write root");
|
||||
let state = direct_tool_bridge_state(temporary.path().to_path_buf());
|
||||
|
||||
// 另一条写通道由 OS 线程持有项目写锁,不受本 runtime 影响。
|
||||
let holder_root = temporary.path().to_path_buf();
|
||||
let lock = acquire_project_write_lock(&holder_root, "concurrent-writer")
|
||||
.expect("acquire the concurrent project writer");
|
||||
let holder = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
drop(lock);
|
||||
});
|
||||
|
||||
// 心跳任务:只有 handler 让出 worker,它才可能在写入等待期间推进。
|
||||
let heartbeat = Arc::new(AtomicBool::new(false));
|
||||
let heartbeat_writer = Arc::clone(&heartbeat);
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
heartbeat_writer.store(true, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
let response = handle_direct_tool_bridge(
|
||||
axum::extract::State(state),
|
||||
axum::Json(DirectToolBridgeRequest {
|
||||
tool: "agc_write_file".to_string(),
|
||||
arguments: json!({ "path": "game/blocking-pool.js", "content": "// pooled\n" }),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.0;
|
||||
|
||||
holder.join().expect("join the concurrent project writer");
|
||||
assert!(
|
||||
heartbeat.load(Ordering::SeqCst),
|
||||
"写入等待期间同一 runtime 的心跳任务停摆了:等待必须走阻塞线程池,不能占住 worker"
|
||||
);
|
||||
assert_eq!(
|
||||
response.get("isError").and_then(Value::as_bool),
|
||||
Some(false),
|
||||
"the pooled direct write must still wait out the holder: {response}"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(temporary.path().join("game/blocking-pool.js"))
|
||||
.expect("read pooled direct write"),
|
||||
"// pooled\n"
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #318 第 3 条验收:权限拒绝不得被投影成"被其他写操作占用"。
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn bridge_write_file_does_not_project_permission_denial_as_contention() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let temporary = tempfile::tempdir().expect("create acl direct write root");
|
||||
init_local_game_project_at(temporary.path(), "direct-acl", "Direct ACL 测试")
|
||||
.expect("initialize acl direct write root");
|
||||
let agent_directory = temporary.path().join(".agent");
|
||||
let original = fs::metadata(&agent_directory)
|
||||
.expect("read control directory metadata")
|
||||
.permissions();
|
||||
fs::set_permissions(&agent_directory, fs::Permissions::from_mode(0o500))
|
||||
.expect("drop write permission on the control directory");
|
||||
|
||||
let result = bridge_write_file(
|
||||
temporary.path(),
|
||||
&json!({ "path": "game/acl-denied.js", "content": "// denied\n" }),
|
||||
);
|
||||
fs::set_permissions(&agent_directory, original)
|
||||
.expect("restore control directory permission");
|
||||
|
||||
if result.get("isError").and_then(Value::as_bool) != Some(true) {
|
||||
// 以 root 运行(或文件系统忽略权限位)时 0o500 不构成拒绝,本用例不成立。
|
||||
return;
|
||||
}
|
||||
let text = result
|
||||
.pointer("/content/0/text")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
!text.contains("项目正在被其他写操作占用"),
|
||||
"a permission denial must not be projected as lock contention: {text}"
|
||||
);
|
||||
assert!(!temporary.path().join("game/acl-denied.js").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_request_uuid_is_stable_v4_and_domain_separated() {
|
||||
let operation = direct_resource_request_uuid("turn-1", "operation", "abc");
|
||||
@@ -2990,6 +2783,8 @@ mod tests {
|
||||
local_path: "assets/art-spritesheet-slices/player.png".to_string(),
|
||||
image_sequence_frames: None,
|
||||
image_sequence_duration_ms: None,
|
||||
category: game_creation_app_asset_category_for_kind("art-spritesheet-slice"),
|
||||
tags: Vec::new(),
|
||||
source: GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Canvas,
|
||||
canvas_project_id: Some("canvas-1".to_string()),
|
||||
@@ -3053,6 +2848,8 @@ mod tests {
|
||||
},
|
||||
]),
|
||||
image_sequence_duration_ms: Some(4_000),
|
||||
category: game_creation_app_asset_category_for_kind("character-animation"),
|
||||
tags: Vec::new(),
|
||||
source: GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Canvas,
|
||||
canvas_project_id: Some("canvas-1".to_string()),
|
||||
@@ -3072,6 +2869,8 @@ mod tests {
|
||||
projection["imageSequenceFrames"][0]["objectKey"],
|
||||
"animations/frame-01.png"
|
||||
);
|
||||
assert_eq!(projection["category"], "character");
|
||||
assert_eq!(projection["tags"], json!([]));
|
||||
let serialized = projection.to_string();
|
||||
assert!(!serialized.contains("signed.invalid"));
|
||||
assert!(!serialized.contains("private prompt"));
|
||||
@@ -3079,6 +2878,42 @@ mod tests {
|
||||
assert!(!serialized.contains("private.invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_registered_resource_projects_manifest_classification_verbatim() {
|
||||
let asset = GameCreationAppAssetManifestEntry {
|
||||
id: "spec-1".to_string(),
|
||||
kind: "spec".to_string(),
|
||||
media_type: "application/json".to_string(),
|
||||
local_path: "assets/specs/hero.json".to_string(),
|
||||
image_sequence_frames: None,
|
||||
image_sequence_duration_ms: None,
|
||||
category:
|
||||
shared_contracts::game_creation_app::GameCreationAppAssetCategory::UiInteraction,
|
||||
tags: vec!["主角".to_string(), "第一版".to_string()],
|
||||
source: GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Generated,
|
||||
canvas_project_id: None,
|
||||
resource_id: None,
|
||||
asset_object_id: None,
|
||||
task_id: None,
|
||||
prompt: Some("private prompt".to_string()),
|
||||
model: Some("private model".to_string()),
|
||||
generation_route: None,
|
||||
generation_kind: None,
|
||||
reference_resource_ids: Vec::new(),
|
||||
},
|
||||
};
|
||||
|
||||
let projection = bridge_registered_resource(&asset, false);
|
||||
assert_eq!(projection["kind"], "spec");
|
||||
assert_eq!(projection["category"], "ui-interaction");
|
||||
assert_eq!(projection["tags"], json!(["主角", "第一版"]));
|
||||
assert_eq!(projection["imageSequenceFrames"], Value::Null);
|
||||
let serialized = projection.to_string();
|
||||
assert!(!serialized.contains("private prompt"));
|
||||
assert!(!serialized.contains("private model"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_success_warnings_are_redacted_before_serialization() {
|
||||
let root = tempfile::tempdir().expect("warning redaction root");
|
||||
|
||||
@@ -256,7 +256,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value {
|
||||
}),
|
||||
json!({
|
||||
"name": "agc_list_registered_assets",
|
||||
"description": "查询当前项目由客户端权威 manifest 登记的资源与未完成资源 operation。结果有界且只包含项目相对路径、稳定资源身份、序列帧身份和恢复状态,不返回 prompt、模型、签名 URL、宿主路径或凭据。",
|
||||
"description": "查询当前项目由客户端权威 manifest 登记的资源与未完成资源 operation。结果有界且只包含项目相对路径、稳定资源身份、功能分类与自定义标签、序列帧身份和恢复状态,不返回 prompt、模型、签名 URL、宿主路径或凭据。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -6786,6 +6786,8 @@ fn register_platform_art_slice_manifest_entries_at(
|
||||
local_path: registration.local_path.clone(),
|
||||
image_sequence_frames: None,
|
||||
image_sequence_duration_ms: None,
|
||||
category: game_creation_app_asset_category_for_kind("art-spritesheet-slice"),
|
||||
tags: Vec::new(),
|
||||
source,
|
||||
});
|
||||
ids.push(id);
|
||||
|
||||
@@ -1822,45 +1822,27 @@ const AGENT_RUNTIME_PROJECT_WRITE_LOCK_SHORT_WAIT_ATTEMPTS: usize = 200;
|
||||
/// Take the project write lock, riding out transient contention for at most
|
||||
/// `max_attempts` polls.
|
||||
///
|
||||
/// 能不能等由 `ProjectWriteLockFailure` 的**类型**决定,不解析错误文案:只有可重试的
|
||||
/// 取锁失败才在这里等,权限拒绝和坏路径立刻返回。判据曾经是
|
||||
/// `项目正在被其他写操作占用:` 这个前缀,那等于把"要不要等"绑在中文文案上——
|
||||
/// 改一次文案就悄悄改掉一次重试语义。Callers pick the budget from what a lost race
|
||||
/// costs them: a one-shot user intent waits out the full window, a poll that will run
|
||||
/// again shortly waits far less.
|
||||
/// `项目正在被其他写操作占用:` is the one lock error that means
|
||||
/// "nothing is broken, the current holder is mid-write" — every other variant
|
||||
/// (a torn lock file, a denied path) is returned immediately. Callers pick the
|
||||
/// budget from what a lost race costs them: a one-shot user intent waits out the
|
||||
/// full window, a poll that will run again shortly waits far less.
|
||||
fn acquire_game_creator_agent_runtime_project_write_lock_within(
|
||||
root: &Path,
|
||||
command_id: &str,
|
||||
max_attempts: usize,
|
||||
) -> Result<ProjectWriteLock, String> {
|
||||
let max_attempts = max_attempts.max(1);
|
||||
let started_at = std::time::Instant::now();
|
||||
for attempt in 0..max_attempts {
|
||||
let failure = match acquire_project_write_lock_failure(root, command_id) {
|
||||
Ok(lock) => return Ok(lock),
|
||||
Err(failure) if failure.is_retryable() => failure,
|
||||
Err(failure) => return Err(failure.message()),
|
||||
};
|
||||
if attempt + 1 == max_attempts {
|
||||
// 等待预算耗尽才记一条:争用本身可能重试上千次,逐次记账会淹掉日志。
|
||||
// 这条记录回答的正是 Issue #318 现场缺的问题——"谁在持锁、是不是自己人",
|
||||
// 以及等满预算之后这到底是争用还是权限拒绝(`projection=`)。
|
||||
// 单次试探(max_attempts == 1,例如 hydrate 的 try_acquire_*)根本没有等待:
|
||||
// 既不写 `wait_exhausted`(waitedMs≈0 会让"耗尽"这个词失去意义,而 hydrate
|
||||
// 每次状态变化都会撞一次锁,会把它变成噪声),也不做终态改判。
|
||||
let waited = max_attempts > 1;
|
||||
let (projection, message) = failure.exhausted_projection(waited);
|
||||
if waited {
|
||||
app_log!(
|
||||
"project.write_lock.wait_exhausted commandId={command_id} attempts={} waitedMs={} projection={projection} holder={}",
|
||||
attempt + 1,
|
||||
started_at.elapsed().as_millis(),
|
||||
crate::project::project_write_lock_contention_diagnostic(root)
|
||||
);
|
||||
match acquire_project_write_lock(root, command_id) {
|
||||
Err(error)
|
||||
if error.starts_with("项目正在被其他写操作占用:")
|
||||
&& attempt + 1 < max_attempts =>
|
||||
{
|
||||
std::thread::sleep(AGENT_RUNTIME_PROJECT_WRITE_LOCK_RETRY_INTERVAL);
|
||||
}
|
||||
return Err(message);
|
||||
result => return result,
|
||||
}
|
||||
std::thread::sleep(AGENT_RUNTIME_PROJECT_WRITE_LOCK_RETRY_INTERVAL);
|
||||
}
|
||||
unreachable!("project write lock retry loop always returns")
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ enum AutonomousManifestParentWakeReconciliationOutcome {
|
||||
|
||||
pub(crate) fn autonomous_manifest_parent_wake_error_is_transient(error: &str) -> bool {
|
||||
let normalized = error.to_ascii_lowercase();
|
||||
error.starts_with(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX)
|
||||
error.starts_with("项目正在被其他写操作占用:")
|
||||
|| error.contains("另一个程序正在使用此文件")
|
||||
|| normalized.contains("sharing violation")
|
||||
|| normalized.contains("lock violation")
|
||||
|
||||
+1
-1
@@ -1495,7 +1495,7 @@ pub(crate) fn hydrate_planning_session_v2(
|
||||
"planning.v2.hydrate",
|
||||
) {
|
||||
Ok(lock) => lock,
|
||||
Err(error) if error.starts_with(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) => {
|
||||
Err(error) if error.starts_with("项目正在被其他写操作占用:") => {
|
||||
return Ok(None)
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
|
||||
@@ -1768,6 +1768,8 @@ pub(crate) fn register_local_asset_entry(
|
||||
local_path: normalized_path.clone(),
|
||||
image_sequence_frames: None,
|
||||
image_sequence_duration_ms: None,
|
||||
category: game_creation_app_asset_category_for_kind(kind),
|
||||
tags: Vec::new(),
|
||||
source,
|
||||
});
|
||||
Ok((id, "asset.register"))
|
||||
@@ -1793,6 +1795,181 @@ pub(crate) fn register_local_asset_entry(
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub(crate) struct ReadLocalProjectAssetReferencesInput {
|
||||
pub project_path: String,
|
||||
pub asset_id: String,
|
||||
}
|
||||
|
||||
/// 引用了某个素材的项目版本摘要。删除弹窗据此列出「被哪些版本使用」。
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct LocalProjectAssetReferenceVersion {
|
||||
pub version_id: String,
|
||||
pub project_revision: u64,
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ReadLocalProjectAssetReferencesResult {
|
||||
pub asset_id: String,
|
||||
pub versions: Vec<LocalProjectAssetReferenceVersion>,
|
||||
}
|
||||
|
||||
/// 版本数组里 `resourceBindings` 指向该素材的版本,顺序与版本数组一致。
|
||||
///
|
||||
/// 版本绑定即资源记录:`resourceId` 指向 manifest 资产 ID,命中即表示该版本用了这个素材。
|
||||
fn asset_referencing_versions(
|
||||
manifest: &GameCreationAppManifest,
|
||||
asset_id: &str,
|
||||
) -> Vec<LocalProjectAssetReferenceVersion> {
|
||||
manifest
|
||||
.versions
|
||||
.iter()
|
||||
.filter(|version| {
|
||||
version
|
||||
.resource_bindings
|
||||
.iter()
|
||||
.any(|binding| binding.resource_id == asset_id)
|
||||
})
|
||||
.map(|version| LocalProjectAssetReferenceVersion {
|
||||
version_id: version.version_id.clone(),
|
||||
project_revision: version.project_revision,
|
||||
created_at: version.created_at,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalized_asset_id(asset_id: &str) -> Result<&str, String> {
|
||||
let asset_id = asset_id.trim();
|
||||
if asset_id.is_empty() {
|
||||
return Err("素材 assetId 不能为空".to_string());
|
||||
}
|
||||
Ok(asset_id)
|
||||
}
|
||||
|
||||
pub(crate) fn read_manifest_asset_references_at(
|
||||
root: &Path,
|
||||
asset_id: &str,
|
||||
) -> Result<ReadLocalProjectAssetReferencesResult, String> {
|
||||
let asset_id = normalized_asset_id(asset_id)?;
|
||||
let manifest = read_existing_manifest_for_project(root)?;
|
||||
Ok(ReadLocalProjectAssetReferencesResult {
|
||||
asset_id: asset_id.to_string(),
|
||||
versions: asset_referencing_versions(&manifest, asset_id),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub(crate) struct DeleteLocalProjectAssetInput {
|
||||
pub project_path: String,
|
||||
pub expected_project_id: String,
|
||||
pub expected_project_revision: u64,
|
||||
pub asset_id: String,
|
||||
/// 用户是否勾选「把相关游戏版本一并删除」;默认不勾,只删素材登记。
|
||||
pub delete_referenced_versions: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DeleteLocalProjectAssetResult {
|
||||
pub asset_id: String,
|
||||
pub local_path: String,
|
||||
pub committed_project_revision: u64,
|
||||
/// 只删除 manifest 登记,磁盘文件保留;调用方据此提示用户。
|
||||
pub file_retained: bool,
|
||||
}
|
||||
|
||||
/// 删除一个素材登记,并按用户确认决定是否连带删除引用它的游戏版本。
|
||||
///
|
||||
/// 语义:
|
||||
/// - 未被任何版本引用 → 只摘掉登记;
|
||||
/// - 被引用且 `delete_referenced_versions` 为 `false` → 只摘掉登记,引用它的版本原样保留为
|
||||
/// 悬空绑定,不做二次拦截;界面不为这些版本合成资源卡;
|
||||
/// - 被引用且为 `true` → 素材与所有引用它的版本在**同一次 manifest 写入**里一起删除;
|
||||
/// - 素材不可变,**不删除磁盘文件**,只摘掉 manifest 登记;
|
||||
/// - 与资源分类更新同口径:持项目写锁后按 `expectedProjectId` / 当前 revision 做 CAS,
|
||||
/// 失败时 manifest 与 revision 都不变。
|
||||
pub(crate) fn delete_manifest_asset_at(
|
||||
root: &Path,
|
||||
expected_project_id: &str,
|
||||
expected_project_revision: u64,
|
||||
asset_id: &str,
|
||||
delete_referenced_versions: bool,
|
||||
) -> Result<DeleteLocalProjectAssetResult, String> {
|
||||
if expected_project_revision
|
||||
> shared_contracts::game_creation_app::GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION
|
||||
{
|
||||
return Err("expectedProjectRevision 超出 JavaScript 安全整数范围".to_string());
|
||||
}
|
||||
let expected_project_id = expected_project_id.trim();
|
||||
if expected_project_id.is_empty() {
|
||||
return Err("素材删除 expectedProjectId 不能为空".to_string());
|
||||
}
|
||||
let asset_id = normalized_asset_id(asset_id)?;
|
||||
|
||||
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
|
||||
return Err("project-identity-conflict".to_string());
|
||||
}
|
||||
let _lock = acquire_project_write_lock(root, "asset.register")?;
|
||||
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
|
||||
return Err("project-identity-conflict".to_string());
|
||||
}
|
||||
if read_game_creator_agent_runtime_project_revision(root)?.revision != expected_project_revision
|
||||
{
|
||||
return Err("project-revision-conflict".to_string());
|
||||
}
|
||||
|
||||
let mut local_path = String::new();
|
||||
mutate_manifest_at_allowing_version_removals(
|
||||
root,
|
||||
&|manifest| {
|
||||
if !delete_referenced_versions {
|
||||
return Vec::new();
|
||||
}
|
||||
asset_referencing_versions(manifest, asset_id)
|
||||
.into_iter()
|
||||
.map(|version| version.version_id)
|
||||
.collect()
|
||||
},
|
||||
|manifest| {
|
||||
let Some(index) = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.position(|asset| asset.id == asset_id)
|
||||
else {
|
||||
return Err(format!("项目资源不存在:{asset_id}"));
|
||||
};
|
||||
local_path = manifest.assets[index].local_path.clone();
|
||||
manifest.assets.remove(index);
|
||||
if delete_referenced_versions {
|
||||
let referenced_version_ids: Vec<String> =
|
||||
asset_referencing_versions(manifest, asset_id)
|
||||
.into_iter()
|
||||
.map(|version| version.version_id)
|
||||
.collect();
|
||||
manifest
|
||||
.versions
|
||||
.retain(|version| !referenced_version_ids.contains(&version.version_id));
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
)?;
|
||||
|
||||
let committed_project_revision = advance_agent_runtime_project_revision_locked(root)
|
||||
.map_err(|error| format!("素材登记已删除,但项目 revision 未能推进:{error}"))?;
|
||||
|
||||
Ok(DeleteLocalProjectAssetResult {
|
||||
asset_id: asset_id.to_string(),
|
||||
local_path,
|
||||
committed_project_revision,
|
||||
file_retained: true,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -18,6 +18,13 @@ 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!(
|
||||
@@ -79,6 +86,122 @@ async fn request_automatic_project_name(prompt: &str) -> Result<Option<String>,
|
||||
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 {
|
||||
@@ -848,242 +971,45 @@ pub(crate) fn update_local_project_resource_canvas_layout(
|
||||
)
|
||||
}
|
||||
|
||||
/// 读取引用了某个素材的项目版本,供删除弹窗提示「被哪些版本使用」。
|
||||
#[tauri::command]
|
||||
pub(crate) fn create_local_project_asset_canvas_draft(
|
||||
input: CreateAssetCanvasDraftInput,
|
||||
) -> Result<CreateAssetCanvasDraftResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
create_asset_canvas_draft_at(&root, &input)
|
||||
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 read_local_project_asset_canvas_draft(
|
||||
input: ReadAssetCanvasDraftInput,
|
||||
) -> Result<ReadAssetCanvasDraftResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
read_asset_canvas_draft_at(&root, &input)
|
||||
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 的 `localPath`,资产 `id` 不变。
|
||||
///
|
||||
/// 只允许在资产当前所在目录内改名,扩展名必须一致,同目录不得已有同名文件;manifest 写失败
|
||||
/// 时把文件改回原名,不留半成品。
|
||||
#[tauri::command]
|
||||
pub(crate) fn discover_local_project_asset_canvas_draft(
|
||||
input: DiscoverAssetCanvasDraftInput,
|
||||
) -> Result<DiscoverAssetCanvasDraftResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
discover_asset_canvas_draft_at(&root, &input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn update_local_project_asset_canvas_draft(
|
||||
input: UpdateAssetCanvasDraftInput,
|
||||
) -> Result<UpdateAssetCanvasDraftResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
update_asset_canvas_draft_at(&root, &input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn acknowledge_local_project_asset_canvas_candidate_layers(
|
||||
input: AcknowledgeAssetCanvasCandidateLayersInput,
|
||||
) -> Result<AcknowledgeAssetCanvasCandidateLayersResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
acknowledge_candidate_layers_at(&root, &input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn import_local_project_asset_canvas_images(
|
||||
app: tauri::AppHandle,
|
||||
input: ImportAssetCanvasImagesInput,
|
||||
) -> Result<ImportAssetCanvasImagesResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||||
let mut dialog = app
|
||||
.dialog()
|
||||
.file()
|
||||
.set_title("导入图片")
|
||||
.add_filter("图片", &["png", "jpg", "jpeg", "webp"]);
|
||||
if let Some(window) = app.get_webview_window("client") {
|
||||
dialog = dialog.set_parent(&window);
|
||||
}
|
||||
dialog.pick_files(move |paths| {
|
||||
let _ = sender.send(paths);
|
||||
});
|
||||
let Some(paths) = receiver
|
||||
.await
|
||||
.map_err(|_| "图片文件选择器意外关闭".to_string())?
|
||||
else {
|
||||
return Ok(ImportAssetCanvasImagesResult::cancelled());
|
||||
};
|
||||
if paths.is_empty() {
|
||||
return Ok(ImportAssetCanvasImagesResult::cancelled());
|
||||
}
|
||||
let paths = paths
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
path.into_path()
|
||||
.map_err(|error| format!("读取导入图片路径失败:{error}"))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
tokio::task::spawn_blocking(move || import_asset_canvas_images_at(&root, &input, &paths))
|
||||
.await
|
||||
.map_err(|error| format!("图片导入任务异常结束:{error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn store_local_project_asset_canvas_media(
|
||||
input: StoreAssetCanvasMediaInput,
|
||||
) -> Result<StoreAssetCanvasMediaResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
store_asset_canvas_media_at(&root, &input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn stage_local_project_asset_canvas_image(
|
||||
input: StageAssetCanvasImageInput,
|
||||
) -> Result<StageAssetCanvasImageResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
stage_asset_canvas_image_at(&root, &input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn archive_failed_local_project_asset_canvas_generation(
|
||||
input: ArchiveAssetCanvasGenerationInput,
|
||||
) -> Result<ArchiveAssetCanvasGenerationResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
archive_failed_asset_canvas_generation_at(&root, &input).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn generate_local_project_asset_canvas_image(
|
||||
app: tauri::AppHandle,
|
||||
input: GenerateAssetCanvasImageInput,
|
||||
) -> Result<GenerateAssetCanvasImageResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
let progress_app = app.clone();
|
||||
let execution = generate_asset_canvas_image_at(&root, &input, move |payload| {
|
||||
let _ = progress_app.emit(ASSET_CANVAS_GENERATION_PROGRESS_EVENT, payload);
|
||||
})
|
||||
.await?;
|
||||
if let Some(event) = execution.event.as_ref() {
|
||||
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
|
||||
app.emit(
|
||||
"game-creator-local-asset-committed",
|
||||
asset_canvas_committed_public_event(payload),
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
});
|
||||
}
|
||||
Ok(execution.result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn finalize_local_project_asset_canvas_generation_failure(
|
||||
input: FinalizeAssetCanvasGenerationFailureInput,
|
||||
) -> Result<FinalizeAssetCanvasGenerationFailureResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
finalize_asset_canvas_generation_failure_at(&root, &input).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn recover_local_project_asset_canvas_generations(
|
||||
app: tauri::AppHandle,
|
||||
input: RecoverAssetCanvasGenerationsInput,
|
||||
) -> Result<RecoverAssetCanvasGenerationsResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
let progress_app = app.clone();
|
||||
let execution = recover_asset_canvas_generations_at(&root, &input, move |payload| {
|
||||
let _ = progress_app.emit(ASSET_CANVAS_GENERATION_PROGRESS_EVENT, payload);
|
||||
})
|
||||
.await?;
|
||||
for event in &execution.events {
|
||||
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
|
||||
app.emit(
|
||||
"game-creator-local-asset-committed",
|
||||
asset_canvas_committed_public_event(payload),
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
});
|
||||
}
|
||||
Ok(execution.result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn confirm_local_project_asset_canvas_generation_service_identity(
|
||||
input: ConfirmAssetCanvasGenerationServiceIdentityInput,
|
||||
) -> Result<ConfirmAssetCanvasGenerationServiceIdentityResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
confirm_asset_canvas_generation_service_identity_at(&root, &input).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_local_project_asset_canvas_media(
|
||||
input: ReadAssetCanvasMediaInput,
|
||||
) -> Result<ReadAssetCanvasMediaResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
read_asset_canvas_media_at(&root, &input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn discard_local_project_asset_canvas_draft(
|
||||
input: DiscardAssetCanvasDraftInput,
|
||||
) -> Result<DiscardAssetCanvasDraftResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
discard_asset_canvas_draft_at(&root, &input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn commit_local_project_asset(
|
||||
app: tauri::AppHandle,
|
||||
input: CommitAssetCanvasInput,
|
||||
) -> Result<CommitAssetCanvasResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
let execution = commit_asset_canvas_at(&root, &input)?;
|
||||
if let Some(event) = execution.event.as_ref() {
|
||||
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
|
||||
app.emit(
|
||||
"game-creator-local-asset-committed",
|
||||
asset_canvas_committed_public_event(payload),
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
});
|
||||
}
|
||||
Ok(execution.result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn commit_local_project_asset_canvas_candidate(
|
||||
app: tauri::AppHandle,
|
||||
input: CommitAssetCanvasCandidateInput,
|
||||
) -> Result<CommitAssetCanvasResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
let execution = commit_asset_canvas_candidate_at(&root, &input)?;
|
||||
if let Some(event) = execution.event.as_ref() {
|
||||
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
|
||||
app.emit(
|
||||
"game-creator-local-asset-committed",
|
||||
asset_canvas_committed_public_event(payload),
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
});
|
||||
}
|
||||
Ok(execution.result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn recover_local_project_asset_canvas_transactions(
|
||||
app: tauri::AppHandle,
|
||||
input: RecoverAssetCanvasTransactionsInput,
|
||||
) -> Result<RecoverAssetCanvasTransactionsResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
let execution = recover_asset_canvas_transactions_at(&root, &input.expected_project_id)?;
|
||||
for event in &execution.events {
|
||||
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
|
||||
app.emit(
|
||||
"game-creator-local-asset-committed",
|
||||
asset_canvas_committed_public_event(payload),
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
});
|
||||
}
|
||||
Ok(execution.result)
|
||||
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.asset_id, &input.new_file_name, None)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1865,6 +1791,16 @@ pub(crate) async fn suggest_automatic_project_name(
|
||||
.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()
|
||||
@@ -2166,6 +2102,22 @@ pub(crate) fn create_ui_design_resource(
|
||||
})
|
||||
}
|
||||
|
||||
#[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,
|
||||
@@ -2791,6 +2743,8 @@ mod ui_editor_font_tests {
|
||||
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,
|
||||
@@ -4378,6 +4332,12 @@ pub(crate) fn read_local_project_file(
|
||||
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>,
|
||||
|
||||
@@ -3640,38 +3640,7 @@ pub(crate) fn merge_game_creator_config_file(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Only files inside the managed runtime config directory (real credentials)
|
||||
/// may use the private-read channel: it hardens the owner/DACL on every read.
|
||||
/// Repository-adjacent fallback templates and overlay files are shared inputs
|
||||
/// that can be git-tracked; privatizing one on read silently locks the
|
||||
/// worktree template to whichever account happened to run the dev CLI, so
|
||||
/// they must go through the non-mutating snapshot channel instead.
|
||||
pub(crate) fn game_creator_config_path_is_runtime_managed(path: &Path) -> bool {
|
||||
game_creator_runtime_config_dir().is_some_and(|directory| path.starts_with(directory))
|
||||
}
|
||||
|
||||
fn read_game_creator_snapshot_file_to_string(
|
||||
path: &Path,
|
||||
label: &str,
|
||||
max_bytes: u64,
|
||||
) -> Result<String, String> {
|
||||
let (mut file, metadata) = open_project_snapshot_regular_file(path, label)?;
|
||||
if metadata.len() > max_bytes {
|
||||
return Err(format!("{label}过大,已拒绝读取:{}", path.display()));
|
||||
}
|
||||
let mut content = String::with_capacity(metadata.len() as usize);
|
||||
file.read_to_string(&mut content)
|
||||
.map_err(|error| format!("读取{label}失败:{}: {error}", path.display()))?;
|
||||
let final_metadata = file
|
||||
.metadata()
|
||||
.map_err(|error| format!("复核{label}失败:{}: {error}", path.display()))?;
|
||||
if final_metadata.len() != metadata.len() {
|
||||
return Err(format!("{label}读取期间文件发生漂移:{}", path.display()));
|
||||
}
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
pub(crate) fn read_game_creator_config_file(path: &Path) -> Result<Option<String>, String> {
|
||||
fn read_game_creator_config_file(path: &Path) -> Result<Option<String>, String> {
|
||||
let backup_path = game_creator_config_backup_path(path);
|
||||
let path_exists = validate_game_creator_config_file_entry(path)?;
|
||||
let read_path = if path_exists {
|
||||
@@ -3681,11 +3650,7 @@ pub(crate) fn read_game_creator_config_file(path: &Path) -> Result<Option<String
|
||||
} else {
|
||||
return Ok(None);
|
||||
};
|
||||
let content = if game_creator_config_path_is_runtime_managed(read_path) {
|
||||
read_game_creator_private_file_to_string(read_path, "客户端配置", 256 * 1024)?
|
||||
} else {
|
||||
read_game_creator_snapshot_file_to_string(read_path, "客户端配置", 256 * 1024)?
|
||||
};
|
||||
let content = read_game_creator_private_file_to_string(read_path, "客户端配置", 256 * 1024)?;
|
||||
Ok(Some(content))
|
||||
}
|
||||
|
||||
|
||||
@@ -26,10 +26,11 @@ use serde::{Deserialize, Serialize};
|
||||
use sha2::Digest;
|
||||
use shared_contracts::error_reports::ErrorReportLogInput;
|
||||
use shared_contracts::game_creation_app::{
|
||||
game_creation_app_asset_category_for_kind, game_creation_app_asset_category_from_str,
|
||||
new_game_creation_app_manifest, new_game_creation_app_seed_tasks,
|
||||
validate_game_iteration_versions, GameCreationAgentArtifactTrace,
|
||||
GameCreationAgentCapabilityDescriptor, GameCreationAgentPassPlanTrace,
|
||||
GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
|
||||
normalize_game_creation_app_asset_tags, validate_game_iteration_versions,
|
||||
GameCreationAgentArtifactTrace, GameCreationAgentCapabilityDescriptor,
|
||||
GameCreationAgentPassPlanTrace, GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
|
||||
GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace,
|
||||
GameCreationAppAgentGroup, GameCreationAppAssetManifestEntry, GameCreationAppAssetSource,
|
||||
GameCreationAppAssetSourceKind, GameCreationAppCommandRunState,
|
||||
@@ -2562,6 +2563,7 @@ fn main() {
|
||||
pick_local_project_directory,
|
||||
rename_local_game_project,
|
||||
suggest_automatic_project_name,
|
||||
polish_local_project_prompt,
|
||||
pick_local_file,
|
||||
pick_client_extension_file,
|
||||
pick_client_extension_directory,
|
||||
@@ -2613,6 +2615,7 @@ fn main() {
|
||||
upload_local_asset,
|
||||
register_local_asset,
|
||||
create_ui_design_resource,
|
||||
update_local_project_resource_classification,
|
||||
derive_local_project_resource,
|
||||
list_pending_local_project_resource_edits,
|
||||
resume_local_project_resource_edit,
|
||||
@@ -2645,6 +2648,7 @@ fn main() {
|
||||
import_local_project_image_assets,
|
||||
read_local_project_file,
|
||||
read_local_project_image_preview,
|
||||
save_local_project_asset_file,
|
||||
read_local_project_text_preview,
|
||||
read_local_project_media_preview,
|
||||
cancel_local_project_resource_preview_scope,
|
||||
@@ -2683,24 +2687,9 @@ fn main() {
|
||||
read_local_project_resource_canvas_layout,
|
||||
read_local_project_resource_graph,
|
||||
update_local_project_resource_canvas_layout,
|
||||
create_local_project_asset_canvas_draft,
|
||||
read_local_project_asset_canvas_draft,
|
||||
discover_local_project_asset_canvas_draft,
|
||||
update_local_project_asset_canvas_draft,
|
||||
acknowledge_local_project_asset_canvas_candidate_layers,
|
||||
import_local_project_asset_canvas_images,
|
||||
store_local_project_asset_canvas_media,
|
||||
stage_local_project_asset_canvas_image,
|
||||
finalize_local_project_asset_canvas_generation_failure,
|
||||
archive_failed_local_project_asset_canvas_generation,
|
||||
generate_local_project_asset_canvas_image,
|
||||
recover_local_project_asset_canvas_generations,
|
||||
confirm_local_project_asset_canvas_generation_service_identity,
|
||||
read_local_project_asset_canvas_media,
|
||||
discard_local_project_asset_canvas_draft,
|
||||
recover_local_project_asset_canvas_transactions,
|
||||
commit_local_project_asset,
|
||||
commit_local_project_asset_canvas_candidate,
|
||||
delete_local_project_asset,
|
||||
read_local_project_asset_references,
|
||||
rename_local_project_asset,
|
||||
get_local_game_project_revision,
|
||||
get_local_game_manifest,
|
||||
download_agc_update,
|
||||
|
||||
@@ -488,6 +488,113 @@ const PREVIEW_FIT_BRIDGE_SCRIPT: &str = r#"(() => {
|
||||
if (!document.hidden) schedule();
|
||||
}, 500);
|
||||
};
|
||||
const inspectMessageType = 'genarrative.local-preview-inspect.v1';
|
||||
const inspectState = { enabled: false, overlay: null, current: null };
|
||||
const inspectOverlay = () => {
|
||||
if (inspectState.overlay) return inspectState.overlay;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.dataset.genarrativePreviewInspect = 'true';
|
||||
overlay.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;border:2px solid #f97316;background:rgba(249,115,22,0.14);box-shadow:inset 0 0 0 1px rgba(255,255,255,0.85);';
|
||||
(document.body || document.documentElement).appendChild(overlay);
|
||||
inspectState.overlay = overlay;
|
||||
return overlay;
|
||||
};
|
||||
const clearInspectOverlay = () => {
|
||||
inspectState.overlay?.remove();
|
||||
inspectState.overlay = null;
|
||||
inspectState.current = null;
|
||||
};
|
||||
const inspectElementSummary = (element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const tag = element.tagName ? element.tagName.toLowerCase() : 'div';
|
||||
const role = (element.getAttribute && element.getAttribute('role')) || '';
|
||||
const text = (element.innerText || element.textContent || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 120);
|
||||
const resourceIds = [];
|
||||
if (element.getAttribute) {
|
||||
for (const name of ['data-resource-id', 'data-asset-id', 'data-genarrative-resource-id']) {
|
||||
const value = element.getAttribute(name);
|
||||
if (value && !resourceIds.includes(value)) resourceIds.push(value);
|
||||
}
|
||||
}
|
||||
const media = element.querySelector ? element.querySelector('img,video,audio') : null;
|
||||
const rawSource =
|
||||
(element.getAttribute && element.getAttribute('src')) ||
|
||||
(media && media.getAttribute && media.getAttribute('src')) ||
|
||||
'';
|
||||
let sourcePath = '';
|
||||
if (rawSource) {
|
||||
try {
|
||||
sourcePath = new URL(rawSource, window.location.href).pathname;
|
||||
} catch {
|
||||
sourcePath = '';
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: text || tag,
|
||||
elementTag: tag,
|
||||
elementRole: role.slice(0, 80),
|
||||
text,
|
||||
width: Math.max(1, Math.round(rect.width)),
|
||||
height: Math.max(1, Math.round(rect.height)),
|
||||
resourceIds,
|
||||
sourcePath,
|
||||
};
|
||||
};
|
||||
const postInspect = (action, selection) => {
|
||||
window.parent.postMessage({ type: inspectMessageType, action, selection }, '*');
|
||||
};
|
||||
const stopInspect = (cancelled) => {
|
||||
if (!inspectState.enabled) return;
|
||||
inspectState.enabled = false;
|
||||
document.removeEventListener('mousemove', handleInspectMove, true);
|
||||
document.removeEventListener('click', handleInspectClick, true);
|
||||
document.removeEventListener('keydown', handleInspectKeyDown, true);
|
||||
clearInspectOverlay();
|
||||
postInspect(cancelled ? 'cancelled' : 'disabled');
|
||||
};
|
||||
const handleInspectMove = (event) => {
|
||||
if (!inspectState.enabled) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
inspectState.current = target;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const overlay = inspectOverlay();
|
||||
overlay.style.left = `${rect.left}px`;
|
||||
overlay.style.top = `${rect.top}px`;
|
||||
overlay.style.width = `${rect.width}px`;
|
||||
overlay.style.height = `${rect.height}px`;
|
||||
};
|
||||
const handleInspectClick = (event) => {
|
||||
if (!inspectState.enabled) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const target = event.target;
|
||||
if (target instanceof Element) postInspect('selected', inspectElementSummary(target));
|
||||
stopInspect(false);
|
||||
};
|
||||
const handleInspectKeyDown = (event) => {
|
||||
if (!inspectState.enabled || event.key !== 'Escape') return;
|
||||
event.preventDefault();
|
||||
stopInspect(true);
|
||||
};
|
||||
const startInspect = () => {
|
||||
if (inspectState.enabled) return;
|
||||
inspectState.enabled = true;
|
||||
document.addEventListener('mousemove', handleInspectMove, true);
|
||||
document.addEventListener('click', handleInspectClick, true);
|
||||
document.addEventListener('keydown', handleInspectKeyDown, true);
|
||||
postInspect('enabled');
|
||||
};
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.source !== window.parent) return;
|
||||
const data = event.data;
|
||||
if (!data || data.type !== inspectMessageType) return;
|
||||
if (data.action === 'enable') startInspect();
|
||||
else if (data.action === 'disable') stopInspect(false);
|
||||
});
|
||||
if (ready) activate();
|
||||
else window.addEventListener('load', activate, { once: true });
|
||||
})();"#;
|
||||
|
||||
@@ -4,7 +4,8 @@ use similar::TextDiff;
|
||||
use std::io::{Seek, SeekFrom};
|
||||
|
||||
mod agent_db;
|
||||
mod asset_canvas;
|
||||
mod asset_export;
|
||||
mod asset_rename;
|
||||
mod checkpoint;
|
||||
mod conversation;
|
||||
mod export;
|
||||
@@ -16,10 +17,10 @@ mod resource_dependency_graph;
|
||||
mod resource_editor;
|
||||
mod resource_layout;
|
||||
mod verification;
|
||||
mod write_lock;
|
||||
|
||||
pub(crate) use agent_db::*;
|
||||
pub(crate) use asset_canvas::*;
|
||||
pub(crate) use asset_export::*;
|
||||
pub(crate) use asset_rename::*;
|
||||
pub(crate) use checkpoint::*;
|
||||
pub(crate) use conversation::*;
|
||||
pub(crate) use export::*;
|
||||
@@ -31,4 +32,3 @@ pub(crate) use resource_dependency_graph::*;
|
||||
pub(crate) use resource_editor::*;
|
||||
pub(crate) use resource_layout::*;
|
||||
pub(crate) use verification::*;
|
||||
pub(crate) use write_lock::*;
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[cfg(windows)]
|
||||
use super::write_lock::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT;
|
||||
use super::filesystem::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT;
|
||||
|
||||
const AGENT_DB_MAX_RECORD_BYTES: usize = 1024 * 1024;
|
||||
const AGENT_DB_ACTION_RECEIPT_RECORD_TYPE: &str = "agent.runtime.action_receipt";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,255 @@
|
||||
use super::*;
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
/// 显式保存:把项目内已登记的素材文件复制到用户选定的目标路径。
|
||||
///
|
||||
/// 这里刻意不做"浏览器下载"——AGC 是 Tauri/WebView2 宿主,未注册 `on_download`
|
||||
/// 时 `<a download>` 能否落盘不可靠,所以保存路径由原生对话框给出,复制由 Rust 完成。
|
||||
const ASSET_EXPORT_COPY_CHUNK_BYTES: usize = 64 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub(crate) struct SaveLocalProjectAssetFileInput {
|
||||
pub(crate) project_path: String,
|
||||
pub(crate) relative_path: String,
|
||||
pub(crate) destination_path: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SaveLocalProjectAssetFileResult {
|
||||
pub(crate) destination_path: String,
|
||||
pub(crate) byte_len: u64,
|
||||
}
|
||||
|
||||
fn resolve_export_source_file(
|
||||
root: &Path,
|
||||
relative_path: &str,
|
||||
) -> Result<PathBuf, String> {
|
||||
let normalized = normalize_relative_path(relative_path.trim())?;
|
||||
if normalized.is_empty() {
|
||||
return Err("待保存的素材路径不能为空".to_string());
|
||||
}
|
||||
let source = root.join(&normalized);
|
||||
// 只允许保存项目根内真实存在的普通文件:符号链接与目录都要拒绝。
|
||||
let metadata = std::fs::symlink_metadata(&source)
|
||||
.map_err(|error| format!("素材文件不存在或不可读:{error}"))?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err("素材路径不能是符号链接".to_string());
|
||||
}
|
||||
if !metadata.is_file() {
|
||||
return Err("素材路径必须是普通文件".to_string());
|
||||
}
|
||||
Ok(source)
|
||||
}
|
||||
|
||||
fn resolve_export_destination_file(destination_path: &str) -> Result<PathBuf, String> {
|
||||
let trimmed = destination_path.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("保存目标路径不能为空".to_string());
|
||||
}
|
||||
let destination = PathBuf::from(trimmed);
|
||||
if destination.file_name().is_none() {
|
||||
return Err("保存目标必须是文件路径".to_string());
|
||||
}
|
||||
let parent = destination
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
.ok_or_else(|| "保存目标缺少父目录".to_string())?;
|
||||
if !parent.is_dir() {
|
||||
return Err("保存目标的父目录不存在".to_string());
|
||||
}
|
||||
if destination.is_dir() {
|
||||
return Err("保存目标不能是目录".to_string());
|
||||
}
|
||||
Ok(destination)
|
||||
}
|
||||
|
||||
pub(crate) fn save_local_project_asset_file_at(
|
||||
input: SaveLocalProjectAssetFileInput,
|
||||
) -> Result<SaveLocalProjectAssetFileResult, String> {
|
||||
let root = Path::new(input.project_path.trim());
|
||||
validate_project_root(root)?;
|
||||
enforce_project_auto_permission_policy(root, "file.read")?;
|
||||
|
||||
let source = resolve_export_source_file(root, &input.relative_path)?;
|
||||
let destination = resolve_export_destination_file(&input.destination_path)?;
|
||||
|
||||
let mut reader =
|
||||
File::open(&source).map_err(|error| format!("打开素材文件失败:{error}"))?;
|
||||
let mut writer = File::create(&destination)
|
||||
.map_err(|error| format!("创建保存目标失败:{error}"))?;
|
||||
let mut buffer = vec![0_u8; ASSET_EXPORT_COPY_CHUNK_BYTES];
|
||||
let mut byte_len = 0_u64;
|
||||
loop {
|
||||
let read = reader
|
||||
.read(&mut buffer)
|
||||
.map_err(|error| format!("读取素材文件失败:{error}"))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
writer
|
||||
.write_all(&buffer[..read])
|
||||
.map_err(|error| format!("写入保存目标失败:{error}"))?;
|
||||
byte_len += read as u64;
|
||||
}
|
||||
writer
|
||||
.flush()
|
||||
.map_err(|error| format!("刷新保存目标失败:{error}"))?;
|
||||
|
||||
Ok(SaveLocalProjectAssetFileResult {
|
||||
destination_path: destination.to_string_lossy().into_owned(),
|
||||
byte_len,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static NEXT_ASSET_EXPORT_TEST_ID: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn unique_asset_export_directory(prefix: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"genarrative-asset-export-{prefix}-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT_ASSET_EXPORT_TEST_ID.fetch_add(1, Ordering::Relaxed)
|
||||
))
|
||||
}
|
||||
|
||||
fn create_export_project() -> (PathBuf, PathBuf) {
|
||||
let root = unique_asset_export_directory("project");
|
||||
let assets = root.join("assets");
|
||||
std::fs::create_dir_all(&assets).expect("create project assets directory");
|
||||
(root, assets)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copies_registered_asset_to_chosen_destination() {
|
||||
let (root, assets) = create_export_project();
|
||||
let source_bytes = b"genarrative-asset-export-payload".to_vec();
|
||||
std::fs::write(assets.join("hero.png"), &source_bytes).expect("write source asset");
|
||||
let destination_directory = unique_asset_export_directory("destination");
|
||||
std::fs::create_dir_all(&destination_directory).expect("create destination directory");
|
||||
let destination = destination_directory.join("hero-copy.png");
|
||||
|
||||
let result = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
relative_path: "assets/hero.png".to_string(),
|
||||
destination_path: destination.to_string_lossy().into_owned(),
|
||||
})
|
||||
.expect("save asset file");
|
||||
|
||||
assert_eq!(result.byte_len, source_bytes.len() as u64);
|
||||
assert_eq!(result.destination_path, destination.to_string_lossy());
|
||||
assert_eq!(
|
||||
std::fs::read(&destination).expect("read copied asset"),
|
||||
source_bytes
|
||||
);
|
||||
// 源素材保持不变:保存只是复制,不改动项目内文件。
|
||||
assert_eq!(
|
||||
std::fs::read(assets.join("hero.png")).expect("read source asset"),
|
||||
source_bytes
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let _ = std::fs::remove_dir_all(&destination_directory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_source_asset() {
|
||||
let (root, _assets) = create_export_project();
|
||||
let destination_directory = unique_asset_export_directory("destination");
|
||||
std::fs::create_dir_all(&destination_directory).expect("create destination directory");
|
||||
|
||||
let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
relative_path: "assets/missing.png".to_string(),
|
||||
destination_path: destination_directory
|
||||
.join("missing-copy.png")
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
})
|
||||
.expect_err("missing source must fail");
|
||||
|
||||
assert!(error.contains("素材文件不存在或不可读"), "{error}");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let _ = std::fs::remove_dir_all(&destination_directory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_directory_as_source_asset() {
|
||||
let (root, assets) = create_export_project();
|
||||
std::fs::create_dir_all(assets.join("nested")).expect("create nested directory");
|
||||
let destination_directory = unique_asset_export_directory("destination");
|
||||
std::fs::create_dir_all(&destination_directory).expect("create destination directory");
|
||||
|
||||
let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
relative_path: "assets/nested".to_string(),
|
||||
destination_path: destination_directory
|
||||
.join("nested-copy")
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
})
|
||||
.expect_err("directory source must fail");
|
||||
|
||||
assert!(error.contains("必须是普通文件"), "{error}");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let _ = std::fs::remove_dir_all(&destination_directory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_blank_destination_and_missing_parent_directory() {
|
||||
let (root, assets) = create_export_project();
|
||||
std::fs::write(assets.join("hero.png"), b"payload").expect("write source asset");
|
||||
|
||||
let blank = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
relative_path: "assets/hero.png".to_string(),
|
||||
destination_path: " ".to_string(),
|
||||
})
|
||||
.expect_err("blank destination must fail");
|
||||
assert!(blank.contains("保存目标路径不能为空"), "{blank}");
|
||||
|
||||
let missing_parent_directory = unique_asset_export_directory("absent");
|
||||
let missing_parent = save_local_project_asset_file_at(
|
||||
SaveLocalProjectAssetFileInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
relative_path: "assets/hero.png".to_string(),
|
||||
destination_path: missing_parent_directory
|
||||
.join("hero-copy.png")
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
},
|
||||
)
|
||||
.expect_err("missing parent directory must fail");
|
||||
assert!(
|
||||
missing_parent.contains("保存目标的父目录不存在"),
|
||||
"{missing_parent}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_destination_that_is_an_existing_directory() {
|
||||
let (root, assets) = create_export_project();
|
||||
std::fs::write(assets.join("hero.png"), b"payload").expect("write source asset");
|
||||
let destination_directory = unique_asset_export_directory("destination");
|
||||
std::fs::create_dir_all(&destination_directory).expect("create destination directory");
|
||||
|
||||
let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
relative_path: "assets/hero.png".to_string(),
|
||||
destination_path: destination_directory.to_string_lossy().into_owned(),
|
||||
})
|
||||
.expect_err("directory destination must fail");
|
||||
|
||||
assert!(error.contains("保存目标不能是目录"), "{error}");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let _ = std::fs::remove_dir_all(&destination_directory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
use super::*;
|
||||
|
||||
use super::filesystem::validate_portable_project_path_component;
|
||||
|
||||
/// 素材重命名的入参。
|
||||
///
|
||||
/// 只收"新文件名"而不是新旧两个全路径:改名被限制在资产当前所在目录内,目录由 manifest
|
||||
/// 里的 `localPath` 决定,调用方无法指定目标目录。
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub(crate) struct RenameLocalProjectAssetInput {
|
||||
pub(crate) project_path: String,
|
||||
pub(crate) asset_id: String,
|
||||
pub(crate) new_file_name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct RenameLocalProjectAssetResult {
|
||||
pub(crate) asset: GameCreationAppAssetManifestEntry,
|
||||
pub(crate) previous_local_path: String,
|
||||
pub(crate) committed_project_revision: u64,
|
||||
}
|
||||
|
||||
/// 故障注入点:让测试在不依赖只读路径的前提下验证"manifest 写失败必须回滚文件改名"。
|
||||
/// 生产路径始终传 `None`。
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum RenameLocalProjectAssetFaultStage {
|
||||
/// 文件已改名、manifest 尚未写入时失败。
|
||||
ManifestWrite,
|
||||
}
|
||||
|
||||
/// 校验调用方给出的新文件名,返回 trim 后的名字。
|
||||
///
|
||||
/// 判据(按顺序):
|
||||
/// - 非空;
|
||||
/// - 不含路径分隔符(`/`、`\`)——这同时保证"不跨目录",改名只落在资产当前所在目录内;
|
||||
/// - 不含 `..`——分隔符已被拒绝后,`..` 仍可能以"整个名字就是 `..`"的形式改变层级,一律拒绝;
|
||||
/// - 通过项目路径组件的可移植性校验(控制字符、Windows 保留字符、结尾点或空格、保留设备名),
|
||||
/// 与项目内其它写入路径保持同一套路径安全口径。
|
||||
fn normalize_asset_file_name(value: &str) -> Result<String, String> {
|
||||
let name = value.trim();
|
||||
if name.is_empty() {
|
||||
return Err("新文件名不能为空".to_string());
|
||||
}
|
||||
if name.contains('/') || name.contains('\\') {
|
||||
return Err("新文件名不能包含路径分隔符".to_string());
|
||||
}
|
||||
if name.contains("..") {
|
||||
return Err("新文件名不能包含 ..".to_string());
|
||||
}
|
||||
validate_portable_project_path_component(name)
|
||||
.map_err(|error| format!("新文件名非法:{error}"))?;
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
/// 取小写扩展名。`.gitignore` 这类隐藏文件按"无扩展名"处理。
|
||||
fn asset_file_extension(file_name: &str) -> Option<String> {
|
||||
Path::new(file_name)
|
||||
.extension()
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.map(str::to_ascii_lowercase)
|
||||
}
|
||||
|
||||
/// 一个帧的 `imageSrc` 是否就是被改名的那份本地文件。
|
||||
///
|
||||
/// 判据:既不是绝对路径、也不是带 scheme 的远程地址、也不是反斜杠路径;去掉目录后文件名与旧名
|
||||
/// 一致(大小写不敏感,和扩展名比较同口径)。只认真正指向该文件的帧,同目录但指向其它文件的帧
|
||||
/// 保持原样,避免改名连带改坏别的引用。
|
||||
fn asset_local_frame_matches(image_src: &str, directory: &str, previous_file_name: &str) -> bool {
|
||||
let image_src = image_src.trim();
|
||||
if image_src.is_empty()
|
||||
|| image_src.starts_with('/')
|
||||
|| image_src.contains('\\')
|
||||
|| image_src.contains("://")
|
||||
|| Path::new(image_src).is_absolute()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let (frame_directory, frame_file_name) = match image_src.rsplit_once('/') {
|
||||
Some((directory, file_name)) => (directory, file_name),
|
||||
None => ("", image_src),
|
||||
};
|
||||
frame_directory == directory && frame_file_name.eq_ignore_ascii_case(previous_file_name)
|
||||
}
|
||||
|
||||
/// 把 manifest 条目里指向被改名文件的序列帧对齐到新 `localPath`。
|
||||
fn align_image_sequence_frames(
|
||||
asset: &mut GameCreationAppAssetManifestEntry,
|
||||
directory: &str,
|
||||
previous_file_name: &str,
|
||||
next_local_path: &str,
|
||||
) {
|
||||
let Some(frames) = asset.image_sequence_frames.as_mut() else {
|
||||
return;
|
||||
};
|
||||
for frame in frames.iter_mut() {
|
||||
if !asset_local_frame_matches(&frame.image_src, directory, previous_file_name) {
|
||||
continue;
|
||||
}
|
||||
frame.image_src = next_local_path.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// manifest 写失败后的回滚:把文件改回原名,并把回滚失败单独报出来。
|
||||
fn rollback_asset_file_rename(
|
||||
current_absolute: &Path,
|
||||
next_absolute: &Path,
|
||||
error: String,
|
||||
) -> String {
|
||||
match fs::rename(next_absolute, current_absolute) {
|
||||
Ok(()) => error,
|
||||
Err(rollback_error) => format!(
|
||||
"{error};reconciliation-required: 素材文件未能改回原名:{} -> {}: {rollback_error}",
|
||||
next_absolute.display(),
|
||||
current_absolute.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 重命名一个已登记素材:**文件改名 + 更新 manifest 的 `localPath`,资产 `id` 不变**。
|
||||
///
|
||||
/// manifest 资产条目没有 `name` 字段,资源显示名来自 `fileName(localPath)`,因此重命名的语义
|
||||
/// 只能是"改文件名 + 改 `localPath`"。
|
||||
///
|
||||
/// 事务顺序(全程持既有项目写锁,期间不会出现第二个项目写者;manifest 写入仍走既有边界:
|
||||
/// 版本数组不可变校验 + 安装后回读一致性校验):
|
||||
/// 1. 读 manifest 定位资产,取旧 `localPath`;
|
||||
/// 2. 校验新文件名、扩展名一致、目标不冲突,然后 `rename` 磁盘文件;
|
||||
/// 3. 更新 manifest 的 `localPath`(`id` / `kind` / `mediaType` / `source` / `category` / `tags`
|
||||
/// 全部不变),并把 `imageSequenceFrames` 里指向该文件的帧对齐到新路径;
|
||||
/// 4. 写 manifest;
|
||||
/// 5. 第 4 步失败时把文件改回原名,不留"文件已改名但 manifest 还是旧路径"的半成品;
|
||||
/// 改回原名也失败时,两个错误都报出来并标记 reconciliation-required。
|
||||
pub(crate) fn rename_local_project_asset_at(
|
||||
root: &Path,
|
||||
asset_id: &str,
|
||||
new_file_name: &str,
|
||||
fault: Option<RenameLocalProjectAssetFaultStage>,
|
||||
) -> Result<RenameLocalProjectAssetResult, String> {
|
||||
let asset_id = asset_id.trim();
|
||||
if asset_id.is_empty() {
|
||||
return Err("素材重命名 assetId 不能为空".to_string());
|
||||
}
|
||||
let new_file_name = normalize_asset_file_name(new_file_name)?;
|
||||
|
||||
let _lock = acquire_project_write_lock(root, "asset.register")?;
|
||||
let manifest_path = root.join(".agent/manifest.json");
|
||||
let mut manifest = read_existing_manifest_for_project(root)?;
|
||||
let index = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.position(|asset| asset.id == asset_id)
|
||||
.ok_or_else(|| format!("项目资源不存在:{asset_id}"))?;
|
||||
let previous_local_path = manifest.assets[index].local_path.clone();
|
||||
let (directory, current_file_name) = match previous_local_path.rsplit_once('/') {
|
||||
Some((directory, file_name)) => (directory.to_string(), file_name.to_string()),
|
||||
None => (String::new(), previous_local_path.clone()),
|
||||
};
|
||||
|
||||
// 同名重命名是空操作:磁盘与 manifest 都不动,直接回报当前状态。
|
||||
if current_file_name == new_file_name {
|
||||
return Ok(RenameLocalProjectAssetResult {
|
||||
asset: manifest.assets[index].clone(),
|
||||
previous_local_path,
|
||||
committed_project_revision: read_game_creator_agent_runtime_project_revision(root)?
|
||||
.revision,
|
||||
});
|
||||
}
|
||||
|
||||
let current_extension = asset_file_extension(¤t_file_name);
|
||||
let next_extension = asset_file_extension(&new_file_name);
|
||||
if current_extension != next_extension {
|
||||
return Err(format!(
|
||||
"新文件名扩展名必须与原文件一致,原扩展名:{}",
|
||||
current_extension
|
||||
.map(|extension| format!(".{extension}"))
|
||||
.unwrap_or_else(|| "无".to_string())
|
||||
));
|
||||
}
|
||||
|
||||
let next_local_path = normalize_relative_path(&if directory.is_empty() {
|
||||
new_file_name.clone()
|
||||
} else {
|
||||
format!("{directory}/{new_file_name}")
|
||||
})?;
|
||||
let current_absolute = resolve_local_project_path(root, &previous_local_path)?;
|
||||
let next_absolute = resolve_local_project_path(root, &next_local_path)?;
|
||||
|
||||
// 被改名的必须是真的普通文件:登记与磁盘不一致时先报错,不写出指向"新名字"的悬空登记。
|
||||
match fs::symlink_metadata(¤t_absolute) {
|
||||
Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => {}
|
||||
Ok(_) => return Err(format!("素材路径必须是普通文件:{previous_local_path}")),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Err(format!("素材文件不存在:{previous_local_path}"));
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"读取素材元数据失败:{}: {error}",
|
||||
current_absolute.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
// 同目录内不得已存在同名文件:先检查再改名,避免 `rename` 覆盖既有文件。
|
||||
// 只差文件名大小写的改名在大小写不敏感的文件系统上会命中同一条检查,按"同名冲突"拒绝。
|
||||
match fs::symlink_metadata(&next_absolute) {
|
||||
Ok(_) => return Err(format!("同目录已存在同名文件:{new_file_name}")),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"读取目标素材元数据失败:{}: {error}",
|
||||
next_absolute.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fs::rename(¤t_absolute, &next_absolute).map_err(|error| {
|
||||
format!(
|
||||
"素材文件改名失败:{} -> {}: {error}",
|
||||
current_absolute.display(),
|
||||
next_absolute.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
manifest.assets[index].local_path = next_local_path.clone();
|
||||
align_image_sequence_frames(
|
||||
&mut manifest.assets[index],
|
||||
&directory,
|
||||
¤t_file_name,
|
||||
&next_local_path,
|
||||
);
|
||||
let asset = manifest.assets[index].clone();
|
||||
|
||||
// 文件已改名:从这里开始的任何失败都必须把文件改回原名。
|
||||
let write_error = match fault {
|
||||
Some(RenameLocalProjectAssetFaultStage::ManifestWrite) => {
|
||||
Some("fault-injected:rename-asset-manifest-write".to_string())
|
||||
}
|
||||
None => write_manifest(&manifest_path, &manifest).err(),
|
||||
};
|
||||
if let Some(error) = write_error {
|
||||
return Err(rollback_asset_file_rename(
|
||||
¤t_absolute,
|
||||
&next_absolute,
|
||||
error,
|
||||
));
|
||||
}
|
||||
|
||||
let committed_project_revision = advance_agent_runtime_project_revision_locked(root)
|
||||
.map_err(|error| format!("素材已改名,但项目 revision 未能推进:{error}"))?;
|
||||
Ok(RenameLocalProjectAssetResult {
|
||||
asset,
|
||||
previous_local_path,
|
||||
committed_project_revision,
|
||||
})
|
||||
}
|
||||
@@ -3,7 +3,7 @@ use super::*;
|
||||
use super::filesystem::validate_portable_project_path_component;
|
||||
|
||||
#[cfg(windows)]
|
||||
use super::write_lock::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT;
|
||||
use super::filesystem::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT;
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_regular_file_handle_identity(file: &File, label: &str) -> Result<(u32, u64), String> {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user