实现AGC回车自动创建工作区
首页普通回车自动分配系统文档目录下的唯一工作区并启动项目总控 保留加号按钮手动选择目录和Shift回车换行语义 统一Windows Linux与macOS默认项目路径解析并移除产品态tmp默认值 补充自动工作区安全测试 首页交互回归与技术方案说明
This commit is contained in:
@@ -1,5 +1,84 @@
|
||||
use super::*;
|
||||
|
||||
const AUTOMATIC_PROJECTS_DIRECTORY_NAME: &str = "Genarrative GameAgent";
|
||||
|
||||
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}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn get_default_local_game_project_path(app: tauri::AppHandle) -> Result<String, String> {
|
||||
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<InitLocalProjectResult, String> {
|
||||
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<InitLocalProjectResult, String> {
|
||||
create_automatic_local_game_project_at(&automatic_local_game_projects_root(&app)?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn init_local_game_project(
|
||||
project_path: String,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -622,6 +622,27 @@ export function App({
|
||||
);
|
||||
const localProjectPathRef = useRef<string | null>(null);
|
||||
localProjectPathRef.current = localProject?.projectPath ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (projectPath || initialProjectPath) {
|
||||
return;
|
||||
}
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
void invoke<string>('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<
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<section className="launcher-page launcher-projects-page">
|
||||
<header>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string>('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');
|
||||
}, [
|
||||
|
||||
@@ -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<string>(
|
||||
'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<InitLocalProjectResult>(
|
||||
'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,
|
||||
|
||||
@@ -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<HomeAttachmentDraft[]> =
|
||||
@@ -104,9 +103,27 @@ function selectEditableEndWhenNeeded() {
|
||||
|
||||
function EditorPlugins({
|
||||
onChange,
|
||||
}: Pick<RichInputAreaProps, 'onChange'>) {
|
||||
onEnter,
|
||||
}: Pick<RichInputAreaProps, 'onChange' | 'onEnter'>) {
|
||||
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}
|
||||
</div>
|
||||
<EditorPlugins onChange={props.onChange} />
|
||||
<EditorPlugins onChange={props.onChange} onEnter={props.onEnter} />
|
||||
</LexicalComposer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string>;
|
||||
onCreateDraftAutomatically: (draft: HomeDraft) => Promise<string>;
|
||||
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<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
async function createFromHome(
|
||||
createDraft: (draft: HomeDraft) => Promise<string>,
|
||||
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<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
void createFromHome(onCreateDraft, '请选择项目目录');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="platform-theme platform-theme--light min-h-screen bg-(image:--platform-body-fill) text-(--platform-text-strong)">
|
||||
<section
|
||||
@@ -164,6 +177,9 @@ export default function HomeView({
|
||||
value={homeRichText}
|
||||
placeholder={activeHomeMode.placeholder}
|
||||
onChange={setHomeRichText}
|
||||
onEnter={() => {
|
||||
void createFromHome(onCreateDraftAutomatically, '正在创建工作区');
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-[1fr_auto] items-center gap-2.5 text-[12px] text-(--platform-text-soft)">
|
||||
<UploadButton />
|
||||
|
||||
@@ -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<string, unknown>) => {
|
||||
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-/),
|
||||
|
||||
@@ -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/<runId>.json` 的 step、taskGraph、passPlans、lifecycleStatus,以及 `read_game_creator_agent_runtimes` 批量读取的 `.agent/runtime/agents/<taskId>.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/<agentId>/` 下的规范 Session。普通用户窗口不得出现 `Agent 聊天` 导航、picker 或工具台入口。
|
||||
|
||||
Reference in New Issue
Block a user