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 498616bd4..9b3990d3b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1,5 +1,84 @@ use super::*; +const AUTOMATIC_PROJECTS_DIRECTORY_NAME: &str = "Genarrative GameAgent"; + +fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result { + app.path() + .document_dir() + .map(|documents_root| documents_root.join(AUTOMATIC_PROJECTS_DIRECTORY_NAME)) + .map_err(|error| format!("无法读取系统文档目录:{error}")) +} + +#[tauri::command] +pub(crate) fn get_default_local_game_project_path(app: tauri::AppHandle) -> Result { + Ok(automatic_local_game_projects_root(&app)? + .join("gameagent-new") + .to_string_lossy() + .into_owned()) +} + +pub(crate) fn create_automatic_local_game_project_at( + projects_root: &Path, +) -> Result { + if projects_root.as_os_str().is_empty() || !projects_root.is_absolute() { + return Err("自动工作区根目录必须是绝对路径".to_string()); + } + fs::create_dir_all(projects_root).map_err(|error| { + format!( + "创建自动工作区根目录失败:{}: {error}", + projects_root.display() + ) + })?; + let metadata = fs::symlink_metadata(projects_root).map_err(|error| { + format!( + "读取自动工作区根目录失败:{}: {error}", + projects_root.display() + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("自动工作区根目录必须是普通文件夹".to_string()); + } + + for _ in 0..16 { + let workspace_id = uuid::Uuid::new_v4().simple().to_string(); + let short_id = &workspace_id[..8]; + let project_name = format!("GameAgent 项目 {short_id}"); + let project_root = projects_root.join(format!("gameagent-{short_id}")); + match fs::create_dir(&project_root) { + Ok(()) => { + let result = (|| { + enforce_project_permission_policy(&project_root, "project.create")?; + let _lock = acquire_project_write_lock(&project_root, "project.create")?; + init_local_game_project_at( + &project_root, + &format!("gameagent-{workspace_id}"), + &project_name, + ) + })(); + if result.is_err() { + let _ = fs::remove_dir_all(&project_root); + } + return result; + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(format!( + "创建自动工作区失败:{}: {error}", + project_root.display() + )); + } + } + } + Err("自动工作区命名冲突,请重试".to_string()) +} + +#[tauri::command] +pub(crate) fn create_automatic_local_game_project( + app: tauri::AppHandle, +) -> Result { + create_automatic_local_game_project_at(&automatic_local_game_projects_root(&app)?) +} + #[tauri::command] pub(crate) fn init_local_game_project( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 81681f082..f3646fcff 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2178,6 +2178,8 @@ fn main() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + create_automatic_local_game_project, + get_default_local_game_project_path, init_local_game_project, import_local_godot_project, is_local_project_directory_non_empty, 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 7a8b75405..e25481651 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 @@ -1264,6 +1264,50 @@ fn init_local_game_project_creates_manifest_and_dirs() { fs::remove_dir_all(root).ok(); } +#[test] +fn automatic_local_game_project_allocates_unique_initialized_workspaces() { + let projects_root = unique_project_path(); + + let first = create_automatic_local_game_project_at(&projects_root) + .expect("create first automatic workspace"); + let second = create_automatic_local_game_project_at(&projects_root) + .expect("create second automatic workspace"); + + assert_ne!(first.project_path, second.project_path); + for result in [first, second] { + let root = PathBuf::from(&result.project_path); + assert_eq!(root.parent(), Some(projects_root.as_path())); + assert!(root + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("gameagent-"))); + assert!(root.join(".agent/manifest.json").is_file()); + assert!(root.join("game/index.html").is_file()); + assert!(result.manifest.name.starts_with("GameAgent 项目 ")); + } + + fs::remove_dir_all(projects_root).ok(); +} + +#[cfg(unix)] +#[test] +fn automatic_local_game_project_rejects_symlinked_projects_root() { + use std::os::unix::fs::symlink; + + let container = unique_project_path(); + let target = container.join("target"); + let projects_root = container.join("projects"); + fs::create_dir_all(&target).expect("create automatic workspace target"); + symlink(&target, &projects_root).expect("symlink automatic workspace root"); + + let error = create_automatic_local_game_project_at(&projects_root) + .expect_err("symlinked automatic workspace root must fail"); + + assert!(error.contains("普通文件夹")); + assert!(fs::read_dir(&target).expect("read target").next().is_none()); + fs::remove_dir_all(container).ok(); +} + #[test] fn init_local_game_project_requires_absolute_path() { let error = init_local_game_project_at(Path::new("relative-game"), "project-1", "demo") diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 2dc8e129b..f85be17dc 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -622,6 +622,27 @@ export function App({ ); const localProjectPathRef = useRef(null); localProjectPathRef.current = localProject?.projectPath ?? null; + + useEffect(() => { + if (projectPath || initialProjectPath) { + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + return; + } + let active = true; + void invoke('get_default_local_game_project_path') + .then((defaultPath) => { + if (active && defaultPath.trim()) { + setProjectPath(defaultPath); + } + }) + .catch(() => undefined); + return () => { + active = false; + }; + }, [initialProjectPath, projectPath]); const manifestRefreshMountedRef = useRef(true); const manifestRefreshStatesRef = useRef( new Map< diff --git a/apps/ai-game-creator-shell/src/app/constants.ts b/apps/ai-game-creator-shell/src/app/constants.ts index 371ec89cf..fb7ea0daa 100644 --- a/apps/ai-game-creator-shell/src/app/constants.ts +++ b/apps/ai-game-creator-shell/src/app/constants.ts @@ -5,7 +5,8 @@ export const seedManifest = createGameCreationAppManifest( '未命名游戏原型', ); -export const defaultProjectPath = '/tmp/genarrative-ai-game-draft'; +export const defaultProjectPath = + import.meta.env.MODE === 'test' ? '/tmp/genarrative-ai-game-draft' : ''; export const AGENT_RUN_HISTORY_MAX_COUNT = 100; export const AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT = 20; export const AGENT_RUN_HISTORY_VISIBLE_STEP = 20; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx b/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx index 42baedf55..cac4d6120 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx @@ -1,4 +1,5 @@ import { FolderKanban } from 'lucide-react'; +import { useEffect } from 'react'; import { closeDialogOnEscape } from '../../app/dialogs'; import type { HomeProjectCreationController } from './useHomeProjectCreation'; @@ -13,6 +14,11 @@ export function ProjectsPage({ homeProject: HomeProjectCreationController; recentProjects: RecentProjectsController; }) { + const loadDefaultProjectPath = homeProject.loadDefaultProjectPath; + useEffect(() => { + void loadDefaultProjectPath(); + }, [loadDefaultProjectPath]); + return (
diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 2fab75181..3505303dc 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -63,6 +63,7 @@ export function WorkspaceLauncherShell({ setAgentResults: setActiveProjectAgentResults, resetLauncherHomeDraft, createHomeDraft, + createHomeDraftAutomatically, openProject, } = homeProject; const activeProjectContextRef = useRef(currentProjectContext); @@ -259,6 +260,7 @@ export function WorkspaceLauncherShell({ homeAgentModeItems={homeAgentModeItems} recentProjectRows={recentProjectRows} onCreateDraft={createHomeDraft} + onCreateDraftAutomatically={createHomeDraftAutomatically} onProjectsOpen={() => setLauncherView('projects')} onProjectOpen={(path) => { setProjectPath(path); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentPanel.ts b/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentPanel.ts index b71ccccb8..fd9ee4672 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentPanel.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentPanel.ts @@ -126,6 +126,27 @@ export function useDeveloperAgentPanel(launcherView: LauncherView) { agentChatRuntimeSyncConversationRef, } = state; + useEffect(() => { + if (launcherView !== 'agent-chat' || agentChatProjectPath) { + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + return; + } + let active = true; + void invoke('get_default_local_game_project_path') + .then((defaultPath) => { + if (active && defaultPath.trim()) { + setAgentChatProjectPath(defaultPath); + } + }) + .catch(() => undefined); + return () => { + active = false; + }; + }, [agentChatProjectPath, launcherView, setAgentChatProjectPath]); + useEffect(() => { setAgentChatRunSubmitMode('steer'); }, [ 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 70b61e872..4cb35ffc5 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 @@ -2,6 +2,7 @@ import { type Dispatch, type FormEvent, type SetStateAction, + useCallback, useState, } from 'react'; @@ -73,6 +74,26 @@ export function useHomeProjectCreation({ (state) => state.reset, ); + const loadDefaultProjectPath = useCallback(async () => { + if (projectPath) { + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + return; + } + try { + const defaultPath = await invoke( + 'get_default_local_game_project_path', + ); + if (defaultPath.trim()) { + setProjectPath((currentPath) => currentPath || defaultPath); + } + } catch { + // The user can still choose a directory manually. + } + }, [projectPath]); + function validateProjectPath(nextProjectPath: string) { const trimmedProjectPath = nextProjectPath.trim(); if (!trimmedProjectPath || !isAbsoluteProjectPath(trimmedProjectPath)) { @@ -153,6 +174,57 @@ export function useHomeProjectCreation({ return imported; } + async function enterCreatedHomeProject( + invoke: TauriInvoke, + result: InitLocalProjectResult, + mode: HomeAgentMode, + prompt: string, + attachments: HomeAttachmentDraft[], + ) { + const importedAttachments = await importHomeAttachments( + invoke, + result.projectPath, + attachments, + ); + const sessionId = await ensureProjectSupervisorActiveSessionId( + invoke, + result.projectPath, + ); + if (!sessionId) { + throw new Error('项目总控 Agent active Session 不可用'); + } + await submitProjectSupervisorRuntimeTask({ + invoke, + projectPath: result.projectPath, + sessionId, + prompt: buildHomeConversationContent(mode, prompt, attachments), + runtime: null, + runProfile: 'autonomous-game-build', + }); + enterProjectDevelopment({ + projectPath: result.projectPath, + projectName: + result.manifest.name || projectNameFromPath(result.projectPath), + projectKind: 'web', + manifest: result.manifest, + projectRevision: await readCurrentProjectRevision( + invoke, + result.projectPath, + ), + mode, + initialPrompt: + prompt.trim() || + (attachments.length > 0 + ? '用户上传了参考附件,等待后续补充需求。' + : ''), + attachments: importedAttachments, + recentRunStatus: null, + recentRunStopReason: null, + createdAt: Date.now(), + }); + resetLauncherHomeDraft(); + } + async function createHomeProjectFromDirectory( nextProjectPath: string, mode: HomeAgentMode, @@ -196,27 +268,14 @@ export function useHomeProjectCreation({ name: projectNameFromPath(trimmedProjectPath), }, ); - const importedAttachments = await importHomeAttachments( - invoke, - result.projectPath, - attachments, - ); try { - const sessionId = await ensureProjectSupervisorActiveSessionId( + await enterCreatedHomeProject( invoke, - result.projectPath, + result, + mode, + prompt, + attachments, ); - if (!sessionId) { - throw new Error('项目总控 Agent active Session 不可用'); - } - await submitProjectSupervisorRuntimeTask({ - invoke, - projectPath: result.projectPath, - sessionId, - prompt: buildHomeConversationContent(mode, prompt, attachments), - runtime: null, - runProfile: 'autonomous-game-build', - }); setStatus('已创建项目并交给项目总控 Agent'); } catch (error) { throw new Error( @@ -225,28 +284,6 @@ export function useHomeProjectCreation({ }`, ); } - enterProjectDevelopment({ - projectPath: result.projectPath, - projectName: - result.manifest.name || projectNameFromPath(result.projectPath), - projectKind: 'web', - manifest: result.manifest, - projectRevision: await readCurrentProjectRevision( - invoke, - result.projectPath, - ), - mode, - initialPrompt: - prompt.trim() || - (attachments.length > 0 - ? '用户上传了参考附件,等待后续补充需求。' - : ''), - attachments: importedAttachments, - recentRunStatus: null, - recentRunStopReason: null, - createdAt: Date.now(), - }); - resetLauncherHomeDraft(); return '已创建项目并进入项目开发'; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -255,6 +292,30 @@ export function useHomeProjectCreation({ } } + async function finishAutomaticallyCreatedHomeProject( + result: InitLocalProjectResult, + mode: HomeAgentMode, + prompt: string, + attachments: HomeAttachmentDraft[], + ) { + const invoke = resolveTauriInvoke(); + if (!invoke) { + throw new Error('需要在 Tauri App 内运行'); + } + setStatus('正在启动项目总控 Agent'); + try { + await enterCreatedHomeProject(invoke, result, mode, prompt, attachments); + setStatus('已自动创建工作区并开始工作'); + return '已自动创建工作区并开始工作'; + } catch (error) { + const message = `工作区已创建;首条需求投递失败:${ + error instanceof Error ? error.message : String(error) + }`; + setStatus(message); + throw new Error(message); + } + } + async function createProjectFromProjectPage( nextProjectPath: string, skipNonEmptyCheck = false, @@ -437,6 +498,23 @@ export function useHomeProjectCreation({ ); } + async function createHomeDraftAutomatically(draft: HomeDraft) { + const invoke = resolveTauriInvoke(); + if (!invoke) { + throw new Error('需要在 Tauri App 内运行'); + } + setStatus('正在创建工作区'); + const result = await invoke( + 'create_automatic_local_game_project', + ); + return finishAutomaticallyCreatedHomeProject( + result, + draft.mode, + draft.prompt, + draft.attachments, + ); + } + async function handlePickProjectDirectory() { const invoke = resolveTauriInvoke(); if (!invoke) { @@ -540,7 +618,9 @@ export function useHomeProjectCreation({ setAgentResults, pendingNonEmptyProject, resetLauncherHomeDraft, + loadDefaultProjectPath, createHomeDraft, + createHomeDraftAutomatically, openProject, openGodotProject, handleSubmit, diff --git a/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/index.tsx b/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/index.tsx index f8a36c53e..4c45a8d75 100644 --- a/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/index.tsx +++ b/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/index.tsx @@ -13,6 +13,7 @@ import { COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_HIGH, createCommand, + KEY_ENTER_COMMAND, type LexicalCommand, PASTE_COMMAND, } from 'lexical'; @@ -20,15 +21,13 @@ import { Upload } from 'lucide-react'; import React, { useEffect, useRef } from 'react'; import type { Draft, HomeAttachmentDraft } from '../../useHomeDraftStore'; -import { - $createAttachmentNode, - AttachmentNode, -} from './attachmentNode'; +import { $createAttachmentNode, AttachmentNode } from './attachmentNode'; type RichInputAreaProps = { value: Draft; placeholder: string; onChange: (value: Draft) => void; + onEnter: () => void; children?: React.ReactNode; }; const INSERT_ATTACHMENTS_COMMAND: LexicalCommand = @@ -104,9 +103,27 @@ function selectEditableEndWhenNeeded() { function EditorPlugins({ onChange, -}: Pick) { + onEnter, +}: Pick) { const [editor] = useLexicalComposerContext(); + useEffect( + () => + editor.registerCommand( + KEY_ENTER_COMMAND, + (event) => { + if (!event || event.shiftKey || event.isComposing) { + return false; + } + event.preventDefault(); + onEnter(); + return true; + }, + COMMAND_PRIORITY_HIGH, + ), + [editor, onEnter], + ); + useEffect( () => editor.registerCommand( @@ -239,7 +256,7 @@ export default function RichInputArea(props: RichInputAreaProps) { /> {props.children} - + ); } diff --git a/apps/ai-game-creator-shell/src/view/home/index.tsx b/apps/ai-game-creator-shell/src/view/home/index.tsx index 768674a36..e39b52162 100644 --- a/apps/ai-game-creator-shell/src/view/home/index.tsx +++ b/apps/ai-game-creator-shell/src/view/home/index.tsx @@ -5,12 +5,10 @@ import { Plus, Sparkles, } from 'lucide-react'; -import type { - FormEvent, -} from 'react'; -import { useState } from 'react'; +import type { FormEvent } from 'react'; +import { useRef, useState } from 'react'; -import BRAND_ICON from '../../../../../packages/shared/src/icons/taonier-product-ip.png' +import BRAND_ICON from '../../../../../packages/shared/src/icons/taonier-product-ip.png'; import RichInputArea, { UploadButton } from './components/RichInputArea'; import { richTextToAttachments, @@ -49,6 +47,7 @@ type HomeViewProps = { homeAgentModeItems: readonly HomeAgentModeItem[]; recentProjectRows: readonly HomeProjectRow[]; onCreateDraft: (draft: HomeDraft) => Promise; + onCreateDraftAutomatically: (draft: HomeDraft) => Promise; onProjectsOpen: () => void; onProjectOpen: (path: string) => void; onGodotProjectOpen: () => void; @@ -61,6 +60,7 @@ export default function HomeView({ homeAgentModeItems, recentProjectRows, onCreateDraft, + onCreateDraftAutomatically, onProjectsOpen, onProjectOpen, onGodotProjectOpen, @@ -72,6 +72,7 @@ export default function HomeView({ (state) => state.setRichText, ); const [homeCreationBusy, setHomeCreationBusy] = useState(false); + const homeCreationBusyRef = useRef(false); const activeHomeMode = homeAgentModeItems.find((item) => item.mode === homeAgentMode) ?? homeAgentModeItems[0]; @@ -80,8 +81,13 @@ export default function HomeView({ return null; } - async function handleHomeSubmit(event: FormEvent) { - event.preventDefault(); + async function createFromHome( + createDraft: (draft: HomeDraft) => Promise, + pendingStatus: string, + ) { + if (homeCreationBusyRef.current) { + return; + } const referencedAttachments = richTextToAttachments(homeRichText); const prompt = richTextToPrompt(homeRichText); if (!prompt) { @@ -91,11 +97,12 @@ export default function HomeView({ ); return; } + homeCreationBusyRef.current = true; setHomeCreationBusy(true); - onStatusChange('请选择项目目录'); + onStatusChange(pendingStatus); try { onStatusChange( - await onCreateDraft({ + await createDraft({ mode: homeAgentMode, prompt, attachments: referencedAttachments, @@ -104,10 +111,16 @@ export default function HomeView({ } catch (error) { onStatusChange(error instanceof Error ? error.message : String(error)); } finally { + homeCreationBusyRef.current = false; setHomeCreationBusy(false); } } + function handleHomeSubmit(event: FormEvent) { + event.preventDefault(); + void createFromHome(onCreateDraft, '请选择项目目录'); + } + return (
{ + void createFromHome(onCreateDraftAutomatically, '正在创建工作区'); + }} >
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 58e090dfd..f4046af9b 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1240,27 +1240,23 @@ export function registerHomeProjectCreationTests() { expect(screen.getAllByText('已取消')).not.toHaveLength(0); }); - it('creates a project from home and sends the first requirement once to the active Project Supervisor Session', async () => { + it('creates an automatic workspace on Enter and sends the first requirement once to the active Project Supervisor Session', async () => { + const automaticProjectPath = + 'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\gameagent-a1b2c3d4'; const manifest = createGameCreationAppManifest( 'local-project-draft', 'home-created-game', ); const supervisorHarness = createProjectSupervisorRuntimeHarness({ - projectPath: '/tmp/home-created-game', + projectPath: automaticProjectPath, initialSessionExists: false, }); const invoke = vi.fn( async (command: string, args?: Record) => { - if (command === 'pick_local_project_directory') { - return '/tmp/home-created-game'; - } - if (command === 'is_local_project_directory_non_empty') { - return false; - } - if (command === 'init_local_game_project') { + if (command === 'create_automatic_local_game_project') { return { - projectPath: String(args?.projectPath ?? ''), - manifestPath: `${String(args?.projectPath ?? '')}/.agent/manifest.json`, + projectPath: automaticProjectPath, + manifestPath: `${automaticProjectPath}\\.agent\\manifest.json`, manifest, }; } @@ -1268,8 +1264,8 @@ export function registerHomeProjectCreationTests() { return { id: 'asset-upload-1', localPath: 'assets/uploads/reference.png', - absolutePath: '/tmp/home-created-game/assets/uploads/reference.png', - manifestPath: '/tmp/home-created-game/.agent/manifest.json', + absolutePath: `${automaticProjectPath}\\assets\\uploads\\reference.png`, + manifestPath: `${automaticProjectPath}\\.agent\\manifest.json`, }; } return supervisorHarness.invoke(command, args); @@ -1307,7 +1303,28 @@ export function registerHomeProjectCreationTests() { screen.getByRole('img', { name: 'reference.png' }).getAttribute('src'), ).toBe('blob:mock-attachment-preview'); - fireEvent.click(screen.getByRole('button', { name: '开启创作' })); + fireEvent.keyDown(promptInput, { + key: 'Enter', + code: 'Enter', + shiftKey: true, + }); + fireEvent.keyDown(promptInput, { + key: 'Enter', + code: 'Enter', + isComposing: true, + }); + expect(invoke).not.toHaveBeenCalledWith( + 'create_automatic_local_game_project', + ); + + fireEvent.keyDown(promptInput, { + key: 'Enter', + code: 'Enter', + }); + fireEvent.keyDown(promptInput, { + key: 'Enter', + code: 'Enter', + }); expect(await screen.findByLabelText('项目总控对话')).not.toBeNull(); expect(screen.getByLabelText('项目总控消息').textContent).toContain( @@ -1321,23 +1338,28 @@ export function registerHomeProjectCreationTests() { name: '打开资源详情:美术资源 reference.png', }), ).not.toBeNull(); - expect(invoke).toHaveBeenCalledWith('init_local_game_project', { - projectPath: '/tmp/home-created-game', - projectId: 'local-project-draft', - name: 'home-created-game', - }); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'create_automatic_local_game_project', + ), + ).toHaveLength(1); + expect(invoke).not.toHaveBeenCalledWith('pick_local_project_directory'); + expect(invoke).not.toHaveBeenCalledWith( + 'is_local_project_directory_non_empty', + expect.anything(), + ); expect(invoke).toHaveBeenCalledWith('upload_local_asset', { - projectPath: '/tmp/home-created-game', + projectPath: automaticProjectPath, fileName: 'reference.png', mediaType: 'image/png', bytes: fileBytes, }); expect(invoke).toHaveBeenCalledWith('list_game_creator_agent_sessions', { - projectPath: '/tmp/home-created-game', + projectPath: automaticProjectPath, agentId: 'project-supervisor', }); expect(invoke).toHaveBeenCalledWith('create_game_creator_agent_session', { - projectPath: '/tmp/home-created-game', + projectPath: automaticProjectPath, agentId: 'project-supervisor', title: '项目总控', }); @@ -1346,7 +1368,7 @@ export function registerHomeProjectCreationTests() { ); expect(startCalls).toHaveLength(1); expect(startCalls[0]?.[1]).toMatchObject({ - projectPath: '/tmp/home-created-game', + projectPath: automaticProjectPath, sessionId: supervisorHarness.sessionId, task: expect.stringContaining('初始意图:art / 做素材'), runId: expect.stringMatching(/^project-supervisor-task-/), diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 300e30c82..af8ef4d7b 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -572,7 +572,7 @@ game-project/ - 短期记忆、长期记忆、项目黑板和角色私有记忆按授权本地项目路径读写;普通用户仍只通过聊天命令访问短期 / 长期 / 黑板记忆,角色私有记忆只在单 agent 对话和生成 loop 中按目标 agent 读取。 - 结构化对话记录按授权本地项目路径追加 JSONL;正式聊天读取 Supervisor active Session 与只读 legacy 项目历史,开发单 Agent 对话读取对应 Agent Session。开发入口已支持本地 Session 新建、切换、归档和分叉,但不提供云端同步。 - Agent 状态列表从 `.agent/manifest.json` 的任务 / 角色清单、`.agent/run.latest.json` / `.agent/runs/.json` 的 step、taskGraph、passPlans、lifecycleStatus,以及 `read_game_creator_agent_runtimes` 批量读取的 `.agent/runtime/agents/.json` 和最近任务派生;v1 不新增独立状态数据库,也不承诺完整后台 runner。 -- App 启动先检查平台登录态;登录后进入同一个客户端首页,不再有面向用户的启动器 / 主窗口切换概念。首页按 `做游戏` / `做素材` / `做方案` 保存 `game` / `art` / `doc` 初始意图,输入状态按文字与附件 token 的顺序保存,附件以文件名 token 内嵌在输入框中而非堆叠在下方;提交时才将 token 转为 LLM 可读的 `{1st attachment}` 引用。发送时弹出原生目录选择,目标目录存在且非空时必须二次确认;确认后调用 `init_local_game_project` 初始化本地项目、`upload_local_asset` 导入附件,再把首条需求直接投递给 active `project-supervisor` Session 的后台 Runtime,写入最近项目并切到项目开发页。缺少 active Session 时先创建并激活;成功后清空首页草稿,取消或创建失败时在首页回显状态,首页响应式断点与应用外壳统一为 `760px`。本流程不调用 `generate_local_game_draft`、`generate_platform_art_asset`、一次性 `chat_with_game_creator_agent` 或 legacy 项目对话 append。 +- App 启动先检查平台登录态;登录后进入同一个客户端首页,不再有面向用户的启动器 / 主窗口切换概念。首页按 `做游戏` / `做素材` / `做方案` 保存 `game` / `art` / `doc` 初始意图,输入状态按文字与附件 token 的顺序保存,附件以文件名 token 内嵌在输入框中而非堆叠在下方;提交时才将 token 转为 LLM 可读的 `{1st attachment}` 引用。普通 `Enter` 在输入法组合态结束后自动在系统“文档/Genarrative GameAgent”下原子分配唯一工作区、初始化本地项目、导入附件,并把首条需求直接投递给 active `project-supervisor` Session 的后台 Runtime,随后写入最近项目并切到项目开发页;`Shift+Enter` 保留换行。Windows 使用系统 Documents 路径,Linux 使用 XDG Documents,macOS 使用用户 Documents;前端不得显示或持久化 `/tmp` 作为默认项目路径。原“开启创作”加号按钮保持手动选择目录:点击后弹出原生目录选择,目标目录存在且非空时必须二次确认,再调用 `init_local_game_project` 完成同一后续链路。缺少 active Session 时先创建并激活;成功后清空首页草稿,取消或创建失败时在首页回显状态,首页响应式断点与应用外壳统一为 `760px`。两条流程都不调用 `generate_local_game_draft`、`generate_platform_art_asset`、一次性 `chat_with_game_creator_agent` 或 legacy 项目对话 append。 - 首页发送、项目组目录选择和本地文件选择必须使用 Tauri 非阻塞原生 picker,并把选择器绑定到当前 `client` 窗口;禁止在同步 command 中调用 `blocking_pick_folder` / `blocking_pick_file` 阻塞 WebView 事件循环。选择器打开期间保留首页草稿可编辑,取消后恢复“开启创作”按钮并回显“已取消”。 - 当前尚未定义 GameAgent 独立的灵感数据源;首页保留“灵感推荐”区块并显示无数据状态,但不得展示或请求主站 `/creation` 的陶泥儿精选 `/api/editor/showcase/resources`。后续接入前必须先明确独立数据契约和交互验收。 - debug 构建启动后在用户 `client` 窗口之外额外打开 `developer` 窗口;该窗口用于开发者单独选择 Agent、管理对应 active/archived Session 并读取历史,用户消息和真实 Agent 回复只持久化到 `.agent/conversations/agents//` 下的规范 Session。普通用户窗口不得出现 `Agent 聊天` 导航、picker 或工具台入口。