接入 Cocos 项目识别与导入
完善 Cocos Creator 项目识别、导入和 Agent 目录初始化 补充打开项目流程、契约、测试与文档
This commit is contained in:
@@ -436,6 +436,10 @@ function spawnChild(command, args, options, spawnImpl = spawn) {
|
||||
const child = spawnImpl(command, args, {
|
||||
...options,
|
||||
shell: useShell,
|
||||
// npm.cmd and the Windows shell otherwise create a visible console for
|
||||
// every service in the dev stack. Their stdout/stderr is already inherited
|
||||
// by the launcher, so no separate terminal window is useful.
|
||||
windowsHide: process.platform === 'win32' ? true : options.windowsHide,
|
||||
// POSIX 下让每个长驻服务拥有独立进程组,退出时可以连同 npm、
|
||||
// Node、Cargo 及其子进程一起清理,避免残留订阅任务持续刷日志。
|
||||
detached: isPosix,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,6 +16,10 @@ const AGENT_EDITOR_ASSET_LIBRARY_MAX_ITEMS: usize = 500;
|
||||
const AGENT_EDITOR_ASSET_ID_MAX_CHARS: usize = 512;
|
||||
const AUTOMATIC_PROJECT_NAME_MAX_PROMPT_CHARS: usize = 8_000;
|
||||
const AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS: u32 = 64;
|
||||
// Project naming is an optional homepage enhancement. It must never hold the
|
||||
// actual project creation flow behind the normal (potentially three-minute)
|
||||
// generation timeout.
|
||||
const AUTOMATIC_PROJECT_NAME_TIMEOUT_MS: u64 = 15_000;
|
||||
const AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT: &str =
|
||||
include_str!("../prompts/automatic-project-name.md");
|
||||
|
||||
@@ -55,11 +59,20 @@ async fn request_automatic_project_name(prompt: &str) -> Result<Option<String>,
|
||||
let user_prompt = build_automatic_project_name_prompt(prompt)?;
|
||||
let app_config = load_game_creator_app_config()?;
|
||||
if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER {
|
||||
let reply = crate::agent::direct_game_creator_home_codex_chat(
|
||||
AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT.trim().to_string(),
|
||||
user_prompt,
|
||||
let reply = tokio::time::timeout(
|
||||
std::time::Duration::from_millis(AUTOMATIC_PROJECT_NAME_TIMEOUT_MS),
|
||||
crate::agent::direct_game_creator_home_codex_chat(
|
||||
AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT.trim().to_string(),
|
||||
user_prompt,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
.map_err(|_| {
|
||||
format!(
|
||||
"自动项目命名超时(超过 {} 秒)",
|
||||
AUTOMATIC_PROJECT_NAME_TIMEOUT_MS / 1_000
|
||||
)
|
||||
})??;
|
||||
return Ok(normalize_suggested_project_name(reply.trim()));
|
||||
}
|
||||
let mut llm = app_config.llm.clone();
|
||||
@@ -72,10 +85,18 @@ async fn request_automatic_project_name(prompt: &str) -> Result<Option<String>,
|
||||
.with_request_timeout_ms(llm.request_timeout_ms)
|
||||
.with_max_output_tokens(AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS)
|
||||
.with_web_search(false);
|
||||
let response = client
|
||||
.run(request)
|
||||
.await
|
||||
.map_err(|error| format!("自动项目命名失败:{error}"))?;
|
||||
let response = tokio::time::timeout(
|
||||
std::time::Duration::from_millis(AUTOMATIC_PROJECT_NAME_TIMEOUT_MS),
|
||||
client.run(request),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
format!(
|
||||
"自动项目命名超时(超过 {} 秒)",
|
||||
AUTOMATIC_PROJECT_NAME_TIMEOUT_MS / 1_000
|
||||
)
|
||||
})?
|
||||
.map_err(|error| format!("自动项目命名失败:{error}"))?;
|
||||
Ok(normalize_suggested_project_name(response.text.trim()))
|
||||
}
|
||||
|
||||
@@ -320,13 +341,19 @@ pub(crate) fn closest_existing_project_picker_directory(path: &Path) -> Option<P
|
||||
None
|
||||
}
|
||||
|
||||
const AUTOMATIC_PROJECTS_DIRECTORY_NAME: &str = "Genarrative GameAgent";
|
||||
const AUTOMATIC_PROJECTS_DIRECTORY_NAME: &str = "projects";
|
||||
|
||||
fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
app.path()
|
||||
.document_dir()
|
||||
.map(|documents_root| documents_root.join(AUTOMATIC_PROJECTS_DIRECTORY_NAME))
|
||||
.map_err(|error| format!("无法读取系统文档目录:{error}"))
|
||||
.app_data_dir()
|
||||
.map(|app_data_root| {
|
||||
// Automatic workspaces are AGC-managed data. Keeping them below
|
||||
// the hardened per-user app-data root avoids applying the strict
|
||||
// private-DACL gate to a user Documents directory whose inherited
|
||||
// ACL AGC is not allowed to rewrite.
|
||||
app_data_root.join(AUTOMATIC_PROJECTS_DIRECTORY_NAME)
|
||||
})
|
||||
.map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))
|
||||
}
|
||||
|
||||
pub(crate) fn create_automatic_local_game_project_at(
|
||||
@@ -463,6 +490,24 @@ pub(crate) fn import_local_godot_project(
|
||||
import_local_godot_project_at(root, project_id.trim(), name.trim())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn import_local_cocos_project(
|
||||
project_path: String,
|
||||
project_id: String,
|
||||
name: String,
|
||||
) -> Result<InitLocalProjectResult, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "project.create")?;
|
||||
if discover_local_cocos_project_root(root)?.is_none() {
|
||||
return Err(
|
||||
"所选目录不是有效的 Cocos Creator 项目(需要 package.json.creator.version 和 assets/)"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let _lock = acquire_project_write_lock(root, "project.create")?;
|
||||
import_local_cocos_project_at(root, project_id.trim(), name.trim())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn is_local_project_directory_non_empty(project_path: String) -> Result<bool, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
@@ -515,6 +560,7 @@ pub(crate) fn inspect_local_project_directory(
|
||||
}
|
||||
let recent_run_trace = recent_game_creator_run_trace(root);
|
||||
let godot_project_root = discover_local_godot_project_root(root)?;
|
||||
let cocos_project_root = discover_local_cocos_project_root(root)?;
|
||||
Ok(LocalProjectDirectoryStatus {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
exists: root.exists(),
|
||||
@@ -522,6 +568,8 @@ pub(crate) fn inspect_local_project_directory(
|
||||
is_game_creator_project: is_game_creator_project_directory(root),
|
||||
is_godot_project: godot_project_root.is_some(),
|
||||
godot_project_root,
|
||||
is_cocos_project: cocos_project_root.is_some(),
|
||||
cocos_project_root,
|
||||
project_name: game_creator_project_name(root),
|
||||
modified_at: project_directory_modified_at(root),
|
||||
manifest_error: game_creator_project_manifest_error(root),
|
||||
|
||||
@@ -402,6 +402,8 @@ struct LocalProjectDirectoryStatus {
|
||||
is_game_creator_project: bool,
|
||||
is_godot_project: bool,
|
||||
godot_project_root: Option<String>,
|
||||
is_cocos_project: bool,
|
||||
cocos_project_root: Option<String>,
|
||||
project_name: Option<String>,
|
||||
modified_at: Option<u64>,
|
||||
manifest_error: Option<String>,
|
||||
@@ -2584,6 +2586,7 @@ fn main() {
|
||||
create_automatic_local_game_project,
|
||||
init_local_game_project,
|
||||
import_local_godot_project,
|
||||
import_local_cocos_project,
|
||||
is_local_project_directory_non_empty,
|
||||
inspect_local_project_directory,
|
||||
pick_local_project_directory,
|
||||
|
||||
@@ -169,6 +169,80 @@ fn validate_manifest_godot_project_root(value: Option<&str>) -> Result<(), Strin
|
||||
.map_err(|error| format!("manifest godotProjectRoot 无效:{error}"))
|
||||
}
|
||||
|
||||
fn validate_manifest_cocos_project_root(value: Option<&str>) -> Result<(), String> {
|
||||
let Some(value) = value else {
|
||||
return Ok(());
|
||||
};
|
||||
if value != "." {
|
||||
return Err("manifest cocosProjectRoot 只能是 .".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn discover_local_cocos_project_root(
|
||||
workspace_root: &Path,
|
||||
) -> Result<Option<String>, String> {
|
||||
if workspace_root.as_os_str().is_empty() || !workspace_root.is_absolute() {
|
||||
return Err("Cocos 项目目录必须是绝对路径".to_string());
|
||||
}
|
||||
if project_path_has_control_chars(workspace_root) {
|
||||
return Err("Cocos 项目目录不能包含控制字符".to_string());
|
||||
}
|
||||
let metadata = match fs::symlink_metadata(workspace_root) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"读取 Cocos 项目目录失败:{}: {error}",
|
||||
workspace_root.display()
|
||||
));
|
||||
}
|
||||
};
|
||||
if godot_metadata_is_link(&metadata) || !metadata.is_dir() {
|
||||
return Ok(None);
|
||||
}
|
||||
let package_path = workspace_root.join("package.json");
|
||||
let package_metadata = match fs::symlink_metadata(&package_path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"读取 Cocos package.json 失败:{}: {error}",
|
||||
package_path.display()
|
||||
));
|
||||
}
|
||||
};
|
||||
if godot_metadata_is_link(&package_metadata) || !package_metadata.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
let package_text = fs::read_to_string(&package_path).map_err(|error| {
|
||||
format!(
|
||||
"读取 Cocos package.json 失败:{}: {error}",
|
||||
package_path.display()
|
||||
)
|
||||
})?;
|
||||
let package: serde_json::Value = serde_json::from_str(&package_text).map_err(|error| {
|
||||
format!(
|
||||
"解析 Cocos package.json 失败:{}: {error}",
|
||||
package_path.display()
|
||||
)
|
||||
})?;
|
||||
let creator_version = package
|
||||
.get("creator")
|
||||
.and_then(|creator| creator.get("version"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|version| !version.trim().is_empty());
|
||||
let assets = workspace_root.join("assets");
|
||||
let assets_metadata = fs::symlink_metadata(&assets).ok();
|
||||
if creator_version.is_none()
|
||||
|| !assets_metadata
|
||||
.is_some_and(|metadata| !godot_metadata_is_link(&metadata) && metadata.is_dir())
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(".".to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn discover_local_godot_project_root(
|
||||
workspace_root: &Path,
|
||||
) -> Result<Option<String>, String> {
|
||||
@@ -626,6 +700,67 @@ pub(crate) fn import_local_godot_project_at(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn import_local_cocos_project_at(
|
||||
root: &Path,
|
||||
project_id: &str,
|
||||
name: &str,
|
||||
) -> Result<InitLocalProjectResult, String> {
|
||||
if root.as_os_str().is_empty() || !root.is_absolute() {
|
||||
return Err("Cocos 项目目录必须是绝对路径".to_string());
|
||||
}
|
||||
if project_path_has_control_chars(root) {
|
||||
return Err("Cocos 项目目录不能包含控制字符".to_string());
|
||||
}
|
||||
prepare_game_creator_project_root_for_read(root, true, "Cocos 工作区目录")?;
|
||||
discover_local_cocos_project_root(root)?.ok_or_else(|| {
|
||||
"所选目录不是有效的 Cocos Creator 项目(需要 package.json.creator.version 和 assets/)"
|
||||
.to_string()
|
||||
})?;
|
||||
if project_id.is_empty() {
|
||||
return Err("项目 ID 不能为空".to_string());
|
||||
}
|
||||
let name = normalize_game_creation_project_name(name)?;
|
||||
let manifest_path = root.join(".agent/manifest.json");
|
||||
if manifest_storage_exists(&manifest_path)? {
|
||||
let mut manifest = read_manifest(&manifest_path)?;
|
||||
if manifest.cocos_project_root.as_deref() != Some(".") {
|
||||
manifest.cocos_project_root = Some(".".to_string());
|
||||
write_manifest(&manifest_path, &manifest)?;
|
||||
}
|
||||
return Ok(InitLocalProjectResult {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
manifest_path: manifest_path.to_string_lossy().into_owned(),
|
||||
manifest,
|
||||
});
|
||||
}
|
||||
let agent_db_path = root.join(".agent/agent.db");
|
||||
if !agent_db_path.exists() {
|
||||
append_agent_db_record(
|
||||
root,
|
||||
serde_json::json!({
|
||||
"recordType": "project.import",
|
||||
"projectId": project_id,
|
||||
"name": name,
|
||||
"projectKind": "cocos",
|
||||
"cocosProjectRoot": ".",
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
for relative in [".agent/logs", ".agent/runtime"] {
|
||||
let path = root.join(relative);
|
||||
ensure_game_creator_private_directory_tree(&path, "Cocos 项目 Agent 目录")?;
|
||||
prepare_game_creator_private_path_for_read(&path, true, "Cocos 项目 Agent 目录")?;
|
||||
}
|
||||
let mut manifest = new_game_creation_app_manifest(project_id, name);
|
||||
manifest.cocos_project_root = Some(".".to_string());
|
||||
write_manifest(&manifest_path, &manifest)?;
|
||||
Ok(InitLocalProjectResult {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
manifest_path: manifest_path.to_string_lossy().into_owned(),
|
||||
manifest,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn record_preview_state(
|
||||
root: &Path,
|
||||
status: GameCreationAppPreviewStatus,
|
||||
@@ -1374,6 +1509,8 @@ pub(crate) fn read_manifest(path: &Path) -> Result<GameCreationAppManifest, Stri
|
||||
.map_err(|error| format!("解析 {label} 失败:{}: {error}", source_path.display()))?;
|
||||
validate_manifest_godot_project_root(manifest.godot_project_root.as_deref())
|
||||
.map_err(|error| format!("校验 {label} Godot 项目根失败:{error}"))?;
|
||||
validate_manifest_cocos_project_root(manifest.cocos_project_root.as_deref())
|
||||
.map_err(|error| format!("校验 {label} Cocos 项目根失败:{error}"))?;
|
||||
validate_game_iteration_versions(&manifest.versions)
|
||||
.map_err(|error| format!("校验 {label} 项目版本失败:{error}"))?;
|
||||
Ok(manifest)
|
||||
@@ -1466,6 +1603,8 @@ where
|
||||
{
|
||||
validate_manifest_godot_project_root(manifest.godot_project_root.as_deref())
|
||||
.map_err(|error| format!("校验 manifest Godot 项目根失败:{error}"))?;
|
||||
validate_manifest_cocos_project_root(manifest.cocos_project_root.as_deref())
|
||||
.map_err(|error| format!("校验 manifest Cocos 项目根失败:{error}"))?;
|
||||
if let Some(parent) = path.parent() {
|
||||
ensure_game_creator_private_directory_tree(parent, "manifest 目录")?;
|
||||
prepare_game_creator_private_path_for_read(parent, true, "manifest 目录")?;
|
||||
|
||||
@@ -1659,6 +1659,8 @@ fn project_directory_status_distinguishes_missing_file_and_dir() {
|
||||
is_game_creator_project: false,
|
||||
is_godot_project: false,
|
||||
godot_project_root: None,
|
||||
is_cocos_project: false,
|
||||
cocos_project_root: None,
|
||||
project_name: None,
|
||||
modified_at: None,
|
||||
manifest_error: None,
|
||||
@@ -1747,6 +1749,19 @@ fn project_directory_status_distinguishes_missing_file_and_dir() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_directory_status_detects_cocos_creator_project() {
|
||||
let root = tempfile::tempdir().expect("cocos project root");
|
||||
fs::create_dir(root.path().join("assets")).expect("assets");
|
||||
fs::write(
|
||||
root.path().join("package.json"),
|
||||
r#"{"name":"cocos-project","creator":{"version":"3.8.8"},"uuid":"fixture"}"#,
|
||||
)
|
||||
.expect("package.json");
|
||||
let detected = discover_local_cocos_project_root(root.path()).expect("cocos discovery");
|
||||
assert_eq!(detected.as_deref(), Some("."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_directory_status_reports_workspace_relative_godot_root() {
|
||||
let root = unique_project_path();
|
||||
|
||||
@@ -251,7 +251,7 @@ import { ProjectWorkspaceChatPane } from './features/project-workspace/ProjectWo
|
||||
import { SupervisorChatOnlyView } from './features/project-workspace/SupervisorChatOnlyView';
|
||||
import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog';
|
||||
import { captureAgentRuntimeError } from './services/errorReporting';
|
||||
import { setAgcPluginProjectPath } from './services/pluginHost';
|
||||
import { setAgcPluginProjectPath, startAgcPlugin } from './services/pluginHost';
|
||||
import type { HomeCreationType } from './view/home';
|
||||
import {
|
||||
type ProjectAgentResultSummary,
|
||||
@@ -557,13 +557,27 @@ export function App({
|
||||
|
||||
useEffect(() => {
|
||||
if (supervisorChatOnly) return;
|
||||
void setAgcPluginProjectPath(localProject?.projectPath ?? null).catch(
|
||||
() => undefined,
|
||||
);
|
||||
const nextProjectPath = localProject?.projectPath ?? null;
|
||||
void setAgcPluginProjectPath(nextProjectPath)
|
||||
.then(async () => {
|
||||
if (workspaceProjectKind === 'cocos' && nextProjectPath) {
|
||||
await startAgcPlugin('agc-cocos-editor');
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (workspaceProjectKind !== 'cocos' || !nextProjectPath) {
|
||||
return;
|
||||
}
|
||||
setWorkspaceStatus(
|
||||
`Cocos Creator 插件未就绪:${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
void setAgcPluginProjectPath(null).catch(() => undefined);
|
||||
};
|
||||
}, [localProject?.projectPath, supervisorChatOnly]);
|
||||
}, [localProject?.projectPath, supervisorChatOnly, workspaceProjectKind]);
|
||||
|
||||
const manifestRefreshMountedRef = useRef(true);
|
||||
const manifestRefreshStatesRef = useRef(
|
||||
@@ -1415,7 +1429,9 @@ export function App({
|
||||
void openWorkspace(
|
||||
initialProjectPath,
|
||||
false,
|
||||
initialProjectKind === 'godot' ? 'open' : 'create',
|
||||
initialProjectKind === 'godot' || initialProjectKind === 'cocos'
|
||||
? 'open'
|
||||
: 'create',
|
||||
initialProjectKind,
|
||||
);
|
||||
// Initial project opening is guarded by initialProjectOpenedRef.
|
||||
|
||||
@@ -128,7 +128,7 @@ export type AgcExtensionSummary = {
|
||||
clientExtension: ClientExtensionItem | null;
|
||||
};
|
||||
|
||||
export type LocalProjectKind = 'web' | 'godot';
|
||||
export type LocalProjectKind = 'web' | 'godot' | 'cocos';
|
||||
|
||||
export type ProjectStartMode = 'planning' | 'direct-build';
|
||||
|
||||
@@ -182,6 +182,8 @@ export interface LocalProjectDirectoryStatus {
|
||||
isGameCreatorProject: boolean;
|
||||
isGodotProject: boolean;
|
||||
godotProjectRoot: string | null;
|
||||
isCocosProject?: boolean;
|
||||
cocosProjectRoot?: string | null;
|
||||
projectName: string | null;
|
||||
modifiedAt?: number | null;
|
||||
manifestError?: string | null;
|
||||
|
||||
@@ -40,7 +40,7 @@ export function resolveProjectSupervisorRuntimeSubmission({
|
||||
supervisorChatOnly,
|
||||
planningEntry = false,
|
||||
}: {
|
||||
workspaceProjectKind: 'web' | 'godot';
|
||||
workspaceProjectKind: 'web' | 'godot' | 'cocos';
|
||||
orchestrationMode: 'single-supervisor' | 'professional-dag';
|
||||
supervisorChatOnly: boolean;
|
||||
planningEntry?: boolean;
|
||||
|
||||
@@ -24,13 +24,22 @@ import type { HomeProjectCreationController } from './useHomeProjectCreation';
|
||||
import type { RecentProjectsController } from './useRecentProjects';
|
||||
|
||||
function projectKindLabel(project: RecentProjectRow) {
|
||||
if (project.projectKind === 'cocos') {
|
||||
return 'Cocos Creator';
|
||||
}
|
||||
if (project.projectKind === 'unity') {
|
||||
return 'Unity';
|
||||
}
|
||||
if (project.projectKind === 'ue') {
|
||||
return 'Unreal Engine';
|
||||
}
|
||||
if (project.projectKind === 'godot') {
|
||||
return project.godotProjectRoot && project.godotProjectRoot !== '.'
|
||||
? `Godot · ${project.godotProjectRoot}`
|
||||
: 'Godot';
|
||||
}
|
||||
if (project.projectKind === 'web') {
|
||||
return 'GameAgent';
|
||||
return 'Phaser';
|
||||
}
|
||||
return '待识别';
|
||||
}
|
||||
@@ -314,7 +323,8 @@ export function ProjectsPage({
|
||||
onSubmit={submitRename}
|
||||
>
|
||||
<span className="launcher-project-kind-icon">
|
||||
{project.projectKind === 'godot' ? (
|
||||
{project.projectKind === 'godot' ||
|
||||
project.projectKind === 'cocos' ? (
|
||||
<Gamepad2 size={18} aria-hidden="true" />
|
||||
) : (
|
||||
<FolderKanban size={18} aria-hidden="true" />
|
||||
@@ -357,7 +367,8 @@ export function ProjectsPage({
|
||||
}}
|
||||
>
|
||||
<span className="launcher-project-kind-icon">
|
||||
{project.projectKind === 'godot' ? (
|
||||
{project.projectKind === 'godot' ||
|
||||
project.projectKind === 'cocos' ? (
|
||||
<Gamepad2 size={18} aria-hidden="true" />
|
||||
) : (
|
||||
<FolderKanban size={18} aria-hidden="true" />
|
||||
|
||||
@@ -295,6 +295,7 @@ export function WorkspaceLauncherShell({
|
||||
{launcherView === 'home' ? (
|
||||
<HomeView
|
||||
hasPromo={launcherNotifications.length > 0}
|
||||
status={status}
|
||||
onStatusChange={setStatus}
|
||||
recentProjectRows={recentProjectRows}
|
||||
onCreateDraftAutomatically={createHomeDraftAutomatically}
|
||||
|
||||
@@ -33,7 +33,7 @@ export type WorkspaceLauncherProps = {
|
||||
export type ProjectSupervisorComponentProps = {
|
||||
initialProjectPath?: string;
|
||||
initialProjectManifest?: GameCreationAppManifest;
|
||||
initialProjectKind?: 'web' | 'godot';
|
||||
initialProjectKind?: 'web' | 'godot' | 'cocos';
|
||||
initialSupervisorMessage?: string;
|
||||
initialCreationType?: HomeCreationType | null;
|
||||
initialAttachments?: LauncherImportedAttachment[];
|
||||
@@ -66,7 +66,8 @@ export type RecentProjectRow = {
|
||||
path: string;
|
||||
name: string;
|
||||
status: string;
|
||||
projectKind: 'web' | 'godot' | 'unknown';
|
||||
projectKind: 'web' | 'cocos' | 'unity' | 'ue' | 'godot' | 'unknown';
|
||||
cocosProjectRoot: string | null;
|
||||
godotProjectRoot: string | null;
|
||||
modifiedAt: number | null;
|
||||
recentRunStatus: string | null;
|
||||
@@ -242,7 +243,8 @@ export function buildRecentProjectRows(
|
||||
? '不是文件夹'
|
||||
: directoryStatus?.manifestError
|
||||
? '无法读取'
|
||||
: directoryStatus?.isGodotProject === true &&
|
||||
: (directoryStatus?.isGodotProject === true ||
|
||||
directoryStatus?.isCocosProject === true) &&
|
||||
directoryStatus?.isGameCreatorProject === false
|
||||
? '可导入'
|
||||
: directoryStatus?.isGameCreatorProject === false
|
||||
@@ -264,12 +266,15 @@ export function buildRecentProjectRows(
|
||||
path: workspace,
|
||||
name: projectName,
|
||||
status,
|
||||
projectKind: directoryStatus?.isGodotProject
|
||||
? 'godot'
|
||||
: directoryStatus?.isGameCreatorProject
|
||||
? 'web'
|
||||
: 'unknown',
|
||||
projectKind: directoryStatus?.isCocosProject
|
||||
? 'cocos'
|
||||
: directoryStatus?.isGodotProject
|
||||
? 'godot'
|
||||
: directoryStatus?.isGameCreatorProject
|
||||
? 'web'
|
||||
: 'unknown',
|
||||
godotProjectRoot: directoryStatus?.godotProjectRoot ?? null,
|
||||
cocosProjectRoot: directoryStatus?.cocosProjectRoot ?? null,
|
||||
modifiedAt: directoryStatus?.modifiedAt ?? null,
|
||||
recentRunStatus: directoryStatus?.recentRunStatus ?? null,
|
||||
recentRunStopReason: directoryStatus?.recentRunStopReason ?? null,
|
||||
@@ -281,7 +286,8 @@ export function buildRecentProjectRows(
|
||||
directoryStatus?.isDirectory !== false &&
|
||||
!directoryStatus?.manifestError &&
|
||||
(directoryStatus?.isGameCreatorProject !== false ||
|
||||
directoryStatus?.isGodotProject === true),
|
||||
directoryStatus?.isGodotProject === true ||
|
||||
directoryStatus?.isCocosProject === true),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -460,6 +460,16 @@ export function useHomeProjectCreation({
|
||||
'get_local_game_manifest',
|
||||
{ projectPath: trimmedProjectPath },
|
||||
);
|
||||
} else if (directoryStatus.isCocosProject) {
|
||||
const result = await invoke<InitLocalProjectResult>(
|
||||
'import_local_cocos_project',
|
||||
{
|
||||
projectPath: trimmedProjectPath,
|
||||
projectId: createLocalProjectId(),
|
||||
name: projectNameFromPath(trimmedProjectPath),
|
||||
},
|
||||
);
|
||||
projectManifest = result.manifest;
|
||||
} else if (
|
||||
directoryStatus.godotProjectRoot !== null &&
|
||||
directoryStatus.godotProjectRoot !== undefined
|
||||
@@ -474,7 +484,9 @@ export function useHomeProjectCreation({
|
||||
);
|
||||
projectManifest = result.manifest;
|
||||
} else {
|
||||
setStatus('这不是已初始化的 AI 游戏项目,请使用新建项目。');
|
||||
setStatus(
|
||||
'未识别为支持的 AGC、Godot 或 Cocos Creator 项目,请选择项目根目录。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setStatus('已打开项目');
|
||||
@@ -483,9 +495,10 @@ export function useHomeProjectCreation({
|
||||
projectName:
|
||||
directoryStatus.projectName ||
|
||||
projectNameFromPath(trimmedProjectPath),
|
||||
projectKind:
|
||||
directoryStatus.godotProjectRoot !== null &&
|
||||
directoryStatus.godotProjectRoot !== undefined
|
||||
projectKind: directoryStatus.isCocosProject
|
||||
? 'cocos'
|
||||
: directoryStatus.godotProjectRoot !== null &&
|
||||
directoryStatus.godotProjectRoot !== undefined
|
||||
? 'godot'
|
||||
: 'web',
|
||||
manifest: projectManifest,
|
||||
|
||||
@@ -136,11 +136,17 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
project.path,
|
||||
project.status,
|
||||
project.godotProjectRoot ?? '',
|
||||
project.projectKind === 'godot'
|
||||
? 'godot'
|
||||
: project.projectKind === 'web'
|
||||
? 'gameagent web'
|
||||
: 'unknown',
|
||||
project.projectKind === 'cocos'
|
||||
? 'cocos creator'
|
||||
: project.projectKind === 'unity'
|
||||
? 'unity'
|
||||
: project.projectKind === 'ue'
|
||||
? 'ue unreal engine'
|
||||
: project.projectKind === 'godot'
|
||||
? 'godot'
|
||||
: project.projectKind === 'web'
|
||||
? 'phaser gameagent web'
|
||||
: 'unknown',
|
||||
].some((value) => value.toLocaleLowerCase().includes(normalizedQuery)),
|
||||
);
|
||||
}, [projectRows, projectSearchQuery]);
|
||||
|
||||
@@ -70,7 +70,7 @@ export type HomeProjectRow = {
|
||||
path: string;
|
||||
name: string;
|
||||
status: string;
|
||||
projectKind: 'web' | 'godot' | 'unknown';
|
||||
projectKind: 'web' | 'cocos' | 'unity' | 'ue' | 'godot' | 'unknown';
|
||||
modifiedAt: number | null;
|
||||
canOpen: boolean;
|
||||
};
|
||||
@@ -87,6 +87,15 @@ function formatProjectUpdatedAt(modifiedAt: number | null) {
|
||||
}
|
||||
|
||||
function projectTypeLabel(projectKind: HomeProjectRow['projectKind']) {
|
||||
if (projectKind === 'cocos') {
|
||||
return 'Cocos Creator';
|
||||
}
|
||||
if (projectKind === 'unity') {
|
||||
return 'Unity';
|
||||
}
|
||||
if (projectKind === 'ue') {
|
||||
return 'Unreal Engine';
|
||||
}
|
||||
return projectKind === 'godot'
|
||||
? 'Godot 游戏'
|
||||
: projectKind === 'web'
|
||||
@@ -96,6 +105,7 @@ function projectTypeLabel(projectKind: HomeProjectRow['projectKind']) {
|
||||
|
||||
type HomeViewProps = {
|
||||
hasPromo: boolean;
|
||||
status?: string;
|
||||
onStatusChange: (status: string) => void;
|
||||
recentProjectRows: readonly HomeProjectRow[];
|
||||
onCreateDraftAutomatically: (
|
||||
@@ -109,6 +119,7 @@ type HomeViewProps = {
|
||||
|
||||
export default function HomeView({
|
||||
hasPromo,
|
||||
status = '',
|
||||
onStatusChange,
|
||||
recentProjectRows,
|
||||
onCreateDraftAutomatically,
|
||||
@@ -271,6 +282,14 @@ export default function HomeView({
|
||||
</div>
|
||||
</RichInputArea>
|
||||
</form>
|
||||
{status ? (
|
||||
<p
|
||||
className="m-0 w-[min(814px,calc(100vw-122px))] text-sm text-(--platform-warm-text) max-[760px]:w-[min(100%,calc(100vw-76px))]"
|
||||
role="status"
|
||||
>
|
||||
{status}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { useHomeProjectCreation } from '../src/features/app-shell/useHomeProjectCreation';
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__TAURI__;
|
||||
});
|
||||
|
||||
test('打开 Cocos 目录走导入入口,不创建 Web 脚手架或发送初始消息', async () => {
|
||||
const manifest = {
|
||||
...createGameCreationAppManifest('cocos-project', 'Cocos 项目'),
|
||||
cocosProjectRoot: '.',
|
||||
};
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'inspect_local_project_directory') {
|
||||
return {
|
||||
exists: true,
|
||||
isDirectory: true,
|
||||
isGameCreatorProject: false,
|
||||
isGodotProject: false,
|
||||
godotProjectRoot: null,
|
||||
isCocosProject: true,
|
||||
cocosProjectRoot: '.',
|
||||
projectName: 'Cocos 项目',
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
};
|
||||
}
|
||||
if (command === 'import_local_cocos_project') {
|
||||
return { projectPath: 'C:/projects/cocos', manifest };
|
||||
}
|
||||
if (command === 'get_local_game_project_revision') return { revision: 0 };
|
||||
throw new Error(`意外调用:${command}`);
|
||||
});
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
const setLauncherView = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useHomeProjectCreation({
|
||||
setStatus: vi.fn(),
|
||||
setLauncherView,
|
||||
setAgentChatProjectPath: vi.fn(),
|
||||
rememberRecentWorkspace: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
await act(() => result.current.openProject('C:/projects/cocos', 'open'));
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('import_local_cocos_project', {
|
||||
projectPath: 'C:/projects/cocos',
|
||||
projectId: expect.any(String),
|
||||
name: 'cocos',
|
||||
});
|
||||
expect(result.current.currentProjectContext).toMatchObject({
|
||||
projectKind: 'cocos',
|
||||
projectPath: 'C:/projects/cocos',
|
||||
initialPrompt: '',
|
||||
manifest,
|
||||
});
|
||||
expect(setLauncherView).toHaveBeenCalledWith('project-development');
|
||||
expect(
|
||||
invoke.mock.calls.some(
|
||||
([command]) => command === 'init_local_game_project',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -34,6 +34,21 @@
|
||||
|
||||
主配置与 local overlay 的单文件原子写入不能保证整体成功;覆盖层写入失败会留下混合配置。保存前先序列化全部变更,多文件保存保留原内容,错误时逆序恢复并报告回滚失败;单文件保持原写入路径,成功后不回读、不触发外部诊断。此回滚仅处理可捕获错误,不承诺进程崩溃下的事务恢复。
|
||||
|
||||
## 2026-09-11 首页自动工作区必须使用 AGC 管理目录
|
||||
|
||||
自动创建工作区使用 `app_data_dir()/projects`。不要把自动工作区根放到用户
|
||||
`Documents`:该目录通常带继承 ACL,AGC 的受管私有目录门禁会拒绝改写,导致
|
||||
首页命名回合成功后创建命令失败且不会留下项目目录。用户通过目录选择器创建的
|
||||
项目仍走 user-selected 权限范围。
|
||||
|
||||
## 2026-09-11 Cocos 项目必须走独立导入分支
|
||||
|
||||
Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` 目录识别;
|
||||
打开时调用 `import_local_cocos_project` 建立最小 `.agent` 记录,不得复用 Phaser
|
||||
新建脚手架。Cocos 入口还必须把项目上下文同步给内置插件,并使用
|
||||
`cocos-editor-execute` feature;否则目录选择成功后会因既有逻辑只认识 AGC/Godot
|
||||
而无任何可见结果。
|
||||
|
||||
## 2026-09-09 `npm run agc` 的 Ctrl+C 不能只依赖 shell 包装层与端口健康检查
|
||||
|
||||
- **现象**:`npm run agc` 按 Ctrl+C 后终端回到提示符,但上个工作树的 `api-server.exe` / SpacetimeDB 仍在监听 `8082` / `8083` / `3101`;切到另一个 worktree 再启动 AGC 时,前端仍然连到上个工作树的后端,在改过数据库 / schema 的工作树上会串库。
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Unity 与 Unreal 项目识别与导入
|
||||
|
||||
状态:开放,暂不实现
|
||||
|
||||
## 背景
|
||||
|
||||
AGC 的“打开项目”入口已经支持 AGC(`.agent/manifest.json`)、Cocos Creator
|
||||
(`package.json.creator.version` + `assets/`)和 Godot(`project.godot`)。
|
||||
|
||||
首页与项目列表的显示层已经预留 `unity` / `ue` 两种项目类型:
|
||||
|
||||
- `RecentProjectRow.projectKind`、`HomeProjectRow.projectKind` 已包含
|
||||
`unity`、`ue`;
|
||||
- 类型标签、搜索关键词和图标分支已就绪;
|
||||
- 打开入口目前只判定 AGC / Cocos / Godot,其余目录提示
|
||||
“未识别为支持的 AGC、Godot 或 Cocos Creator 项目”。
|
||||
|
||||
因此 `unity` / `ue` 今天不会出现在列表里,它们只是显示层占位,没有对应识别、
|
||||
导入命令或目标编辑器适配器。
|
||||
|
||||
## 结论
|
||||
|
||||
Unity、Unreal 的项目识别、导入和编辑器适配暂不实现。保留显示层占位,不写猜测性
|
||||
识别代码,也不为未接入的编辑器创建 `.agent` 记录。
|
||||
|
||||
## 未来实现时的最小增量
|
||||
|
||||
1. Rust:`discover_local_unity_project_root`(`Assets/` 与
|
||||
`ProjectSettings/ProjectVersion.txt`)、`discover_local_unreal_project_root`
|
||||
(根目录或一层子目录的 `*.uproject`);把 `is_unity_project` /
|
||||
`is_unreal_project` 加入 `LocalProjectDirectoryStatus`。
|
||||
2. Rust:新增 `import_local_unity_project` / `import_local_unreal_project`
|
||||
命令,manifest 增加对应项目根字段并做同 Cocos 的根校验。
|
||||
3. 前端:`openProject` 增加两条导入分支,`projectKind` 映射补齐 `unity` / `ue`。
|
||||
4. 图标:Unity、Unreal 各自使用独立图标,不再落到 `FolderKanban`。
|
||||
5. 适配器:只有真实接入目标编辑器连接与执行能力后,才注册对应
|
||||
`EditorAdapter`;不得先写死进程扫描或注入路径。
|
||||
|
||||
## 关闭条件
|
||||
|
||||
- Unity 与 Unreal 的目录识别、导入、项目类型显示在真实项目上通过验收;
|
||||
- 各自的编辑器适配器要么完成真实验证,要么明确记录为未接入能力;
|
||||
- 补齐对应技术文档,并删除本 TODO。
|
||||
@@ -48,6 +48,15 @@ plugins/agc-cocos-editor/
|
||||
|
||||
该插件是**内置插件**:随客户端分发、不能卸载,只能通过 `set_agc_plugin_enabled` 控制是否可用。禁用后插件进程停止且不能启动,Runtime 工具 `cocos.editor.execute` 与 DirectProject 的 `agc_cocos_execute` 同时从工具目录、工具策略快照和 Agent 上下文里消失;重新启用后立即恢复。开关状态保存在 AppData `extensions/builtin-plugins.json`。
|
||||
|
||||
## AGC 项目打开入口
|
||||
|
||||
AGC 识别根目录同时存在 `package.json.creator.version` 与普通 `assets/` 目录的
|
||||
Cocos Creator 项目,并将其标记为 `cocos` 项目类型。选择目录后通过
|
||||
`import_local_cocos_project` 建立最小 `.agent/` 运行记录,不创建 Phaser/Web
|
||||
脚手架;工作区上下文同步到内置 `agc-cocos-editor` 插件,开发态默认启用
|
||||
`cocos-editor-execute` feature。若目录不是已支持的 AGC、Godot 或 Cocos 项目,
|
||||
入口必须显示明确错误,不再静默返回。
|
||||
|
||||
## 第一阶段命令协议
|
||||
|
||||
DirectProject 的现役 `agc_tools` 目录通过 Windows `cocos-editor-execute` feature 注册 `agc_cocos_execute`,参数只有 `code`。客户端在 blocking worker 内调用插件 native 模块,保留项目锁和现有项目权限;当前 bridge 出现执行结果不确定后拒绝后续 execute。旧 Runtime 的对应工具名为 `cocos.editor.execute`,继续使用它已有的 pending action、权限和恢复语义;插件入口注册的同名命令走宿主 `host.rpc` → `EditorAdapter` 路径,两条路径共享同一 native 实现和不确定结果阻断语义。
|
||||
|
||||
Generated
+77
-37
File diff suppressed because it is too large
Load Diff
@@ -592,6 +592,7 @@ export interface GameCreationAppManifest {
|
||||
commandRuns?: GameCreationAppCommandRunState[];
|
||||
versions?: GameIterationVersion[];
|
||||
godotProjectRoot?: string | null;
|
||||
cocosProjectRoot?: string | null;
|
||||
}
|
||||
|
||||
export interface GameCreationAgentToolCallTrace {
|
||||
|
||||
@@ -1777,6 +1777,7 @@ class DevRunner {
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: process.platform === 'win32',
|
||||
windowsHide: process.platform === 'win32',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2096,6 +2097,7 @@ class DevRunner {
|
||||
env: mergedEnv,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: process.platform === 'win32',
|
||||
windowsHide: process.platform === 'win32',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2171,6 +2173,7 @@ class DevRunner {
|
||||
env: mergedEnv,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: process.platform === 'win32',
|
||||
windowsHide: process.platform === 'win32',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2259,6 +2262,7 @@ class DevRunner {
|
||||
env,
|
||||
...createDevServerSpawnOptions(),
|
||||
shell: process.platform === 'win32',
|
||||
windowsHide: process.platform === 'win32',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2303,6 +2307,7 @@ class DevRunner {
|
||||
env,
|
||||
...createDevServerSpawnOptions(),
|
||||
shell: process.platform === 'win32',
|
||||
windowsHide: process.platform === 'win32',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3260,6 +3265,7 @@ function runForeground(command, args, { cwd, env, label }) {
|
||||
env,
|
||||
stdio: ['inherit', 'pipe', 'pipe'],
|
||||
shell: process.platform === 'win32',
|
||||
windowsHide: process.platform === 'win32',
|
||||
});
|
||||
|
||||
child.stdout?.on('data', (chunk) => capture(chunk, process.stdout));
|
||||
|
||||
@@ -795,6 +795,8 @@ pub struct GameCreationAppManifest {
|
||||
pub versions: Vec<GameIterationVersion>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub godot_project_root: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cocos_project_root: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
@@ -928,6 +930,7 @@ pub fn new_game_creation_app_manifest(
|
||||
command_runs: Vec::new(),
|
||||
versions: Vec::new(),
|
||||
godot_project_root: None,
|
||||
cocos_project_root: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user