接入 Cocos 项目识别与导入

完善 Cocos Creator 项目识别、导入和 Agent 目录初始化

补充打开项目流程、契约、测试与文档
This commit is contained in:
2026-09-11 19:56:34 +08:00
parent 8ba9835d10
commit b67cb18b45
23 changed files with 551 additions and 80 deletions
@@ -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();
+22 -6
View File
@@ -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.
+3 -1
View File
@@ -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 实现和不确定结果阻断语义。
+77 -37
View File
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 {
+6
View File
@@ -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,
}
}