修正首页自动建项目后进入项目对话
恢复首页自动项目创建并移除无项目对话入口 首条正文与附件在新项目工作台内交给直连 Codex 补充 Tauri 命令、配置门禁、AppSurface 测试与迁移计划
This commit is contained in:
@@ -978,13 +978,18 @@ assertCommandNamesSubset(
|
||||
parseTauriHandlerCommandNames(tauriHandlerSource),
|
||||
);
|
||||
|
||||
const tauriHandlerCommandNames =
|
||||
parseTauriHandlerCommandNames(tauriHandlerSource);
|
||||
if (!tauriHandlerCommandNames.includes('create_automatic_local_game_project')) {
|
||||
throw new Error(
|
||||
'AI game creator shell home creation must expose automatic project creation',
|
||||
);
|
||||
}
|
||||
if (
|
||||
parseTauriHandlerCommandNames(tauriHandlerSource).includes(
|
||||
'create_automatic_local_game_project',
|
||||
)
|
||||
tauriHandlerCommandNames.includes('chat_with_game_creator_home_direct_codex')
|
||||
) {
|
||||
throw new Error(
|
||||
'AI game creator shell must not expose automatic project creation to the frontend',
|
||||
'AI game creator shell home surface must not expose a projectless Codex conversation',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -245,7 +245,15 @@ pub(crate) fn closest_existing_project_picker_directory(path: &Path) -> Option<P
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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}"))
|
||||
}
|
||||
|
||||
pub(crate) fn create_automatic_local_game_project_at(
|
||||
projects_root: &Path,
|
||||
) -> Result<InitLocalProjectResult, String> {
|
||||
@@ -301,6 +309,13 @@ pub(crate) fn create_automatic_local_game_project_at(
|
||||
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,
|
||||
|
||||
@@ -2256,6 +2256,7 @@ fn main() {
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
create_automatic_local_game_project,
|
||||
init_local_game_project,
|
||||
import_local_godot_project,
|
||||
is_local_project_directory_non_empty,
|
||||
@@ -2269,7 +2270,6 @@ fn main() {
|
||||
chat_with_game_creator_role_agent,
|
||||
chat_with_game_creator_role_agent_stream,
|
||||
chat_with_game_creator_direct_codex,
|
||||
chat_with_game_creator_home_direct_codex,
|
||||
start_game_creator_agent_runtime_task,
|
||||
start_game_creator_supervisor_runtime_task,
|
||||
compact_game_creator_agent_runtime_context,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { launcherNotifications } from '../../app/constants';
|
||||
import { closeDialogOnEscape } from '../../app/dialogs';
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import type { LocalGameProjectRevisionStatus } from '../../app/types';
|
||||
import HomeView, { type HomeDraft } from '../../view/home';
|
||||
import HomeView from '../../view/home';
|
||||
import { type LauncherView, Sidebar } from '../../view/layout';
|
||||
import ProjectDevelopmentView from '../../view/project-development';
|
||||
import {
|
||||
@@ -67,7 +67,7 @@ export function WorkspaceLauncherShell({
|
||||
activeProjectAgentResults,
|
||||
setAgentResults: setActiveProjectAgentResults,
|
||||
resetLauncherHomeDraft,
|
||||
createHomeDraft,
|
||||
createHomeDraftAutomatically,
|
||||
openProject,
|
||||
} = homeProject;
|
||||
const activeProjectContextRef = useRef(currentProjectContext);
|
||||
@@ -231,38 +231,6 @@ export function WorkspaceLauncherShell({
|
||||
'美术生成将接入平台 API',
|
||||
];
|
||||
|
||||
const sendHomeMessage = useCallback(
|
||||
async (draft: HomeDraft) => {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
throw new Error('需要在陶泥儿客户端内运行');
|
||||
}
|
||||
const result = await invoke<{
|
||||
reply: string;
|
||||
requestProjectCreation: boolean;
|
||||
}>('chat_with_game_creator_home_direct_codex', {
|
||||
prompt: draft.prompt,
|
||||
attachments: draft.attachments.map(({ file }) => ({
|
||||
name: file.name,
|
||||
mediaType: file.type,
|
||||
size: file.size,
|
||||
})),
|
||||
});
|
||||
if (!result.requestProjectCreation) {
|
||||
return { status: '陶泥儿已回复', reply: result.reply };
|
||||
}
|
||||
const creationResult = await createHomeDraft(draft);
|
||||
return {
|
||||
status:
|
||||
creationResult === '已取消'
|
||||
? '陶泥儿已说明创作方向;尚未创建项目'
|
||||
: creationResult,
|
||||
reply: result.reply,
|
||||
};
|
||||
},
|
||||
[createHomeDraft],
|
||||
);
|
||||
|
||||
return (
|
||||
<main
|
||||
className="launcher-shell platform-theme platform-theme--light"
|
||||
@@ -308,7 +276,7 @@ export function WorkspaceLauncherShell({
|
||||
status={status}
|
||||
onStatusChange={setStatus}
|
||||
recentProjectRows={recentProjectRows}
|
||||
onSendHomeMessage={sendHomeMessage}
|
||||
onCreateDraftAutomatically={createHomeDraftAutomatically}
|
||||
onProjectsOpen={() => setLauncherView('projects')}
|
||||
onProjectOpen={(path) => {
|
||||
setProjectPath(path);
|
||||
|
||||
@@ -453,6 +453,33 @@ export function useHomeProjectCreation({
|
||||
);
|
||||
}
|
||||
|
||||
async function createHomeDraftAutomatically(draft: HomeDraft) {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
throw new Error('需要在陶泥儿客户端内运行');
|
||||
}
|
||||
setStatus('正在创建工作区');
|
||||
const result = await invoke<InitLocalProjectResult>(
|
||||
'create_automatic_local_game_project',
|
||||
);
|
||||
try {
|
||||
await enterCreatedHomeProject(
|
||||
invoke,
|
||||
result,
|
||||
draft.prompt,
|
||||
draft.attachments,
|
||||
);
|
||||
setStatus('已创建工作区,正在开始智能创作');
|
||||
return '已创建工作区并进入项目开发';
|
||||
} catch (error) {
|
||||
const message = `工作区已创建;首条需求投递失败:${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`;
|
||||
setStatus(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function pickAndOpenProject() {
|
||||
if (projectActionRef.current) {
|
||||
return;
|
||||
@@ -539,6 +566,7 @@ export function useHomeProjectCreation({
|
||||
pendingNonEmptyProject,
|
||||
resetLauncherHomeDraft,
|
||||
createHomeDraft,
|
||||
createHomeDraftAutomatically,
|
||||
openProject,
|
||||
pickAndOpenProject,
|
||||
pickAndCreateProject,
|
||||
|
||||
@@ -19,22 +19,12 @@ export type HomeProjectRow = {
|
||||
canOpen: boolean;
|
||||
};
|
||||
|
||||
export type HomeChatResult = {
|
||||
status: string;
|
||||
reply: string;
|
||||
};
|
||||
|
||||
type HomeChatMessage = {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
};
|
||||
|
||||
type HomeViewProps = {
|
||||
hasPromo: boolean;
|
||||
status: string;
|
||||
onStatusChange: (status: string) => void;
|
||||
recentProjectRows: readonly HomeProjectRow[];
|
||||
onSendHomeMessage: (draft: HomeDraft) => Promise<HomeChatResult>;
|
||||
onCreateDraftAutomatically: (draft: HomeDraft) => Promise<string>;
|
||||
onProjectsOpen: () => void;
|
||||
onProjectOpen: (path: string) => void;
|
||||
onProjectPick: () => void;
|
||||
@@ -45,7 +35,7 @@ export default function HomeView({
|
||||
status,
|
||||
onStatusChange,
|
||||
recentProjectRows,
|
||||
onSendHomeMessage,
|
||||
onCreateDraftAutomatically,
|
||||
onProjectsOpen,
|
||||
onProjectOpen,
|
||||
onProjectPick,
|
||||
@@ -54,57 +44,40 @@ export default function HomeView({
|
||||
const setHomeRichText = useLauncherHomeDraftStore(
|
||||
(state) => state.setRichText,
|
||||
);
|
||||
const resetHomeRichText = useLauncherHomeDraftStore((state) => state.reset);
|
||||
const [homeMessageBusy, setHomeMessageBusy] = useState(false);
|
||||
const homeMessageBusyRef = useRef(false);
|
||||
const [homeComposerRevision, setHomeComposerRevision] = useState(0);
|
||||
const [homeMessages, setHomeMessages] = useState<HomeChatMessage[]>([]);
|
||||
const [homeCreationBusy, setHomeCreationBusy] = useState(false);
|
||||
const homeCreationBusyRef = useRef(false);
|
||||
|
||||
async function sendFromHome() {
|
||||
if (homeMessageBusyRef.current) {
|
||||
async function createFromHome() {
|
||||
if (homeCreationBusyRef.current) {
|
||||
return;
|
||||
}
|
||||
const referencedAttachments = richTextToAttachments(homeRichText);
|
||||
const prompt = richTextToPrompt(homeRichText);
|
||||
if (!prompt && referencedAttachments.length === 0) {
|
||||
onStatusChange('请输入想和陶泥儿聊的内容或上传参考附件');
|
||||
onStatusChange('请输入创作需求或上传参考附件');
|
||||
return;
|
||||
}
|
||||
homeMessageBusyRef.current = true;
|
||||
setHomeMessageBusy(true);
|
||||
onStatusChange('陶泥儿正在回复');
|
||||
const visibleUserMessage =
|
||||
prompt ||
|
||||
`附件:${referencedAttachments
|
||||
.map((attachment) => attachment.file.name)
|
||||
.join('、')}`;
|
||||
setHomeMessages((current) => [
|
||||
...current,
|
||||
{ role: 'user', content: visibleUserMessage },
|
||||
]);
|
||||
homeCreationBusyRef.current = true;
|
||||
setHomeCreationBusy(true);
|
||||
onStatusChange('正在创建工作区');
|
||||
try {
|
||||
const result = await onSendHomeMessage({
|
||||
prompt,
|
||||
attachments: referencedAttachments,
|
||||
});
|
||||
setHomeMessages((current) => [
|
||||
...current,
|
||||
{ role: 'assistant', content: result.reply },
|
||||
]);
|
||||
onStatusChange(result.status);
|
||||
resetHomeRichText();
|
||||
setHomeComposerRevision((current) => current + 1);
|
||||
onStatusChange(
|
||||
await onCreateDraftAutomatically({
|
||||
prompt,
|
||||
attachments: referencedAttachments,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
onStatusChange(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
homeMessageBusyRef.current = false;
|
||||
setHomeMessageBusy(false);
|
||||
homeCreationBusyRef.current = false;
|
||||
setHomeCreationBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleHomeSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
void sendFromHome();
|
||||
void createFromHome();
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -136,12 +109,11 @@ export default function HomeView({
|
||||
onSubmit={handleHomeSubmit}
|
||||
>
|
||||
<RichInputArea
|
||||
key={homeComposerRevision}
|
||||
value={homeRichText}
|
||||
placeholder="和陶泥儿聊聊你的灵感或问题"
|
||||
placeholder="告诉陶泥儿你想创建什么"
|
||||
onChange={setHomeRichText}
|
||||
onEnter={() => {
|
||||
void sendFromHome();
|
||||
void createFromHome();
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-[1fr_auto] items-center gap-2.5 text-[12px] text-(--platform-text-soft)">
|
||||
@@ -149,33 +121,14 @@ export default function HomeView({
|
||||
<button
|
||||
className="grid size-6 cursor-pointer place-items-center rounded-full border-0 bg-(image:--platform-button-primary-fill) p-0 text-(--platform-button-primary-text) shadow-(--platform-profile-action-shadow) disabled:cursor-not-allowed disabled:opacity-55"
|
||||
type="submit"
|
||||
aria-label="发送给陶泥儿"
|
||||
disabled={homeMessageBusy}
|
||||
aria-label="开启创作"
|
||||
disabled={homeCreationBusy}
|
||||
>
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</RichInputArea>
|
||||
</form>
|
||||
{homeMessages.length > 0 ? (
|
||||
<section
|
||||
className="grid w-[min(760px,calc(100vw-122px))] gap-2 rounded-[18px] border border-(--platform-surface-border) bg-(image:--platform-subpanel-fill) p-3 text-[13px] shadow-(--platform-panel-shadow) max-[760px]:w-[min(100%,calc(100vw-76px))]"
|
||||
aria-label="陶泥儿首页对话"
|
||||
>
|
||||
{homeMessages.map((message, index) => (
|
||||
<p
|
||||
className={
|
||||
message.role === 'user'
|
||||
? 'm-0 justify-self-end rounded-xl bg-(--platform-warm-bg) px-3 py-2 text-(--platform-warm-text)'
|
||||
: 'm-0 justify-self-start whitespace-pre-wrap rounded-xl bg-(--platform-input-fill) px-3 py-2 text-(--platform-text-strong)'
|
||||
}
|
||||
key={`${message.role}:${index}:${message.content}`}
|
||||
>
|
||||
{message.content}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section
|
||||
@@ -188,7 +141,7 @@ export default function HomeView({
|
||||
最近项目
|
||||
</h2>
|
||||
<p className="mb-0 mt-1 text-[12px] text-(--platform-text-soft)">
|
||||
{status}
|
||||
选择一个项目继续创作
|
||||
</p>
|
||||
</div>
|
||||
<div className="launcher-project-list-actions">
|
||||
|
||||
@@ -1271,91 +1271,109 @@ export function registerHomeProjectCreationTests() {
|
||||
expect(await screen.findByText('已取消')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('sends ordinary home questions to the isolated Codex conversation without project side effects', async () => {
|
||||
it('creates a project before handling every home message inside the project Codex conversation', async () => {
|
||||
const automaticProjectPath =
|
||||
'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\gameagent-home-message';
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'home-message-project',
|
||||
'首页消息项目',
|
||||
);
|
||||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||||
projectPath: automaticProjectPath,
|
||||
initialSessionExists: false,
|
||||
});
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'chat_with_game_creator_home_direct_codex') {
|
||||
const prompt = String(args?.prompt ?? '');
|
||||
if (command === 'create_automatic_local_game_project') {
|
||||
return {
|
||||
reply:
|
||||
prompt === '你好'
|
||||
? '你好!我是陶泥儿。'
|
||||
: '今天是 2026 年 8 月 20 日。',
|
||||
requestProjectCreation: false,
|
||||
projectPath: automaticProjectPath,
|
||||
manifestPath: `${automaticProjectPath}\\.agent\\manifest.json`,
|
||||
manifest,
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
if (command === 'chat_with_game_creator_direct_codex') {
|
||||
return '别这么骂自己,具体发生什么了?';
|
||||
}
|
||||
return supervisorHarness.invoke(command, args);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
window.__TAURI__ = {
|
||||
core: { invoke },
|
||||
event: { listen: supervisorHarness.listen },
|
||||
};
|
||||
renderLauncherAt('/?launcher', 'home', true);
|
||||
|
||||
const promptInput = screen.getByLabelText('创作想法');
|
||||
nativeClipboardMock.text = '你好';
|
||||
nativeClipboardMock.text = '你好,今天多少号';
|
||||
fireEvent.paste(promptInput);
|
||||
await waitFor(() => {
|
||||
expect(promptInput.textContent).toContain('你好');
|
||||
expect(promptInput.textContent).toContain('你好,今天多少号');
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送给陶泥儿' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '开启创作' }));
|
||||
|
||||
expect(await screen.findByText('你好!我是陶泥儿。')).not.toBeNull();
|
||||
expect(await screen.findByLabelText('项目开发工作台')).not.toBeNull();
|
||||
const projectConversation = screen.getByLabelText('陶泥儿项目对话');
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'chat_with_game_creator_home_direct_codex',
|
||||
{
|
||||
prompt: '你好',
|
||||
attachments: [],
|
||||
},
|
||||
expect(projectConversation.textContent).toContain('你好,今天多少号');
|
||||
expect(projectConversation.textContent).toContain(
|
||||
'别这么骂自己,具体发生什么了?',
|
||||
);
|
||||
});
|
||||
|
||||
const secondPromptInput = screen.getByLabelText('创作想法');
|
||||
nativeClipboardMock.text = '今天多少号';
|
||||
fireEvent.paste(secondPromptInput);
|
||||
await waitFor(() => {
|
||||
expect(secondPromptInput.textContent).toContain('今天多少号');
|
||||
});
|
||||
fireEvent.keyDown(secondPromptInput, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(
|
||||
await screen.findByText('今天是 2026 年 8 月 20 日。'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) => command === 'chat_with_game_creator_home_direct_codex',
|
||||
([command]) => command === 'create_automatic_local_game_project',
|
||||
),
|
||||
).toHaveLength(2);
|
||||
for (const command of [
|
||||
'create_automatic_local_game_project',
|
||||
'pick_local_project_directory',
|
||||
'is_local_project_directory_non_empty',
|
||||
'init_local_game_project',
|
||||
'upload_local_asset',
|
||||
'chat_with_game_creator_direct_codex',
|
||||
'generate_platform_art_asset',
|
||||
'start_local_game_preview',
|
||||
'start_game_creator_supervisor_runtime_task',
|
||||
'resume_game_creator_agent_runtime_tasks',
|
||||
]) {
|
||||
expect(
|
||||
invoke.mock.calls.some(([calledCommand]) => calledCommand === command),
|
||||
).toBe(false);
|
||||
}
|
||||
expect(screen.queryByLabelText('项目开发工作台')).toBeNull();
|
||||
expect(screen.queryByLabelText('Agent 分类')).toBeNull();
|
||||
).toHaveLength(1);
|
||||
expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', {
|
||||
projectPath: automaticProjectPath,
|
||||
prompt: '你好,今天多少号',
|
||||
});
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'chat_with_game_creator_home_direct_codex',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(screen.queryByLabelText('陶泥儿首页对话')).toBeNull();
|
||||
});
|
||||
|
||||
it('forwards safe home attachment metadata to Codex without creating a project', async () => {
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'chat_with_game_creator_home_direct_codex') {
|
||||
return {
|
||||
reply: '我看到了附件说明,可以先聊聊你希望如何使用它。',
|
||||
requestProjectCreation: false,
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
it('imports home attachments into the automatic project before the project Codex turn', async () => {
|
||||
const automaticProjectPath =
|
||||
'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\gameagent-home-attachment';
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'home-attachment-project',
|
||||
'首页附件项目',
|
||||
);
|
||||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||||
projectPath: automaticProjectPath,
|
||||
initialSessionExists: false,
|
||||
});
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
const fileBytes = Array.from(new TextEncoder().encode('png'));
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'create_automatic_local_game_project') {
|
||||
return {
|
||||
projectPath: automaticProjectPath,
|
||||
manifestPath: `${automaticProjectPath}\\.agent\\manifest.json`,
|
||||
manifest,
|
||||
};
|
||||
}
|
||||
if (command === 'upload_local_asset') {
|
||||
return {
|
||||
id: 'asset-upload-1',
|
||||
localPath: 'assets/uploads/reference.png',
|
||||
absolutePath: `${automaticProjectPath}\\assets\\uploads\\reference.png`,
|
||||
manifestPath: `${automaticProjectPath}\\.agent\\manifest.json`,
|
||||
};
|
||||
}
|
||||
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);
|
||||
|
||||
const fileInput =
|
||||
@@ -1365,110 +1383,50 @@ export function registerHomeProjectCreationTests() {
|
||||
type: 'image/png',
|
||||
lastModified: 1,
|
||||
});
|
||||
Object.defineProperty(attachment, 'arrayBuffer', {
|
||||
value: async () => new Uint8Array(fileBytes).buffer,
|
||||
});
|
||||
fireEvent.change(fileInput!, { target: { files: [attachment] } });
|
||||
|
||||
const promptInput = screen.getByLabelText('创作想法');
|
||||
nativeClipboardMock.text = '先看看这个附件';
|
||||
nativeClipboardMock.text = '按这个角色做游戏';
|
||||
fireEvent.paste(promptInput);
|
||||
await waitFor(() => {
|
||||
expect(promptInput.textContent).toContain('先看看这个附件');
|
||||
expect(promptInput.textContent).toContain('按这个角色做游戏');
|
||||
expect(promptInput.textContent).toContain('角色参考.png');
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送给陶泥儿' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '开启创作' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('我看到了附件说明,可以先聊聊你希望如何使用它。'),
|
||||
).not.toBeNull();
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
expect(await screen.findByLabelText('项目开发工作台')).not.toBeNull();
|
||||
expect(invoke).toHaveBeenCalledWith('upload_local_asset', {
|
||||
projectPath: automaticProjectPath,
|
||||
fileName: '角色参考.png',
|
||||
mediaType: 'image/png',
|
||||
bytes: fileBytes,
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', {
|
||||
projectPath: automaticProjectPath,
|
||||
prompt: expect.stringContaining('按这个角色做游戏'),
|
||||
});
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'chat_with_game_creator_home_direct_codex',
|
||||
{
|
||||
prompt: '先看看这个附件',
|
||||
attachments: [
|
||||
{
|
||||
name: '角色参考.png',
|
||||
mediaType: 'image/png',
|
||||
size: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(
|
||||
invoke.mock.calls.some(
|
||||
([command]) => command === 'pick_local_project_directory',
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
const attachmentOnlyInput =
|
||||
document.querySelector<HTMLInputElement>('input[type="file"]');
|
||||
expect(attachmentOnlyInput).not.toBeNull();
|
||||
const attachmentOnly = new File(['{}'], '只发附件.json', {
|
||||
type: 'application/json',
|
||||
lastModified: 2,
|
||||
});
|
||||
fireEvent.change(attachmentOnlyInput!, {
|
||||
target: { files: [attachmentOnly] },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('创作想法').textContent).toContain(
|
||||
'只发附件.json',
|
||||
);
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送给陶泥儿' }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) => command === 'chat_with_game_creator_home_direct_codex',
|
||||
),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
expect(invoke).toHaveBeenLastCalledWith(
|
||||
'chat_with_game_creator_home_direct_codex',
|
||||
{
|
||||
prompt: '',
|
||||
attachments: [
|
||||
{
|
||||
name: '只发附件.json',
|
||||
mediaType: 'application/json',
|
||||
size: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a project only after the home Codex reply explicitly requests it', async () => {
|
||||
const projectPath = 'C:\\Users\\tester\\Documents\\taonier-match-three';
|
||||
const pendingInitialization = new Promise<never>(() => undefined);
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'chat_with_game_creator_home_direct_codex') {
|
||||
return {
|
||||
reply:
|
||||
'我已理解需求。请选择一个项目文件夹,我会继续完成这个三消游戏。',
|
||||
requestProjectCreation: true,
|
||||
};
|
||||
}
|
||||
if (command === 'pick_local_project_directory') {
|
||||
return projectPath;
|
||||
}
|
||||
if (command === 'is_local_project_directory_non_empty') {
|
||||
expect(args).toEqual({ projectPath });
|
||||
return false;
|
||||
}
|
||||
if (command === 'init_local_game_project') {
|
||||
expect(args).toEqual(
|
||||
expect.objectContaining({
|
||||
projectPath,
|
||||
name: 'taonier-match-three',
|
||||
}),
|
||||
);
|
||||
return pendingInitialization;
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
it('keeps the home composer out of chat mode while automatic project creation is pending', async () => {
|
||||
let rejectAutomaticProject: ((error: Error) => void) | null = null;
|
||||
const automaticProject = new Promise<never>((_resolve, reject) => {
|
||||
rejectAutomaticProject = reject;
|
||||
});
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'create_automatic_local_game_project') {
|
||||
return automaticProject;
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
});
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
renderLauncherAt('/?launcher', 'home');
|
||||
renderLauncherAt('/?launcher', 'home', true);
|
||||
|
||||
const promptInput = screen.getByLabelText('创作想法');
|
||||
nativeClipboardMock.text = '做一个三消游戏';
|
||||
@@ -1476,41 +1434,34 @@ export function registerHomeProjectCreationTests() {
|
||||
await waitFor(() => {
|
||||
expect(promptInput.textContent).toContain('做一个三消游戏');
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送给陶泥儿' }));
|
||||
const createButton = screen.getByRole('button', { name: '开启创作' });
|
||||
fireEvent.click(createButton);
|
||||
fireEvent.click(createButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const homeRequestIndex = invoke.mock.calls.findIndex(
|
||||
([command]) => command === 'chat_with_game_creator_home_direct_codex',
|
||||
);
|
||||
const pickerIndex = invoke.mock.calls.findIndex(
|
||||
([command]) => command === 'pick_local_project_directory',
|
||||
);
|
||||
const initIndex = invoke.mock.calls.findIndex(
|
||||
([command]) => command === 'init_local_game_project',
|
||||
);
|
||||
expect(homeRequestIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(invoke.mock.calls[homeRequestIndex]).toEqual([
|
||||
'chat_with_game_creator_home_direct_codex',
|
||||
{ prompt: '做一个三消游戏', attachments: [] },
|
||||
]);
|
||||
expect(pickerIndex).toBeGreaterThan(homeRequestIndex);
|
||||
expect(initIndex).toBeGreaterThan(pickerIndex);
|
||||
expect(invoke.mock.calls[initIndex]).toEqual([
|
||||
'init_local_game_project',
|
||||
expect.objectContaining({ projectPath }),
|
||||
]);
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) => command === 'create_automatic_local_game_project',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
expect(
|
||||
invoke.mock.calls.some(
|
||||
([command]) => command === 'create_automatic_local_game_project',
|
||||
),
|
||||
).toBe(false);
|
||||
expect(screen.queryByText(/初始意图/u)).toBeNull();
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) => command === 'start_game_creator_supervisor_runtime_task',
|
||||
),
|
||||
).toHaveLength(0);
|
||||
expect((createButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(screen.queryByLabelText('陶泥儿首页对话')).toBeNull();
|
||||
expect(screen.getByLabelText('最近项目').textContent).toContain(
|
||||
'选择一个项目继续创作',
|
||||
);
|
||||
expect(screen.getByLabelText('最近项目').textContent).not.toContain(
|
||||
'正在创建工作区',
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
rejectAutomaticProject?.(new Error('自动创建测试结束'));
|
||||
await automaticProject.catch(() => undefined);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect((createButton as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
expect(screen.getAllByText('自动创建测试结束')).not.toHaveLength(0);
|
||||
});
|
||||
|
||||
it('hydrates an existing project before sending a direct Codex turn', async () => {
|
||||
|
||||
@@ -123,28 +123,21 @@
|
||||
2. 不取消 `cargo:rerun-if-changed` 对上游侧车与声明文件的监听;上游升级仍必须触发重新 stage,运行时仍拒绝 hash 不匹配的内置可执行文件。
|
||||
3. 真实回归:从干净的本次 AGC dev 进程重启,确认一次必要的首次构建后不再出现连续的 `codex.exe changed` 重建,Tauri 窗口稳定打开;再完成 direct Codex 普通对话与项目修改的客户端验收。
|
||||
|
||||
## 7.3 首页无项目陶泥儿对话收口(2026-08-20)
|
||||
|
||||
### 现场偏差
|
||||
|
||||
- 首页仍保留“做游戏 / 做素材 / 做方案”分类器,并在提交时无条件调用 `create_automatic_local_game_project`。
|
||||
- 因此“你好”“今天多少号”等普通问题也会创建可见 `gameagent-*` 项目,并被前端改写为“初始意图:...”。这既不是用户原话,也违反了“由 Codex 自己理解意图”的直连边界。
|
||||
- 自动创建后再进入项目聊天,页面还会显示“自动执行”“项目总控”等旧 Runtime 产品文案,容易让人误判仍在运行 Supervisor。
|
||||
## 7.3 首页自动创建项目与项目内对话收口(2026-08-20)
|
||||
|
||||
### 收口契约
|
||||
|
||||
1. 首页只保留一个“陶泥儿”输入入口;移除模式选择、首条 `初始意图` 包装和由首页触发的自动项目创建。用户输入及附件说明原样交给独立的 home Codex thread。
|
||||
2. home thread 使用临时、隔离、只读工作区:不绑定用户项目目录、不读取项目 `.agent`、不生成陶泥儿素材、不启动预览/试玩、不登记版本,也不创建可见项目或最近项目记录。客户端在 home thread 期间拒绝所有写入、命令、MCP、权限扩大和文件变更审批。
|
||||
3. Codex 是唯一意图判断者。普通问题直接回复;当它判断用户明确要开始游戏创作时,只能返回一个受限的“请求创建工作区”动作,客户端据此展示创建入口或进入既有新建项目流程。前端不得重新按关键词分类或自行创建目录。
|
||||
4. 当前项目工作台继续复用 project-bound direct Codex thread,可读写当前项目;其产品文案统一使用“陶泥儿”“智能创作”,不显示“项目总控”“自动执行”“Supervisor”或“专业 Agent”。旧 Runtime 只保留在开发诊断入口。
|
||||
5. 首页回归必须至少覆盖“你好”“今天多少号”和显式“做一个三消游戏”:前两项只产生 home direct turn、没有项目/美术/预览/版本副作用;第三项由 Codex 的受限创建请求触发,而不是由客户端关键词分类触发。真实桌面端需从当前 checkout 逐项验证。
|
||||
1. 首页只保留一个“陶泥儿”创作入口,不展示模式选择,也不在首页渲染用户或助手消息气泡。
|
||||
2. 每次提交非空正文或附件时,客户端对且只对本次提交调用一次 `create_automatic_local_game_project`,在系统文档目录的 `Genarrative GameAgent/` 下分配唯一 `gameagent-*` 工作区;不弹目录选择器,也不复用最近项目。
|
||||
3. 工作区初始化后先把附件导入该项目,再立即进入项目开发工作台;用户原始正文作为 `initialPrompt` 交给 project-bound direct Codex thread。意图理解、是否修改游戏以及后续试玩均在项目内完成,首页不运行 projectless Codex 对话。
|
||||
4. 项目工作台产品文案统一使用“陶泥儿”“智能创作”,不显示“项目总控”“自动执行”“Supervisor”或“专业 Agent”。客户端只做项目创建、附件导入和确定性投影,不恢复旧多 Agent Runtime。
|
||||
5. 首页的创建状态只显示在主输入区;“最近项目”使用独立静态说明,不能复用“正在创建工作区 / 正在回复 / 已回复”等全局状态。
|
||||
|
||||
### 实施补强
|
||||
### 验收合同
|
||||
|
||||
- 创建协议只接受回复原始第一行精确为 `[[AGC_CREATE_PROJECT]]`(可使用 CRLF);前置空白、解释前缀、第二行标记或标记后拼接其它字符一律只是普通回复,不能创建项目。
|
||||
- `DirectHome` 的 app-server 连接池身份独立于可写项目会话;`thread/start` 固定 `approvalPolicy=never` 与 `sandbox=read-only`,`turn/start` 不发送可写 `sandboxPolicy`。任何 file-change、command、MCP 或审批 item 均失败关闭。
|
||||
- 已移除旧 `create_automatic_local_game_project` 的 Tauri 前端 handler;首页和普通 WebView 即使被错误调用也不能绕过 Codex 的受限创建请求直接落盘。
|
||||
- 首页附件以独立结构化参数传入:用户正文保持原样,编辑器附件占位符不得混入正文;仅补充有界的文件名、媒体类型和字节数,不传本地路径、二进制内容或浏览器 `File` 对象。允许只发附件说明;home Codex 只能理解元数据,创建工作区并经用户确认后才由既有导入流程读取实际附件。
|
||||
- 普通文本、游戏需求和带附件需求都必须先创建项目,再在 `陶泥儿项目对话` 中出现同一条原始用户消息与 Codex 回复;`chat_with_game_creator_home_direct_codex` 不得暴露为首页 Tauri handler。
|
||||
- 连续点击或连续 Enter 只能创建一个工作区;创建进行中按钮禁用,但编辑器不得生成首页聊天气泡。
|
||||
- 附件必须经 `upload_local_asset` 写入新项目后再交给项目 Codex;不得只把附件元数据留在首页,也不得把浏览器本地路径写入聊天正文。
|
||||
|
||||
## 7.4 真实项目验收发现的 Codex 原生组件闭包(2026-08-20)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user