From be370cc615136d69b15887d3676e98d27d48b5db Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:09:32 +0800 Subject: [PATCH] =?UTF-8?q?AGC=20=E6=94=AF=E6=8C=81=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=87=AA=E9=80=89=E9=A1=B9=E7=9B=AE=E5=88=9B=E5=BB=BA=E7=9B=AE?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增设置「工作区」分类,承载「项目创建目录」的选择与恢复默认入口 - 自动建项命令新增可选 projectsRoot,未指定时沿用 AppData projects 默认目录 - 新增项目创建目录校验与解析:只接受目录选择器返回且通过私有路径门禁的既有目录 - 目录选择器新增可选 title 参数,用于「选择项目创建目录」文案 - 首页自动建项与模板建项在建项时携带用户选定的项目创建目录 - 客户端本地保存项目创建目录偏好,建项时重新校验,不作为授权凭据 - 补充设置页工作区、首页建项与目录偏好单测 - 修复项目开发视图缺失的 Shapes 图标导入,恢复 agc:typecheck 通过 - 同步实施计划、决策日志与排障记忆文档 --- .../src-tauri/src/commands.rs | 59 ++++++++++- .../src-tauri/src/template_library.rs | 7 +- .../src-tauri/src/tests/project.rs | 57 ++++++++++ .../src/features/app-shell/model.ts | 58 +++++++++- .../app-shell/useHomeProjectCreation.ts | 4 + .../app-shell/useProjectCreationDirectory.ts | 81 ++++++++++++++ .../runtime-config/RuntimeConfigDialog.tsx | 61 +++++++++++ .../template-library/useTemplateLibrary.ts | 3 + apps/ai-game-creator-shell/src/styles.css | 10 ++ .../tests/appSurface/home.suite.ts | 100 ++++++++++++++++++ .../appSurface/runtime-settings.suite.ts | 64 +++++++++++ .../tests/projectCreationDirectory.test.ts | 53 ++++++++++ ...实施计划】AGC项目创建目录可选-2026-09-17.md | 68 ++++++++++++ .../shared-memory/decision-log.md | 10 ++ docs/project-memory/shared-memory/pitfalls.md | 9 ++ 15 files changed, 636 insertions(+), 8 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/app-shell/useProjectCreationDirectory.ts create mode 100644 apps/ai-game-creator-shell/tests/projectCreationDirectory.test.ts create mode 100644 docs/project-memory/plans/【实施计划】AGC项目创建目录可选-2026-09-17.md diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 400b768bc..fb6edc2c4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -482,6 +482,43 @@ fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result Result { + 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 { + 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( projects_root: &Path, requested_name: Option<&str>, @@ -551,9 +588,10 @@ pub(crate) fn create_automatic_local_game_project( app: tauri::AppHandle, name: Option, planning: Option, + projects_root: Option, ) -> Result { 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(), planning.unwrap_or(false), ) @@ -764,13 +802,30 @@ pub(crate) fn recent_game_creator_run_trace(root: &Path) -> Option(&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] pub(crate) async fn pick_local_project_directory( app: tauri::AppHandle, initial_path: Option, + title: Option, ) -> Result, String> { 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 .as_deref() .map(str::trim) diff --git a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs index ee2cea8dc..6b1e7b8b9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs @@ -880,12 +880,9 @@ pub(crate) async fn create_automatic_local_game_project_from_template( template_version: String, name: Option, planning: Option, + projects_root: Option, ) -> Result { - let projects_root = app - .path() - .app_data_dir() - .map(|root| root.join("projects")) - .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))?; + let projects_root = crate::resolve_game_project_creation_root(&app, projects_root.as_deref())?; let cache_root = template_cache_root(&app)?; ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; let record = diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 145aabd36..1dcdad4e3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -1549,6 +1549,63 @@ fn automatic_local_game_project_allocates_unique_initialized_workspaces() { 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] fn automatic_local_game_project_accepts_only_a_safe_custom_name() { let projects_root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/model.ts b/apps/ai-game-creator-shell/src/features/app-shell/model.ts index 90ace801c..99f11e3ea 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/model.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/model.ts @@ -17,10 +17,15 @@ import type { ProjectAgentRuntimeSummary, } from '../../view/project-development'; 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 = '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 = 'genarrative.supervisor-chat.draft'; @@ -156,6 +161,57 @@ export function removeRecentWorkspace(path: string) { 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( message: ChatMessage, projectPath: string, diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index c07d04499..13c16f7f0 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -48,6 +48,7 @@ import { isAbsoluteProjectPath, projectPathHasControlCharacter, } from '../project-summary/projectSummary'; +import { readProjectCreationDirectory } from './model'; import { resolveSessionPreviewOnProjectOpen } from './sessionPreview'; /** 首页输入框当前的纯文本(Lexical 编辑器状态 -> 文本);没有输入就返回空串。 */ @@ -828,6 +829,9 @@ export function useHomeProjectCreation({ { name: suggestedName, planning: startMode === 'planning', + // 用户在首页选过「项目创建目录」就用它;没选传 null,由 Rust 侧回落到 + // AGC 管理的默认位置(应用数据目录下的 projects)。 + projectsRoot: readProjectCreationDirectory() || null, }, ); createdProjectPath = result.projectPath; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useProjectCreationDirectory.ts b/apps/ai-game-creator-shell/src/features/app-shell/useProjectCreationDirectory.ts new file mode 100644 index 000000000..1f8a772b3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/app-shell/useProjectCreationDirectory.ts @@ -0,0 +1,81 @@ +import { useCallback, useRef, useState } from 'react'; + +import { resolveTauriInvoke } from '../../app/tauri'; +import { + readProjectCreationDirectory, + writeProjectCreationDirectory, +} from './model'; + +/** + * 「项目创建目录」用户偏好。 + * + * 默认沿用 AGC 管理的应用数据目录(`/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( + '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 +>; diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index 48980e2ab..37ebb7361 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -2,6 +2,7 @@ import { Bot, CheckCircle2, CircleAlert, + FolderOpen, Info, LoaderCircle, Pencil, @@ -44,6 +45,7 @@ import { startAgcPlugin, stopAgcPlugin, } from '../../services/pluginHost'; +import { useProjectCreationDirectory } from '../app-shell/useProjectCreationDirectory'; import { PluginPanelHost } from '../plugins/PluginPanelHost'; import { reasoningEffortLabel } from '../project-workspace/composerReasoningEffort'; import { CustomLlmSettings } from './CustomLlmSettings'; @@ -77,6 +79,7 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = { type RuntimeSettingsSection = | 'general' + | 'workspace' | 'agents' | 'extensions' | 'advanced' @@ -96,6 +99,12 @@ const runtimeSettingsSections = [ description: '运行方式与输出偏好', icon: Settings2, }, + { + id: 'workspace', + label: '工作区', + description: '项目创建目录', + icon: FolderOpen, + }, { id: 'agents', label: 'Agent 分工', @@ -224,6 +233,11 @@ export function RuntimeConfigDialog({ const [runtimeConfigBusy, setRuntimeConfigBusy] = useState(false); const [activeSection, setActiveSection] = useState('general'); + /** + * 「项目创建目录」是客户端本地偏好(localStorage),不随下面的配置文件一起保存: + * 选择目录当场生效,做游戏 / 做方案与模板建项下一次建项就落在该目录下。 + */ + const projectCreationDirectory = useProjectCreationDirectory(); const [clientExtensions, setClientExtensions] = useState< ClientExtensionItem[] >([]); @@ -875,6 +889,53 @@ export function RuntimeConfigDialog({ ) : null} ) : null} + {activeSection === 'workspace' ? ( +
+ 项目创建目录 + + {projectCreationDirectory.projectCreationDirectory || + '默认位置'} + +
+ + {projectCreationDirectory.projectCreationDirectory ? ( + + ) : null} +
+ {projectCreationDirectory.projectCreationDirectoryStatus ? ( + + {projectCreationDirectory.projectCreationDirectoryStatus} + + ) : null} +
+ ) : null} {activeSection === 'advanced' && runtimeConfigDraft.agentMode !== 'codex_cli' ? ( <> diff --git a/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts b/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts index aa1ef20a7..bcad3c06b 100644 --- a/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts +++ b/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts @@ -10,6 +10,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { resolveTauriInvoke } from '../../app/tauri'; import type { InitLocalProjectResult } from '../../app/types'; +import { readProjectCreationDirectory } from '../app-shell/model'; import { collectGameTemplateRuntimes, collectGameTemplateTags, @@ -153,6 +154,8 @@ export function useTemplateLibrary({ templateVersion: template.templateVersion, name: null, planning: false, + // 与首页自动建项共用一个「项目创建目录」偏好;没选时由 Rust 侧回落到默认位置。 + projectsRoot: readProjectCreationDirectory() || null, }, ); await onProjectCreated(result); diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 03c2a4897..d345c663b 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -4247,7 +4247,15 @@ h2 { gap: 7px; } +.runtime-settings-field-actions { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-top: 7px; +} + .runtime-settings-section-actions button, +.runtime-settings-field-actions button, .runtime-settings-extension-actions button { display: inline-flex; align-items: center; @@ -4264,12 +4272,14 @@ h2 { } .runtime-settings-section-actions button:hover, +.runtime-settings-field-actions button:hover, .runtime-settings-extension-actions button:hover { border-color: var(--platform-surface-hover-border); background: var(--platform-button-ghost-fill); } .runtime-settings-section-actions button:disabled, +.runtime-settings-field-actions button:disabled, .runtime-settings-extension-actions button:disabled { cursor: default; opacity: 0.55; diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index b327549b4..1a7c86187 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1631,6 +1631,7 @@ export function registerHomeProjectCreationTests() { expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', { name: null, planning: false, + projectsRoot: null, }); expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', { projectPath: automaticProjectPath, @@ -1736,6 +1737,7 @@ export function registerHomeProjectCreationTests() { expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', { name: '角色参考游戏', planning: false, + projectsRoot: null, }); expect(invoke).toHaveBeenCalledWith('upload_local_asset', { projectPath: automaticProjectPath, @@ -3537,4 +3539,102 @@ export function registerRecentProjectsTests() { ); 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) => { + 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, + }); + }); } diff --git a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts index 78c1da3c1..c66c09123 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts @@ -360,6 +360,70 @@ export function registerRuntimeSettingsTests() { 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 () => { const invoke = vi.fn( async (command: string, args?: Record) => { diff --git a/apps/ai-game-creator-shell/tests/projectCreationDirectory.test.ts b/apps/ai-game-creator-shell/tests/projectCreationDirectory.test.ts new file mode 100644 index 000000000..65a91837b --- /dev/null +++ b/apps/ai-game-creator-shell/tests/projectCreationDirectory.test.ts @@ -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(''); + }); +}); diff --git a/docs/project-memory/plans/【实施计划】AGC项目创建目录可选-2026-09-17.md b/docs/project-memory/plans/【实施计划】AGC项目创建目录可选-2026-09-17.md new file mode 100644 index 000000000..e3df9b9ce --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC项目创建目录可选-2026-09-17.md @@ -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 管理的默认位置(`/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 默认路径逻辑不变。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ec473d2c0..eb0500773 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8935,3 +8935,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` 重建发布。 - 验证:`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-09-17 AGC 自动建项支持用户自选项目创建目录(入口设在设置「工作区」) + +- 背景:首页「做游戏 / 做方案」与模板库「使用模板」的自动建项固定落在 `/projects`,用户无法把游戏放到自己的工作盘或工程目录;同时该路径不能随意放开(受管私有目录门禁与 Documents 继承 ACL 的既有约束见 `pitfalls.md`)。 +- 决策:新增可选参数 `projectsRoot`(`create_automatic_local_game_project`、`create_automatic_local_game_project_from_template`),为空时由 Rust 回落到 `/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` 通过。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index f77e2447e..d2f317ca3 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -248,6 +248,15 @@ AGC 的 Cocos 能力来自随客户端分发的 `agc-cocos-editor` 内置插件 首页命名回合成功后创建命令失败且不会留下项目目录。用户通过目录选择器创建的 项目仍走 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 项目识别不等于编辑器桥就绪 - 现象:能发现正确 Creator PID、Agent 也有 `agc_cocos_execute`,但首次执行报 pipe 不存在;仅登记目标的 `connect` 会误报成功。