Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d1fa973d25 | |||
| ee33af7b75 | |||
| 9fa05eccaf | |||
| 709ef6256c | |||
| 37d45d2de8 | |||
| 6b13d1d613 | |||
| d5eca8cce6 | |||
| be370cc615 |
@@ -2,9 +2,9 @@
|
|||||||
//! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。
|
//! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。
|
||||||
|
|
||||||
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENTS: usize = 8;
|
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENTS: usize = 8;
|
||||||
const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160;
|
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160;
|
||||||
const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
|
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
|
||||||
const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512;
|
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512;
|
||||||
|
|
||||||
const HOME_ATTACHMENT_HEADER: &str =
|
const HOME_ATTACHMENT_HEADER: &str =
|
||||||
"[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]";
|
"[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]";
|
||||||
|
|||||||
+133
-2
@@ -4,6 +4,8 @@ use super::model::{
|
|||||||
};
|
};
|
||||||
use crate::agent::{
|
use crate::agent::{
|
||||||
read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest,
|
read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest,
|
||||||
|
MAX_DIRECT_CODEX_ATTACHMENTS, MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS,
|
||||||
|
MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS,
|
||||||
};
|
};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -27,6 +29,7 @@ pub(crate) fn validate_direct_codex_user_item(
|
|||||||
}
|
}
|
||||||
let manifest = read_manifest_for_project(root)?;
|
let manifest = read_manifest_for_project(root)?;
|
||||||
let mut reference_count = 0usize;
|
let mut reference_count = 0usize;
|
||||||
|
let mut attachment_count = 0usize;
|
||||||
for part in &message.content {
|
for part in &message.content {
|
||||||
match part {
|
match part {
|
||||||
DirectCodexUserContentPart::InputText { .. } => {}
|
DirectCodexUserContentPart::InputText { .. } => {}
|
||||||
@@ -39,14 +42,40 @@ pub(crate) fn validate_direct_codex_user_item(
|
|||||||
validate_runtime_region_reference(&manifest, reference)?;
|
validate_runtime_region_reference(&manifest, reference)?;
|
||||||
}
|
}
|
||||||
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
||||||
|
attachment_count = attachment_count.saturating_add(1);
|
||||||
|
if attachment_count > MAX_DIRECT_CODEX_ATTACHMENTS {
|
||||||
|
return Err(format!(
|
||||||
|
"一次最多携带 {MAX_DIRECT_CODEX_ATTACHMENTS} 个附件"
|
||||||
|
));
|
||||||
|
}
|
||||||
if reference.name.trim().is_empty() {
|
if reference.name.trim().is_empty() {
|
||||||
return Err("附件缺少文件名".to_string());
|
return Err("附件缺少文件名".to_string());
|
||||||
}
|
}
|
||||||
|
let name = reference.name.trim();
|
||||||
|
if name.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS
|
||||||
|
|| name.chars().any(char::is_control)
|
||||||
|
{
|
||||||
|
return Err("附件文件名无效或过长".to_string());
|
||||||
|
}
|
||||||
|
let media_type = reference.media_type.trim();
|
||||||
|
if media_type.is_empty()
|
||||||
|
|| media_type.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS
|
||||||
|
|| media_type.chars().any(|character| {
|
||||||
|
!(character.is_ascii_alphanumeric()
|
||||||
|
|| matches!(character, '/' | '+' | '-' | '.' | '_'))
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err("附件媒体类型无效或过长".to_string());
|
||||||
|
}
|
||||||
|
let status = reference.status.trim();
|
||||||
|
if status == "imported" && reference.local_path.trim().is_empty() {
|
||||||
|
return Err("已导入附件缺少项目路径".to_string());
|
||||||
|
}
|
||||||
if !reference.local_path.trim().is_empty() {
|
if !reference.local_path.trim().is_empty() {
|
||||||
sanitize_attachment_local_path(&reference.local_path)
|
sanitize_attachment_local_path(&reference.local_path)
|
||||||
.ok_or_else(|| "附件项目路径无效".to_string())?;
|
.ok_or_else(|| "附件项目路径无效".to_string())?;
|
||||||
}
|
}
|
||||||
if !matches!(reference.status.trim(), "imported" | "failed") {
|
if !matches!(status, "imported" | "failed") {
|
||||||
return Err("附件状态无效".to_string());
|
return Err("附件状态无效".to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,8 +136,9 @@ fn validate_runtime_region_reference(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::content_has_meaningful_input;
|
use super::{content_has_meaningful_input, validate_direct_codex_user_item};
|
||||||
use crate::agent::direct_codex_user_item::model::DirectCodexUserContentPart;
|
use crate::agent::direct_codex_user_item::model::DirectCodexUserContentPart;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
fn input_text(text: &str) -> DirectCodexUserContentPart {
|
fn input_text(text: &str) -> DirectCodexUserContentPart {
|
||||||
DirectCodexUserContentPart::InputText {
|
DirectCodexUserContentPart::InputText {
|
||||||
@@ -148,4 +178,105 @@ mod tests {
|
|||||||
},
|
},
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inline_attachment_count_is_bounded_independently() {
|
||||||
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
|
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
|
||||||
|
.expect("init project");
|
||||||
|
let content = (0..=crate::agent::MAX_DIRECT_CODEX_ATTACHMENTS)
|
||||||
|
.map(|index| {
|
||||||
|
json!({
|
||||||
|
"type": "agc_attachment_reference",
|
||||||
|
"name": format!("attachment-{index}.txt"),
|
||||||
|
"mediaType": "text/plain",
|
||||||
|
"size": 1,
|
||||||
|
"localPath": "",
|
||||||
|
"status": "failed"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let item = serde_json::from_value(json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"content": content,
|
||||||
|
"id": "turn-1:user"
|
||||||
|
}))
|
||||||
|
.expect("deserialize user item");
|
||||||
|
let error = validate_direct_codex_user_item(root.path(), &item)
|
||||||
|
.expect_err("too many inline attachments must be rejected");
|
||||||
|
assert!(error.contains("最多携带"), "{error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn imported_attachment_requires_a_project_path() {
|
||||||
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
|
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
|
||||||
|
.expect("init project");
|
||||||
|
let item = serde_json::from_value(json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"content": [{
|
||||||
|
"type": "agc_attachment_reference",
|
||||||
|
"name": "attachment.txt",
|
||||||
|
"mediaType": "text/plain",
|
||||||
|
"size": 1,
|
||||||
|
"localPath": "",
|
||||||
|
"status": "imported"
|
||||||
|
}],
|
||||||
|
"id": "turn-1:user"
|
||||||
|
}))
|
||||||
|
.expect("deserialize user item");
|
||||||
|
let error = validate_direct_codex_user_item(root.path(), &item)
|
||||||
|
.expect_err("imported attachment without a project path must fail");
|
||||||
|
assert!(error.contains("缺少项目路径"), "{error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_name_and_media_type_are_bounded_and_well_formed() {
|
||||||
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
|
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
|
||||||
|
.expect("init project");
|
||||||
|
let long_name = "a".repeat(crate::agent::MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS + 1);
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
json!({
|
||||||
|
"name": "bad\nname.txt",
|
||||||
|
"mediaType": "text/plain"
|
||||||
|
}),
|
||||||
|
"文件名",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!({
|
||||||
|
"name": "ok.txt",
|
||||||
|
"mediaType": "text/plain\nsecret"
|
||||||
|
}),
|
||||||
|
"媒体类型",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!({
|
||||||
|
"name": long_name,
|
||||||
|
"mediaType": "text/plain"
|
||||||
|
}),
|
||||||
|
"文件名",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (metadata, expected) in cases {
|
||||||
|
let mut value = metadata;
|
||||||
|
value["type"] = json!("agc_attachment_reference");
|
||||||
|
value["size"] = json!(1);
|
||||||
|
value["localPath"] = json!("");
|
||||||
|
value["status"] = json!("failed");
|
||||||
|
let item = serde_json::from_value(json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"content": [value],
|
||||||
|
"id": "turn-1:user"
|
||||||
|
}))
|
||||||
|
.expect("deserialize user item");
|
||||||
|
let error = validate_direct_codex_user_item(root.path(), &item)
|
||||||
|
.expect_err("invalid attachment metadata must fail");
|
||||||
|
assert!(error.contains(expected), "{error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem};
|
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem};
|
||||||
use super::validation::validate_direct_codex_user_item;
|
use super::validation::validate_direct_codex_user_item;
|
||||||
use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path};
|
use crate::agent::{
|
||||||
|
read_manifest_for_project, sanitize_attachment_local_path, sanitize_attachment_media_type,
|
||||||
|
sanitize_attachment_name,
|
||||||
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -101,14 +104,15 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
|
|||||||
summary
|
summary
|
||||||
}
|
}
|
||||||
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
||||||
|
let name = sanitize_attachment_name(&reference.name);
|
||||||
|
let media_type = sanitize_attachment_media_type(&reference.media_type);
|
||||||
|
let local_path = sanitize_attachment_local_path(&reference.local_path);
|
||||||
let mut summary = format!(
|
let mut summary = format!(
|
||||||
"[附件:名称={};类型={};大小={} 字节",
|
"[附件:名称={};类型={};大小={} 字节",
|
||||||
reference.name.trim(),
|
name, media_type, reference.size
|
||||||
reference.media_type.trim(),
|
|
||||||
reference.size
|
|
||||||
);
|
);
|
||||||
if !reference.local_path.trim().is_empty() {
|
if let Some(local_path) = local_path {
|
||||||
summary.push_str(&format!(";项目路径={}", reference.local_path.trim()));
|
summary.push_str(&format!(";项目路径={local_path}"));
|
||||||
}
|
}
|
||||||
summary.push_str(&format!(";状态={}", reference.status.trim()));
|
summary.push_str(&format!(";状态={}", reference.status.trim()));
|
||||||
summary.push(']');
|
summary.push(']');
|
||||||
@@ -212,6 +216,35 @@ mod tests {
|
|||||||
assert!(content[1]["text"].as_str().unwrap().contains("notes.txt"));
|
assert!(content[1]["text"].as_str().unwrap().contains("notes.txt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_metadata_is_sanitized_before_prompt_projection() {
|
||||||
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
|
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
||||||
|
.expect("init project");
|
||||||
|
let item = json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"id": "turn-1:user",
|
||||||
|
"content": [{
|
||||||
|
"type": "agc_attachment_reference",
|
||||||
|
"name": "C:\\tmp\\notes.md",
|
||||||
|
"mediaType": "text/plain",
|
||||||
|
"size": 4,
|
||||||
|
"localPath": "assets\\.\\notes.txt",
|
||||||
|
"status": "imported"
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
let wire = super::direct_codex_user_item_to_wire_input(
|
||||||
|
root.path(),
|
||||||
|
&serde_json::from_value(item).expect("deserialize user item"),
|
||||||
|
)
|
||||||
|
.expect("attachment metadata should project");
|
||||||
|
let text = wire[0]["text"].as_str().expect("wire text");
|
||||||
|
assert!(text.contains("名称=notes.md"), "{text}");
|
||||||
|
assert!(text.contains("类型=text/plain"), "{text}");
|
||||||
|
assert!(text.contains("项目路径=assets/notes.txt"), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn whitespace_only_text_parts_survive_validation() {
|
fn whitespace_only_text_parts_survive_validation() {
|
||||||
let root = tempfile::tempdir().expect("temp project");
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
|
|||||||
@@ -482,6 +482,43 @@ fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result<PathBuf,
|
|||||||
.map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))
|
.map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 校验用户选择的项目创建目录。
|
||||||
|
///
|
||||||
|
/// 目录必须已经存在(原生目录选择器返回的结果),并且先过 AGC 私有路径门禁:门禁失败时
|
||||||
|
/// 这里就拒绝,避免项目被建到 AGC 无法加固、后续无法打开的位置。
|
||||||
|
pub(crate) fn validate_requested_game_project_creation_root(
|
||||||
|
requested: &str,
|
||||||
|
) -> Result<PathBuf, String> {
|
||||||
|
let requested = requested.trim();
|
||||||
|
let root = Path::new(requested);
|
||||||
|
if requested.is_empty() || !root.is_absolute() {
|
||||||
|
return Err("项目创建目录必须是绝对路径".to_string());
|
||||||
|
}
|
||||||
|
if project_path_has_control_chars(root) {
|
||||||
|
return Err("项目创建目录不能包含控制字符".to_string());
|
||||||
|
}
|
||||||
|
let metadata = fs::symlink_metadata(root)
|
||||||
|
.map_err(|error| format!("读取项目创建目录失败:{}: {error}", root.display()))?;
|
||||||
|
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||||
|
return Err("项目创建目录必须是普通文件夹".to_string());
|
||||||
|
}
|
||||||
|
// 用户选择的外部目录仍走显式的项目根准备:保留 user-selected 范围的一次性修复,
|
||||||
|
// 同时不放弃 reparse point / 非普通目录的失败关闭。
|
||||||
|
prepare_game_creator_project_root_for_read(root, true, "项目创建目录")?;
|
||||||
|
Ok(root.to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析本次建项要使用的根目录:没选就用 AGC 管理的默认目录,选了就用用户指定的目录。
|
||||||
|
pub(crate) fn resolve_game_project_creation_root(
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
requested: Option<&str>,
|
||||||
|
) -> Result<PathBuf, String> {
|
||||||
|
match requested.map(str::trim).filter(|value| !value.is_empty()) {
|
||||||
|
Some(requested) => validate_requested_game_project_creation_root(requested),
|
||||||
|
None => automatic_local_game_projects_root(app),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn create_automatic_local_game_project_at(
|
pub(crate) fn create_automatic_local_game_project_at(
|
||||||
projects_root: &Path,
|
projects_root: &Path,
|
||||||
requested_name: Option<&str>,
|
requested_name: Option<&str>,
|
||||||
@@ -551,9 +588,10 @@ pub(crate) fn create_automatic_local_game_project(
|
|||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
planning: Option<bool>,
|
planning: Option<bool>,
|
||||||
|
projects_root: Option<String>,
|
||||||
) -> Result<InitLocalProjectResult, String> {
|
) -> Result<InitLocalProjectResult, String> {
|
||||||
create_automatic_local_game_project_at(
|
create_automatic_local_game_project_at(
|
||||||
&automatic_local_game_projects_root(&app)?,
|
&resolve_game_project_creation_root(&app, projects_root.as_deref())?,
|
||||||
name.as_deref(),
|
name.as_deref(),
|
||||||
planning.unwrap_or(false),
|
planning.unwrap_or(false),
|
||||||
)
|
)
|
||||||
@@ -764,13 +802,30 @@ pub(crate) fn recent_game_creator_run_trace(root: &Path) -> Option<GameCreationA
|
|||||||
serde_json::from_str::<GameCreationAgentRunTrace>(&content).ok()
|
serde_json::from_str::<GameCreationAgentRunTrace>(&content).ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 目录选择器标题:调用方只能给短标题,其余(超长、含控制字符、空白)一律回退默认文案。
|
||||||
|
fn pick_project_directory_title(title: Option<&str>) -> &str {
|
||||||
|
const MAX_TITLE_CHARS: usize = 24;
|
||||||
|
title
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| {
|
||||||
|
!value.is_empty()
|
||||||
|
&& value.chars().count() <= MAX_TITLE_CHARS
|
||||||
|
&& !value.chars().any(char::is_control)
|
||||||
|
})
|
||||||
|
.unwrap_or("选择游戏项目目录")
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) async fn pick_local_project_directory(
|
pub(crate) async fn pick_local_project_directory(
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
initial_path: Option<String>,
|
initial_path: Option<String>,
|
||||||
|
title: Option<String>,
|
||||||
) -> Result<Option<String>, String> {
|
) -> Result<Option<String>, String> {
|
||||||
let (sender, receiver) = tokio::sync::oneshot::channel();
|
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||||||
let mut dialog = app.dialog().file().set_title("选择游戏项目目录");
|
let mut dialog = app
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.set_title(pick_project_directory_title(title.as_deref()));
|
||||||
if let Some(initial_path) = initial_path
|
if let Some(initial_path) = initial_path
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
|
|||||||
@@ -880,12 +880,9 @@ pub(crate) async fn create_automatic_local_game_project_from_template(
|
|||||||
template_version: String,
|
template_version: String,
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
planning: Option<bool>,
|
planning: Option<bool>,
|
||||||
|
projects_root: Option<String>,
|
||||||
) -> Result<InitLocalProjectResult, String> {
|
) -> Result<InitLocalProjectResult, String> {
|
||||||
let projects_root = app
|
let projects_root = crate::resolve_game_project_creation_root(&app, projects_root.as_deref())?;
|
||||||
.path()
|
|
||||||
.app_data_dir()
|
|
||||||
.map(|root| root.join("projects"))
|
|
||||||
.map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))?;
|
|
||||||
let cache_root = template_cache_root(&app)?;
|
let cache_root = template_cache_root(&app)?;
|
||||||
ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?;
|
ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?;
|
||||||
let record =
|
let record =
|
||||||
|
|||||||
@@ -1549,6 +1549,63 @@ fn automatic_local_game_project_allocates_unique_initialized_workspaces() {
|
|||||||
fs::remove_dir_all(projects_root).ok();
|
fs::remove_dir_all(projects_root).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn requested_project_creation_root_accepts_only_an_absolute_regular_directory() {
|
||||||
|
let root = unique_project_path();
|
||||||
|
fs::create_dir_all(&root).expect("create creation-root fixture");
|
||||||
|
let not_a_directory = root.join("not-a-directory.txt");
|
||||||
|
fs::write(¬_a_directory, b"x").expect("write file fixture");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
validate_requested_game_project_creation_root(" ").expect_err("blank root is rejected"),
|
||||||
|
"项目创建目录必须是绝对路径"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
validate_requested_game_project_creation_root("relative/projects")
|
||||||
|
.expect_err("relative root is rejected"),
|
||||||
|
"项目创建目录必须是绝对路径"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
validate_requested_game_project_creation_root(&format!("{}\\pro\nject", root.display()))
|
||||||
|
.expect_err("control character is rejected"),
|
||||||
|
"项目创建目录不能包含控制字符"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
validate_requested_game_project_creation_root(¬_a_directory.to_string_lossy())
|
||||||
|
.expect_err("file root is rejected"),
|
||||||
|
"项目创建目录必须是普通文件夹"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
validate_requested_game_project_creation_root(&format!(" {}\n", root.display()))
|
||||||
|
.expect("trimmed directory root is accepted"),
|
||||||
|
root
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
validate_requested_game_project_creation_root(&root.join("missing").to_string_lossy())
|
||||||
|
.is_err(),
|
||||||
|
"a not-yet-existing creation root must fail instead of being created silently"
|
||||||
|
);
|
||||||
|
|
||||||
|
fs::remove_dir_all(root).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn automatic_local_game_project_creates_inside_the_requested_creation_root() {
|
||||||
|
let projects_root = unique_project_path();
|
||||||
|
fs::create_dir_all(&projects_root).expect("create creation-root fixture");
|
||||||
|
|
||||||
|
let requested = validate_requested_game_project_creation_root(&projects_root.to_string_lossy())
|
||||||
|
.expect("valid creation root");
|
||||||
|
let result = create_automatic_local_game_project_at(&requested, None, false)
|
||||||
|
.expect("create workspace in requested root");
|
||||||
|
|
||||||
|
let project_root = PathBuf::from(&result.project_path);
|
||||||
|
assert_eq!(project_root.parent(), Some(projects_root.as_path()));
|
||||||
|
assert!(project_root.join(".agent/manifest.json").is_file());
|
||||||
|
|
||||||
|
fs::remove_dir_all(projects_root).ok();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn automatic_local_game_project_accepts_only_a_safe_custom_name() {
|
fn automatic_local_game_project_accepts_only_a_safe_custom_name() {
|
||||||
let projects_root = unique_project_path();
|
let projects_root = unique_project_path();
|
||||||
|
|||||||
@@ -17,10 +17,15 @@ import type {
|
|||||||
ProjectAgentRuntimeSummary,
|
ProjectAgentRuntimeSummary,
|
||||||
} from '../../view/project-development';
|
} from '../../view/project-development';
|
||||||
import type { ProjectManifestSnapshotMetadata } from '../../view/project-development/projectResourceLiveUpdateModel';
|
import type { ProjectManifestSnapshotMetadata } from '../../view/project-development/projectResourceLiveUpdateModel';
|
||||||
import { isAbsoluteProjectPath } from '../project-summary/projectSummary';
|
import {
|
||||||
|
isAbsoluteProjectPath,
|
||||||
|
projectPathHasControlCharacter,
|
||||||
|
} from '../project-summary/projectSummary';
|
||||||
|
|
||||||
const RECENT_WORKSPACES_STORAGE_KEY =
|
const RECENT_WORKSPACES_STORAGE_KEY =
|
||||||
'genarrative-ai-game-creator.recent-workspaces.v1';
|
'genarrative-ai-game-creator.recent-workspaces.v1';
|
||||||
|
const PROJECT_CREATION_DIRECTORY_STORAGE_KEY =
|
||||||
|
'genarrative-ai-game-creator.project-creation-directory.v1';
|
||||||
const SUPERVISOR_CHAT_DRAFT_STORAGE_PREFIX =
|
const SUPERVISOR_CHAT_DRAFT_STORAGE_PREFIX =
|
||||||
'genarrative.supervisor-chat.draft';
|
'genarrative.supervisor-chat.draft';
|
||||||
|
|
||||||
@@ -156,6 +161,57 @@ export function removeRecentWorkspace(path: string) {
|
|||||||
return recent;
|
return recent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「项目创建目录」偏好:空串表示沿用 AGC 管理的默认位置(应用数据目录下的 projects)。
|
||||||
|
*
|
||||||
|
* 这里只保存用户意图,不是授权凭据:目录授权来自原生目录选择器,并由 Rust 侧私有路径门禁
|
||||||
|
* 在每次建项时重新复核,所以存储被改坏最坏只是退回默认位置或拿到一次可见的建项失败。
|
||||||
|
*/
|
||||||
|
export function normalizeProjectCreationDirectory(value: string) {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || projectPathHasControlCharacter(trimmed)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const withoutTrailingSeparator = trimmed.replace(/[\\/]+$/, '');
|
||||||
|
// `C:\` 这类盘根只去掉分隔符会变成相对路径 `C:`,必须补回来。
|
||||||
|
return /^[a-zA-Z]:$/.test(withoutTrailingSeparator)
|
||||||
|
? `${withoutTrailingSeparator}\\`
|
||||||
|
: withoutTrailingSeparator;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readProjectCreationDirectory() {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(
|
||||||
|
PROJECT_CREATION_DIRECTORY_STORAGE_KEY,
|
||||||
|
);
|
||||||
|
const parsed: unknown = raw ? JSON.parse(raw) : '';
|
||||||
|
if (typeof parsed !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const directory = normalizeProjectCreationDirectory(parsed);
|
||||||
|
return isAbsoluteProjectPath(directory) ? directory : '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeProjectCreationDirectory(path: string) {
|
||||||
|
const directory = normalizeProjectCreationDirectory(path);
|
||||||
|
try {
|
||||||
|
if (directory) {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
PROJECT_CREATION_DIRECTORY_STORAGE_KEY,
|
||||||
|
JSON.stringify(directory),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
window.localStorage.removeItem(PROJECT_CREATION_DIRECTORY_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// WebView storage can be unavailable in restricted test shells.
|
||||||
|
}
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
|
|
||||||
export function isTransientProjectOpenMessage(
|
export function isTransientProjectOpenMessage(
|
||||||
message: ChatMessage,
|
message: ChatMessage,
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
isAbsoluteProjectPath,
|
isAbsoluteProjectPath,
|
||||||
projectPathHasControlCharacter,
|
projectPathHasControlCharacter,
|
||||||
} from '../project-summary/projectSummary';
|
} from '../project-summary/projectSummary';
|
||||||
|
import { readProjectCreationDirectory } from './model';
|
||||||
import { resolveSessionPreviewOnProjectOpen } from './sessionPreview';
|
import { resolveSessionPreviewOnProjectOpen } from './sessionPreview';
|
||||||
|
|
||||||
/** 首页输入框当前的纯文本(Lexical 编辑器状态 -> 文本);没有输入就返回空串。 */
|
/** 首页输入框当前的纯文本(Lexical 编辑器状态 -> 文本);没有输入就返回空串。 */
|
||||||
@@ -828,6 +829,9 @@ export function useHomeProjectCreation({
|
|||||||
{
|
{
|
||||||
name: suggestedName,
|
name: suggestedName,
|
||||||
planning: startMode === 'planning',
|
planning: startMode === 'planning',
|
||||||
|
// 用户在首页选过「项目创建目录」就用它;没选传 null,由 Rust 侧回落到
|
||||||
|
// AGC 管理的默认位置(应用数据目录下的 projects)。
|
||||||
|
projectsRoot: readProjectCreationDirectory() || null,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
createdProjectPath = result.projectPath;
|
createdProjectPath = result.projectPath;
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { resolveTauriInvoke } from '../../app/tauri';
|
||||||
|
import {
|
||||||
|
readProjectCreationDirectory,
|
||||||
|
writeProjectCreationDirectory,
|
||||||
|
} from './model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「项目创建目录」用户偏好。
|
||||||
|
*
|
||||||
|
* 默认沿用 AGC 管理的应用数据目录(`<app_data>/projects`);用户改选时必须走原生目录
|
||||||
|
* 选择器,因为只有它构成"用户显式选择"边界:选择结果当场按用户选择范围加固,后续建项
|
||||||
|
* 再由 Rust 侧私有路径门禁复核一次。
|
||||||
|
*/
|
||||||
|
export function useProjectCreationDirectory() {
|
||||||
|
const [projectCreationDirectory, setProjectCreationDirectory] = useState(
|
||||||
|
readProjectCreationDirectory,
|
||||||
|
);
|
||||||
|
const [projectCreationDirectoryBusy, setProjectCreationDirectoryBusy] =
|
||||||
|
useState(false);
|
||||||
|
const [projectCreationDirectoryStatus, setProjectCreationDirectoryStatus] =
|
||||||
|
useState('');
|
||||||
|
const pickInFlightRef = useRef(false);
|
||||||
|
|
||||||
|
const pickProjectCreationDirectory = useCallback(async () => {
|
||||||
|
if (pickInFlightRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const invoke = resolveTauriInvoke();
|
||||||
|
if (!invoke) {
|
||||||
|
setProjectCreationDirectoryStatus('需要在陶泥儿客户端内运行');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pickInFlightRef.current = true;
|
||||||
|
setProjectCreationDirectoryBusy(true);
|
||||||
|
setProjectCreationDirectoryStatus('正在选择项目创建目录');
|
||||||
|
try {
|
||||||
|
const selected = await invoke<string | null>(
|
||||||
|
'pick_local_project_directory',
|
||||||
|
{
|
||||||
|
title: '选择项目创建目录',
|
||||||
|
...(projectCreationDirectory
|
||||||
|
? { initialPath: projectCreationDirectory }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!selected) {
|
||||||
|
setProjectCreationDirectoryStatus('已取消');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setProjectCreationDirectory(writeProjectCreationDirectory(selected));
|
||||||
|
setProjectCreationDirectoryStatus('已更新项目创建目录');
|
||||||
|
} catch (error) {
|
||||||
|
setProjectCreationDirectoryStatus(
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
pickInFlightRef.current = false;
|
||||||
|
setProjectCreationDirectoryBusy(false);
|
||||||
|
}
|
||||||
|
}, [projectCreationDirectory]);
|
||||||
|
|
||||||
|
const resetProjectCreationDirectory = useCallback(() => {
|
||||||
|
writeProjectCreationDirectory('');
|
||||||
|
setProjectCreationDirectory('');
|
||||||
|
setProjectCreationDirectoryStatus('已恢复默认位置');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectCreationDirectory,
|
||||||
|
projectCreationDirectoryBusy,
|
||||||
|
projectCreationDirectoryStatus,
|
||||||
|
pickProjectCreationDirectory,
|
||||||
|
resetProjectCreationDirectory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectCreationDirectoryController = ReturnType<
|
||||||
|
typeof useProjectCreationDirectory
|
||||||
|
>;
|
||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
|
FolderOpen,
|
||||||
Info,
|
Info,
|
||||||
LoaderCircle,
|
LoaderCircle,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -44,6 +45,7 @@ import {
|
|||||||
startAgcPlugin,
|
startAgcPlugin,
|
||||||
stopAgcPlugin,
|
stopAgcPlugin,
|
||||||
} from '../../services/pluginHost';
|
} from '../../services/pluginHost';
|
||||||
|
import { useProjectCreationDirectory } from '../app-shell/useProjectCreationDirectory';
|
||||||
import { PluginPanelHost } from '../plugins/PluginPanelHost';
|
import { PluginPanelHost } from '../plugins/PluginPanelHost';
|
||||||
import { reasoningEffortLabel } from '../project-workspace/composerReasoningEffort';
|
import { reasoningEffortLabel } from '../project-workspace/composerReasoningEffort';
|
||||||
import { CustomLlmSettings } from './CustomLlmSettings';
|
import { CustomLlmSettings } from './CustomLlmSettings';
|
||||||
@@ -77,6 +79,7 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = {
|
|||||||
|
|
||||||
type RuntimeSettingsSection =
|
type RuntimeSettingsSection =
|
||||||
| 'general'
|
| 'general'
|
||||||
|
| 'workspace'
|
||||||
| 'agents'
|
| 'agents'
|
||||||
| 'extensions'
|
| 'extensions'
|
||||||
| 'advanced'
|
| 'advanced'
|
||||||
@@ -96,6 +99,12 @@ const runtimeSettingsSections = [
|
|||||||
description: '运行方式与输出偏好',
|
description: '运行方式与输出偏好',
|
||||||
icon: Settings2,
|
icon: Settings2,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'workspace',
|
||||||
|
label: '工作区',
|
||||||
|
description: '项目创建目录',
|
||||||
|
icon: FolderOpen,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'agents',
|
id: 'agents',
|
||||||
label: 'Agent 分工',
|
label: 'Agent 分工',
|
||||||
@@ -224,6 +233,11 @@ export function RuntimeConfigDialog({
|
|||||||
const [runtimeConfigBusy, setRuntimeConfigBusy] = useState(false);
|
const [runtimeConfigBusy, setRuntimeConfigBusy] = useState(false);
|
||||||
const [activeSection, setActiveSection] =
|
const [activeSection, setActiveSection] =
|
||||||
useState<RuntimeSettingsSection>('general');
|
useState<RuntimeSettingsSection>('general');
|
||||||
|
/**
|
||||||
|
* 「项目创建目录」是客户端本地偏好(localStorage),不随下面的配置文件一起保存:
|
||||||
|
* 选择目录当场生效,做游戏 / 做方案与模板建项下一次建项就落在该目录下。
|
||||||
|
*/
|
||||||
|
const projectCreationDirectory = useProjectCreationDirectory();
|
||||||
const [clientExtensions, setClientExtensions] = useState<
|
const [clientExtensions, setClientExtensions] = useState<
|
||||||
ClientExtensionItem[]
|
ClientExtensionItem[]
|
||||||
>([]);
|
>([]);
|
||||||
@@ -875,6 +889,53 @@ export function RuntimeConfigDialog({
|
|||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
{activeSection === 'workspace' ? (
|
||||||
|
<div className="runtime-settings-readonly-field">
|
||||||
|
<span>项目创建目录</span>
|
||||||
|
<strong
|
||||||
|
title={
|
||||||
|
projectCreationDirectory.projectCreationDirectory ||
|
||||||
|
undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{projectCreationDirectory.projectCreationDirectory ||
|
||||||
|
'默认位置'}
|
||||||
|
</strong>
|
||||||
|
<div className="runtime-settings-field-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={
|
||||||
|
projectCreationDirectory.projectCreationDirectoryBusy
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
void projectCreationDirectory.pickProjectCreationDirectory()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FolderOpen size={14} aria-hidden="true" />
|
||||||
|
选择目录
|
||||||
|
</button>
|
||||||
|
{projectCreationDirectory.projectCreationDirectory ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={
|
||||||
|
projectCreationDirectory.projectCreationDirectoryBusy
|
||||||
|
}
|
||||||
|
onClick={
|
||||||
|
projectCreationDirectory.resetProjectCreationDirectory
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} aria-hidden="true" />
|
||||||
|
恢复默认位置
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{projectCreationDirectory.projectCreationDirectoryStatus ? (
|
||||||
|
<small>
|
||||||
|
{projectCreationDirectory.projectCreationDirectoryStatus}
|
||||||
|
</small>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{activeSection === 'advanced' &&
|
{activeSection === 'advanced' &&
|
||||||
runtimeConfigDraft.agentMode !== 'codex_cli' ? (
|
runtimeConfigDraft.agentMode !== 'codex_cli' ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
|
|
||||||
import { resolveTauriInvoke } from '../../app/tauri';
|
import { resolveTauriInvoke } from '../../app/tauri';
|
||||||
import type { InitLocalProjectResult } from '../../app/types';
|
import type { InitLocalProjectResult } from '../../app/types';
|
||||||
|
import { readProjectCreationDirectory } from '../app-shell/model';
|
||||||
import {
|
import {
|
||||||
collectGameTemplateRuntimes,
|
collectGameTemplateRuntimes,
|
||||||
collectGameTemplateTags,
|
collectGameTemplateTags,
|
||||||
@@ -153,6 +154,8 @@ export function useTemplateLibrary({
|
|||||||
templateVersion: template.templateVersion,
|
templateVersion: template.templateVersion,
|
||||||
name: null,
|
name: null,
|
||||||
planning: false,
|
planning: false,
|
||||||
|
// 与首页自动建项共用一个「项目创建目录」偏好;没选时由 Rust 侧回落到默认位置。
|
||||||
|
projectsRoot: readProjectCreationDirectory() || null,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await onProjectCreated(result);
|
await onProjectCreated(result);
|
||||||
|
|||||||
@@ -4247,7 +4247,15 @@ h2 {
|
|||||||
gap: 7px;
|
gap: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.runtime-settings-field-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 7px;
|
||||||
|
margin-top: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
.runtime-settings-section-actions button,
|
.runtime-settings-section-actions button,
|
||||||
|
.runtime-settings-field-actions button,
|
||||||
.runtime-settings-extension-actions button {
|
.runtime-settings-extension-actions button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -4264,12 +4272,14 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.runtime-settings-section-actions button:hover,
|
.runtime-settings-section-actions button:hover,
|
||||||
|
.runtime-settings-field-actions button:hover,
|
||||||
.runtime-settings-extension-actions button:hover {
|
.runtime-settings-extension-actions button:hover {
|
||||||
border-color: var(--platform-surface-hover-border);
|
border-color: var(--platform-surface-hover-border);
|
||||||
background: var(--platform-button-ghost-fill);
|
background: var(--platform-button-ghost-fill);
|
||||||
}
|
}
|
||||||
|
|
||||||
.runtime-settings-section-actions button:disabled,
|
.runtime-settings-section-actions button:disabled,
|
||||||
|
.runtime-settings-field-actions button:disabled,
|
||||||
.runtime-settings-extension-actions button:disabled {
|
.runtime-settings-extension-actions button:disabled {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
|
|||||||
@@ -1631,6 +1631,7 @@ export function registerHomeProjectCreationTests() {
|
|||||||
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
|
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
|
||||||
name: null,
|
name: null,
|
||||||
planning: false,
|
planning: false,
|
||||||
|
projectsRoot: null,
|
||||||
});
|
});
|
||||||
expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', {
|
expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', {
|
||||||
projectPath: automaticProjectPath,
|
projectPath: automaticProjectPath,
|
||||||
@@ -1736,6 +1737,7 @@ export function registerHomeProjectCreationTests() {
|
|||||||
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
|
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
|
||||||
name: '角色参考游戏',
|
name: '角色参考游戏',
|
||||||
planning: false,
|
planning: false,
|
||||||
|
projectsRoot: null,
|
||||||
});
|
});
|
||||||
expect(invoke).toHaveBeenCalledWith('upload_local_asset', {
|
expect(invoke).toHaveBeenCalledWith('upload_local_asset', {
|
||||||
projectPath: automaticProjectPath,
|
projectPath: automaticProjectPath,
|
||||||
@@ -3543,4 +3545,102 @@ export function registerRecentProjectsTests() {
|
|||||||
);
|
);
|
||||||
expect(window.localStorage.length).toBe(0);
|
expect(window.localStorage.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('creates the automatic workspace inside the project creation directory picked in settings', async () => {
|
||||||
|
const automaticProjectPath =
|
||||||
|
'F:\\Projects\\我的游戏\\gameagent-chosen-directory';
|
||||||
|
const creationDirectory = 'F:\\Projects\\我的游戏';
|
||||||
|
const manifest = createGameCreationAppManifest(
|
||||||
|
'home-creation-directory-project',
|
||||||
|
'自选目录项目',
|
||||||
|
);
|
||||||
|
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||||||
|
projectPath: automaticProjectPath,
|
||||||
|
initialSessionExists: false,
|
||||||
|
});
|
||||||
|
const invoke = vi.fn(
|
||||||
|
async (command: string, args?: Record<string, unknown>) => {
|
||||||
|
if (command === 'read_game_creator_app_config') {
|
||||||
|
return {
|
||||||
|
path: 'C:\\Users\\tester\\AppData\\Roaming\\genarrative\\config.json',
|
||||||
|
config: {
|
||||||
|
agentMode: 'codex_app_server',
|
||||||
|
llm: {
|
||||||
|
apiKey: '',
|
||||||
|
baseUrl: 'https://llm.example.test/v1',
|
||||||
|
model: 'gpt-creation-directory',
|
||||||
|
apiKind: 'openai_responses',
|
||||||
|
reasoningEffort: 'high',
|
||||||
|
stream: true,
|
||||||
|
webSearchEnabled: false,
|
||||||
|
contextWindowTokens: 128000,
|
||||||
|
autoCompactTokenLimit: 64000,
|
||||||
|
toolOutputTokenLimit: 12000,
|
||||||
|
requestTimeoutMs: 180000,
|
||||||
|
maxRetries: 2,
|
||||||
|
retryBackoffMs: 500,
|
||||||
|
},
|
||||||
|
agentLlm: {},
|
||||||
|
editorApi: { baseUrl: 'http://127.0.0.1:8082', apiKey: '' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (command === 'pick_local_project_directory') {
|
||||||
|
return creationDirectory;
|
||||||
|
}
|
||||||
|
if (command === 'create_automatic_local_game_project') {
|
||||||
|
return {
|
||||||
|
projectPath: automaticProjectPath,
|
||||||
|
manifestPath: `${automaticProjectPath}\\.agent\\manifest.json`,
|
||||||
|
manifest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (command === 'chat_with_game_creator_direct_codex') {
|
||||||
|
return '收到,开始搭建。';
|
||||||
|
}
|
||||||
|
return supervisorHarness.invoke(command, args);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
window.__TAURI__ = {
|
||||||
|
core: { invoke },
|
||||||
|
event: { listen: supervisorHarness.listen },
|
||||||
|
};
|
||||||
|
renderLauncherAt('/?launcher', 'home', true);
|
||||||
|
|
||||||
|
// 设置 → 工作区:默认位置就是不选目录,仍然落在 AGC 管理的应用数据目录。
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '配置' }));
|
||||||
|
const settings = await screen.findByRole('dialog', { name: '运行时配置' });
|
||||||
|
fireEvent.click(within(settings).getByRole('button', { name: /工作区/ }));
|
||||||
|
expect(within(settings).getByText('默认位置')).not.toBeNull();
|
||||||
|
fireEvent.click(within(settings).getByRole('button', { name: '选择目录' }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(within(settings).getByText(creationDirectory)).not.toBeNull();
|
||||||
|
});
|
||||||
|
expect(invoke).toHaveBeenCalledWith('pick_local_project_directory', {
|
||||||
|
title: '选择项目创建目录',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
window.localStorage.getItem(
|
||||||
|
'genarrative-ai-game-creator.project-creation-directory.v1',
|
||||||
|
),
|
||||||
|
).toBe(JSON.stringify(creationDirectory));
|
||||||
|
fireEvent.click(
|
||||||
|
within(settings).getByRole('button', { name: '关闭 Agent 设置' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const promptInput = screen.getByLabelText('创作想法');
|
||||||
|
nativeClipboardMock.text = '做一个花园经营游戏';
|
||||||
|
fireEvent.paste(promptInput);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(promptInput.textContent).toContain('做一个花园经营游戏');
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '开启创作' }));
|
||||||
|
|
||||||
|
expect(await screen.findByLabelText('项目开发工作台')).not.toBeNull();
|
||||||
|
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
|
||||||
|
name: null,
|
||||||
|
planning: false,
|
||||||
|
projectsRoot: creationDirectory,
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -360,6 +360,70 @@ export function registerRuntimeSettingsTests() {
|
|||||||
expect(screen.getByText('桌面客户端')).not.toBeNull();
|
expect(screen.getByText('桌面客户端')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the project creation directory in the workspace settings section', async () => {
|
||||||
|
const creationDirectory = 'F:\\Projects\\陶泥儿游戏';
|
||||||
|
const storageKey =
|
||||||
|
'genarrative-ai-game-creator.project-creation-directory.v1';
|
||||||
|
const invoke = vi.fn(async (command: string) => {
|
||||||
|
if (command === 'read_game_creator_app_config') {
|
||||||
|
return {
|
||||||
|
path: '/home/test/AppData/game-creator.config.json',
|
||||||
|
config: {
|
||||||
|
agentMode: 'codex_app_server',
|
||||||
|
llm: {
|
||||||
|
apiKey: '',
|
||||||
|
baseUrl: 'https://llm.example.test/v1',
|
||||||
|
model: 'gpt-workspace',
|
||||||
|
apiKind: 'openai_responses',
|
||||||
|
reasoningEffort: 'high',
|
||||||
|
stream: true,
|
||||||
|
webSearchEnabled: false,
|
||||||
|
contextWindowTokens: 128000,
|
||||||
|
autoCompactTokenLimit: 64000,
|
||||||
|
toolOutputTokenLimit: 12000,
|
||||||
|
requestTimeoutMs: 180000,
|
||||||
|
maxRetries: 2,
|
||||||
|
retryBackoffMs: 500,
|
||||||
|
},
|
||||||
|
agentLlm: {},
|
||||||
|
editorApi: { baseUrl: 'http://127.0.0.1:8082', apiKey: '' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (command === 'pick_local_project_directory') {
|
||||||
|
return creationDirectory;
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected invoke ${command}`);
|
||||||
|
});
|
||||||
|
window.__TAURI__ = { core: { invoke } };
|
||||||
|
renderLauncherAt('/?launcher');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '配置' }));
|
||||||
|
const dialog = await screen.findByRole('dialog', { name: '运行时配置' });
|
||||||
|
fireEvent.click(within(dialog).getByRole('button', { name: /工作区/ }));
|
||||||
|
|
||||||
|
// 不选目录时是默认位置,且本地不写任何偏好。
|
||||||
|
expect(within(dialog).getByText('默认位置')).not.toBeNull();
|
||||||
|
expect(window.localStorage.getItem(storageKey)).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(within(dialog).getByRole('button', { name: '选择目录' }));
|
||||||
|
expect(await within(dialog).findByText(creationDirectory)).not.toBeNull();
|
||||||
|
expect(invoke).toHaveBeenCalledWith('pick_local_project_directory', {
|
||||||
|
title: '选择项目创建目录',
|
||||||
|
});
|
||||||
|
expect(window.localStorage.getItem(storageKey)).toBe(
|
||||||
|
JSON.stringify(creationDirectory),
|
||||||
|
);
|
||||||
|
expect(within(dialog).getByText('已更新项目创建目录')).not.toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
within(dialog).getByRole('button', { name: '恢复默认位置' }),
|
||||||
|
);
|
||||||
|
expect(within(dialog).getByText('默认位置')).not.toBeNull();
|
||||||
|
expect(window.localStorage.getItem(storageKey)).toBeNull();
|
||||||
|
expect(within(dialog).getByText('已恢复默认位置')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('locks the Agent mode and official LLM route while dropping legacy credentials', async () => {
|
it('locks the Agent mode and official LLM route while dropping legacy credentials', async () => {
|
||||||
const invoke = vi.fn(
|
const invoke = vi.fn(
|
||||||
async (command: string, args?: Record<string, unknown>) => {
|
async (command: string, args?: Record<string, unknown>) => {
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/** @vitest-environment jsdom */
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizeProjectCreationDirectory,
|
||||||
|
readProjectCreationDirectory,
|
||||||
|
writeProjectCreationDirectory,
|
||||||
|
} from '../src/features/app-shell/model';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'genarrative-ai-game-creator.project-creation-directory.v1';
|
||||||
|
|
||||||
|
describe('项目创建目录偏好', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('去掉首尾空白与多余分隔符,并保留盘根', () => {
|
||||||
|
expect(normalizeProjectCreationDirectory(' ')).toBe('');
|
||||||
|
expect(normalizeProjectCreationDirectory(' F:\\Projects\\游戏\\ ')).toBe(
|
||||||
|
'F:\\Projects\\游戏',
|
||||||
|
);
|
||||||
|
expect(normalizeProjectCreationDirectory('F:/Projects/游戏/')).toBe(
|
||||||
|
'F:/Projects/游戏',
|
||||||
|
);
|
||||||
|
expect(normalizeProjectCreationDirectory('C:\\')).toBe('C:\\');
|
||||||
|
// 首尾空白按 trim 处理;目录中间的控制字符必须整条拒绝。
|
||||||
|
expect(normalizeProjectCreationDirectory('F:\\游戏\n')).toBe('F:\\游戏');
|
||||||
|
expect(normalizeProjectCreationDirectory('F:\\游\n戏')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('保存选中的目录,并在恢复默认位置时清空', () => {
|
||||||
|
expect(readProjectCreationDirectory()).toBe('');
|
||||||
|
|
||||||
|
expect(writeProjectCreationDirectory(' F:\\Projects\\游戏 ')).toBe(
|
||||||
|
'F:\\Projects\\游戏',
|
||||||
|
);
|
||||||
|
expect(readProjectCreationDirectory()).toBe('F:\\Projects\\游戏');
|
||||||
|
|
||||||
|
expect(writeProjectCreationDirectory(' ')).toBe('');
|
||||||
|
expect(readProjectCreationDirectory()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('忽略存储里不可用的值,退回默认位置', () => {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify('relative/games'));
|
||||||
|
expect(readProjectCreationDirectory()).toBe('');
|
||||||
|
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(42));
|
||||||
|
expect(readProjectCreationDirectory()).toBe('');
|
||||||
|
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, '{not json');
|
||||||
|
expect(readProjectCreationDirectory()).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# AGC 项目创建目录可选实施计划
|
||||||
|
|
||||||
|
Version: 1.0
|
||||||
|
Status: active
|
||||||
|
Date: 2026-09-17
|
||||||
|
Related Spec: `docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md`、`docs/【技术方案】AGC异步操作可恢复闭环-2026-09-14.md`
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
让用户在 AGC 里能自己选项目创建目录:首页「做游戏 / 做方案」自动建项与模板库「使用模板」建项都落在用户选定的目录下;不选时保持 AGC 管理的默认位置(`<app_data>/projects`),行为与现状一致。
|
||||||
|
|
||||||
|
## 交接结果
|
||||||
|
|
||||||
|
- `create_automatic_local_game_project`、`create_automatic_local_game_project_from_template` 新增可选参数 `projectsRoot`;为空时由 Rust 侧回落到默认目录。
|
||||||
|
- `pick_local_project_directory` 新增可选参数 `title`(只接受短标题,其余回退「选择游戏项目目录」)。
|
||||||
|
- 新增用户偏好「项目创建目录」:`localStorage` 键 `genarrative-ai-game-creator.project-creation-directory.v1`。
|
||||||
|
- 入口在客户端设置里:侧边栏「配置」→ 设置分类「工作区」;`RuntimeConfigDialog` 内用 `useProjectCreationDirectory` 展示与改选目录,不经过首页或模板库页头。
|
||||||
|
- 首页「做游戏 / 做方案」与模板库「使用模板」建项时读取同一份偏好,用户不需要在建项前再选一次。
|
||||||
|
|
||||||
|
## 行为契约
|
||||||
|
|
||||||
|
- 允许的来源只有一个:本机原生目录选择器(`pick_local_project_directory`)。它返回的目录已在选择时按 user-selected 范围做过一次加固,构成「用户显式选择」边界。
|
||||||
|
- Rust 侧 `validate_requested_game_project_creation_root` 对传入目录要求:非空、绝对路径、无控制字符、已存在的普通目录(符号链接 / Windows reparse point / 普通文件一律拒绝),并通过 `prepare_game_creator_project_root_for_read`(user-selected 范围的一次性修复)。目录不存在时不代为创建,直接失败。
|
||||||
|
- 建出的项目目录仍在所选目录下按 `gameagent-<8位短ID>` 命名,项目名、`.agent` 初始化、首轮投递与既有自动建项完全一致。
|
||||||
|
- 偏好只保存「用户意图」,不是授权凭据:存储被外部改动最坏只是回退默认位置或一次可见的建项失败,不会跳过 Rust 门禁。
|
||||||
|
- 该偏好是客户端本地设置,不写入 `read_game_creator_app_config` / 保存设置的那份运行时配置:在「工作区」里选择目录当场生效,与「保存设置」按钮无关。
|
||||||
|
- 「恢复默认位置」清空偏好即回到应用数据目录;既有项目不迁移。
|
||||||
|
|
||||||
|
## 步骤
|
||||||
|
|
||||||
|
1. **Rust 建项入口**
|
||||||
|
- `commands.rs`:新增 `validate_requested_game_project_creation_root` / `resolve_game_project_creation_root`,`create_automatic_local_game_project` 接受 `projects_root`;`pick_local_project_directory` 接受 `title`。
|
||||||
|
- `template_library.rs`:模板建项复用同一解析函数。
|
||||||
|
- 交付:两条定向单测(校验矩阵、在指定创建目录下建项)。
|
||||||
|
- 验收:`cargo test --bin genarrative-ai-game-creator-shell creation_root` 全绿。
|
||||||
|
|
||||||
|
2. **前端偏好与入口**
|
||||||
|
- `features/app-shell/model.ts`:`normalizeProjectCreationDirectory` / `readProjectCreationDirectory` / `writeProjectCreationDirectory` / `projectCreationDirectoryLabel`。
|
||||||
|
- `useProjectCreationDirectory`:选择目录、恢复默认、状态文案(失败在设置页字段内可见)。
|
||||||
|
- `RuntimeConfigDialog` 新增「工作区」设置分类,承载「项目创建目录」字段与「选择目录 / 恢复默认位置」动作。
|
||||||
|
- `useHomeProjectCreation` 与 `useTemplateLibrary` 在建项时带上 `projectsRoot`。
|
||||||
|
- 交付:偏好模型 4 项单测、设置页 1 项交互测试、appSurface 1 项「设置里选目录后建项」端到端场景。
|
||||||
|
- 验收:`npx vitest run apps/ai-game-creator-shell/tests/projectCreationDirectory.test.ts apps/ai-game-creator-shell/tests/appSurface.test.ts` 全绿。
|
||||||
|
|
||||||
|
3. **文档与共享记忆**
|
||||||
|
- 本实施计划;模板库技术方案的命令表补 `projectsRoot`;`decision-log.md` 记录偏好键与命令参数;`pitfalls.md` 说明用户自选目录与 AGC 管理目录的关系。
|
||||||
|
- 验收:`node scripts/check-doc-index.mjs` 通过。
|
||||||
|
|
||||||
|
## 验证命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell
|
||||||
|
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell creation_root
|
||||||
|
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell template_library
|
||||||
|
cd apps/ai-game-creator-shell && npx tsc -p tsconfig.json --noEmit
|
||||||
|
npx vitest run apps/ai-game-creator-shell/tests
|
||||||
|
npm run check:encoding
|
||||||
|
node scripts/check-doc-index.mjs
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
手动验收(客户端内):侧边栏「配置」→「工作区」→「选择目录」选 `F:\Projects\我的游戏`,回首页建项后项目目录应为 `F:\Projects\我的游戏\gameagent-<8位>`;模板库「使用模板」建项同样落在该目录;点「恢复默认位置」后再建项回到应用数据目录。
|
||||||
|
|
||||||
|
## 风险与回退
|
||||||
|
|
||||||
|
- **自选目录无法加固**:`Documents` 等带受保护继承 ACL 的位置可能加固失败。失败发生在选择器或建项前置校验阶段,报错可见且不会留下半成品项目;用户改选其它目录或用默认位置即可。
|
||||||
|
- **偏好漂移**:偏好只是提示值,每次建项都会重新校验;存储被改坏不会绕过门禁。
|
||||||
|
- **回退**:删掉偏好键(或点「恢复默认位置」)即回到默认目录;需要彻底移除该能力时,去掉两个命令的可选参数与设置页字段即可,Rust 默认路径逻辑不变。
|
||||||
@@ -8951,3 +8951,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
|||||||
- 上线依赖(本次未完成):`*.preview.genarrative.world` 通配证书(Let's Encrypt 通配只能走 DNS-01,域名在 DNSPod,certbot 无官方插件,需要 DNSPod API Token 配合 acme.sh)、station 侧按 Host 分发到 `84xx` 端口、dev 通配 vhost 与隧道;控制面本体需在 station 用 `scripts/deploy/preview-deployer-install.sh` 重建发布。
|
- 上线依赖(本次未完成):`*.preview.genarrative.world` 通配证书(Let's Encrypt 通配只能走 DNS-01,域名在 DNSPod,certbot 无官方插件,需要 DNSPod API Token 配合 acme.sh)、station 侧按 Host 分发到 `84xx` 端口、dev 通配 vhost 与隧道;控制面本体需在 station 用 `scripts/deploy/preview-deployer-install.sh` 重建发布。
|
||||||
- 验证:`cargo test -p preview-deployer-server`(13 项)、`apps/preview-deployer-web` vitest(13 项,含新增公网地址用例)、`npx tsc --noEmit`、`npm run preview-deployer:web:build`(`PREVIEW_DEPLOYER_WEB_BASE=/build/`)、`npm run check:preview-deployer`、`npm run check:encoding`、`git diff --check` 全部通过。
|
- 验证:`cargo test -p preview-deployer-server`(13 项)、`apps/preview-deployer-web` vitest(13 项,含新增公网地址用例)、`npx tsc --noEmit`、`npm run preview-deployer:web:build`(`PREVIEW_DEPLOYER_WEB_BASE=/build/`)、`npm run check:preview-deployer`、`npm run check:encoding`、`git diff --check` 全部通过。
|
||||||
- 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)、[Jenkins容器预览部署控制面技术方案](../../technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md)。
|
- 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)、[Jenkins容器预览部署控制面技术方案](../../technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md)。
|
||||||
|
|
||||||
|
## 2026-09-17 AGC 自动建项支持用户自选项目创建目录(入口设在设置「工作区」)
|
||||||
|
|
||||||
|
- 背景:首页「做游戏 / 做方案」与模板库「使用模板」的自动建项固定落在 `<app_data>/projects`,用户无法把游戏放到自己的工作盘或工程目录;同时该路径不能随意放开(受管私有目录门禁与 Documents 继承 ACL 的既有约束见 `pitfalls.md`)。
|
||||||
|
- 决策:新增可选参数 `projectsRoot`(`create_automatic_local_game_project`、`create_automatic_local_game_project_from_template`),为空时由 Rust 回落到 `<app_data>/projects`。可选值只接受本机原生目录选择器返回的目录:`validate_requested_game_project_creation_root` 要求非空绝对路径、无控制字符、已存在的普通目录(拒绝链接/reparse point),并通过 `prepare_game_creator_project_root_for_read` 的 user-selected 范围校验与一次性修复;目录不存在不代为创建。
|
||||||
|
- 决策:客户端偏好「项目创建目录」存 `localStorage` 键 `genarrative-ai-game-creator.project-creation-directory.v1`;入口只在设置里(侧边栏「配置」→ 分类「工作区」),首页输入行与模板库页头不再各挂一个入口。「恢复默认位置」即清空偏好。偏好只表达用户意图,不是授权凭据:每次建项都重新过 Rust 门禁,存储被改坏最坏是回退默认位置或一次可见失败。既有项目不迁移。
|
||||||
|
- 决策:该偏好属于客户端本地设置,不并入 `read_game_creator_app_config` 那份运行时配置:在「工作区」里选择目录当场生效,不受「保存设置」按钮影响;首页与模板库建项时各自读取同一份偏好。
|
||||||
|
- 决策:`pick_local_project_directory` 增加可选 `title`(限 24 字符、无控制字符,其余回退默认标题),使「选择项目创建目录」不再冒用「选择游戏项目目录」文案。
|
||||||
|
- 关联规范:`docs/project-memory/plans/【实施计划】AGC项目创建目录可选-2026-09-17.md`、`docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md`。
|
||||||
|
- 验证:`cargo test --bin genarrative-ai-game-creator-shell creation_root`(2 项)、模板库定向单测 14 项、偏好模型 3 项、`appSurface` 472 项(含设置页「工作区」选择/恢复目录与「设置里选目录后建项带 `projectsRoot`」两条场景)、`tsc`、`check:encoding` 与 `check-doc-index` 通过。
|
||||||
|
|||||||
@@ -248,6 +248,15 @@ AGC 的 Cocos 能力来自随客户端分发的 `agc-cocos-editor` 内置插件
|
|||||||
首页命名回合成功后创建命令失败且不会留下项目目录。用户通过目录选择器创建的
|
首页命名回合成功后创建命令失败且不会留下项目目录。用户通过目录选择器创建的
|
||||||
项目仍走 user-selected 权限范围。
|
项目仍走 user-selected 权限范围。
|
||||||
|
|
||||||
|
- 2026-09-17 补充:首页与模板建项支持用户自选 `projectsRoot`(见
|
||||||
|
`docs/project-memory/plans/【实施计划】AGC项目创建目录可选-2026-09-17.md`)。
|
||||||
|
自选目录只有一条合法来源——本机原生目录选择器返回的目录,并且必须在 Rust 侧
|
||||||
|
通过 `validate_requested_game_project_creation_root`
|
||||||
|
(绝对路径 / 无控制字符 / 已存在普通目录 / 非链接与 reparse point /
|
||||||
|
`prepare_game_creator_project_root_for_read`)。默认值仍必须是
|
||||||
|
`app_data_dir()/projects`:不要因为"用户能自选"就把默认值改成 Documents 或
|
||||||
|
其它用户目录,也不要在目录不存在时替用户创建。
|
||||||
|
|
||||||
## 2026-09-12 Cocos 项目识别不等于编辑器桥就绪
|
## 2026-09-12 Cocos 项目识别不等于编辑器桥就绪
|
||||||
|
|
||||||
- 现象:能发现正确 Creator PID、Agent 也有 `agc_cocos_execute`,但首次执行报 pipe 不存在;仅登记目标的 `connect` 会误报成功。
|
- 现象:能发现正确 Creator PID、Agent 也有 `agc_cocos_execute`,但首次执行报 pipe 不存在;仅登记目标的 `connect` 会误报成功。
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ templates/
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `fetch_game_template_library` | 读 `templates/index.json`(≤4 MiB),校验后缓存到 `<app_data>/templates/index.json`;网络失败时回退本机缓存并在 `source` 标 `cache` |
|
| `fetch_game_template_library` | 读 `templates/index.json`(≤4 MiB),校验后缓存到 `<app_data>/templates/index.json`;网络失败时回退本机缓存并在 `source` 标 `cache` |
|
||||||
| `download_game_template` | 取清单里对应条目,流式下载 zip(≤512 MiB),校验字节数与 SHA-256,解压到 `<app_data>/templates/installed/<id>/<version>/`,最后写 `installed.json` 作为安装完成的唯一标记 |
|
| `download_game_template` | 取清单里对应条目,流式下载 zip(≤512 MiB),校验字节数与 SHA-256,解压到 `<app_data>/templates/installed/<id>/<version>/`,最后写 `installed.json` 作为安装完成的唯一标记 |
|
||||||
| `create_automatic_local_game_project_from_template` | 需要时先安装模板,然后在 `<app_data>/projects/` 下按既有自动工作区规则建目录:先复制模板文件,再走 `init_local_game_project_at` 补 `.agent` 清单与标准目录 |
|
| `create_automatic_local_game_project_from_template` | 需要时先安装模板,然后在 `<app_data>/projects/` 下按既有自动工作区规则建目录:先复制模板文件,再走 `init_local_game_project_at` 补 `.agent` 清单与标准目录。根目录可用 `projectsRoot` 覆盖(必须来自本机目录选择器并通过私有路径门禁),未指定时仍是 `<app_data>/projects/`;见 [`【实施计划】AGC项目创建目录可选-2026-09-17.md`](../project-memory/plans/【实施计划】AGC项目创建目录可选-2026-09-17.md) |
|
||||||
|
|
||||||
安全与健壮性:
|
安全与健壮性:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user