重构AGC项目管理界面
参考成熟桌面项目管理器重构紧凑项目表、搜索和行尾菜单 支持Windows路径及Godot根目录和一层子目录工程识别与导入 修复1280×720页面溢出、列表滚动和末行菜单裁切 补充项目页回归测试、PRD、技术方案和项目记忆
This commit is contained in:
@@ -1,9 +1,153 @@
|
||||
import { FolderKanban, FolderOpen, FolderPlus } from 'lucide-react';
|
||||
import {
|
||||
CircleAlert,
|
||||
Ellipsis,
|
||||
FolderKanban,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
Gamepad2,
|
||||
Search,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
import { closeDialogOnEscape } from '../../app/dialogs';
|
||||
import type { RecentProjectRow } from './model';
|
||||
import type { HomeProjectCreationController } from './useHomeProjectCreation';
|
||||
import type { RecentProjectsController } from './useRecentProjects';
|
||||
|
||||
function projectKindLabel(project: RecentProjectRow) {
|
||||
if (project.projectKind === 'godot') {
|
||||
return project.godotProjectRoot && project.godotProjectRoot !== '.'
|
||||
? `Godot · ${project.godotProjectRoot}`
|
||||
: 'Godot';
|
||||
}
|
||||
if (project.projectKind === 'web') {
|
||||
return 'GameAgent';
|
||||
}
|
||||
return '待识别';
|
||||
}
|
||||
|
||||
function projectStatusTone(project: RecentProjectRow) {
|
||||
if (project.canOpen) {
|
||||
return 'ready';
|
||||
}
|
||||
if (project.status === '检查中') {
|
||||
return 'checking';
|
||||
}
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
function ProjectMoreMenu({
|
||||
project,
|
||||
recentProjects,
|
||||
}: {
|
||||
project: RecentProjectRow;
|
||||
recentProjects: RecentProjectsController;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dropUp, setDropUp] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
setDropUp(false);
|
||||
return;
|
||||
}
|
||||
const trigger = triggerRef.current;
|
||||
const menu = menuRef.current?.querySelector<HTMLElement>(
|
||||
'.launcher-project-more-menu',
|
||||
);
|
||||
const table = trigger?.closest<HTMLElement>('.launcher-project-table');
|
||||
if (!trigger || !menu || !table) {
|
||||
return;
|
||||
}
|
||||
const triggerBounds = trigger.getBoundingClientRect();
|
||||
const tableBounds = table.getBoundingClientRect();
|
||||
const menuHeight = menu.offsetHeight;
|
||||
const availableBelow =
|
||||
Math.min(window.innerHeight, tableBounds.bottom) - triggerBounds.bottom;
|
||||
const availableAbove = triggerBounds.top - Math.max(0, tableBounds.top);
|
||||
setDropUp(
|
||||
availableBelow < menuHeight + 8 && availableAbove > availableBelow,
|
||||
);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
function closeIfOutside(event: PointerEvent) {
|
||||
if (
|
||||
event.target instanceof Node &&
|
||||
!menuRef.current?.contains(event.target)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
function closeOnEscape(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape') {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
document.addEventListener('pointerdown', closeIfOutside);
|
||||
document.addEventListener('keydown', closeOnEscape);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', closeIfOutside);
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="launcher-project-more" ref={menuRef}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="launcher-project-more-trigger"
|
||||
aria-label={`${project.name}的更多操作`}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<Ellipsis size={18} aria-hidden="true" />
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
className={`launcher-project-more-menu${dropUp ? ' launcher-project-more-menu-drop-up' : ''}`}
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={!project.canReveal}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
void recentProjects.handleRevealProjectDirectory(project.path);
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={15} aria-hidden="true" />
|
||||
显示目录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="launcher-project-remove-action"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
recentProjects.handleRecentWorkspaceRemove(project.path);
|
||||
}}
|
||||
>
|
||||
<X size={15} aria-hidden="true" />
|
||||
从列表移除
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectsPage({
|
||||
status,
|
||||
homeProject,
|
||||
@@ -13,109 +157,137 @@ export function ProjectsPage({
|
||||
homeProject: HomeProjectCreationController;
|
||||
recentProjects: RecentProjectsController;
|
||||
}) {
|
||||
const rows = recentProjects.filteredProjectRows;
|
||||
const hasStoredProjects = recentProjects.projectRows.length > 0;
|
||||
return (
|
||||
<section className="launcher-page launcher-projects-page">
|
||||
<header>
|
||||
<div>
|
||||
<h1>项目组</h1>
|
||||
<header className="launcher-projects-toolbar">
|
||||
<div className="launcher-projects-heading">
|
||||
<h1>项目</h1>
|
||||
<p className="launcher-project-page-status" aria-live="polite">
|
||||
{recentProjects.recentWorkspaceRefreshing
|
||||
? '正在检查项目状态'
|
||||
: status}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<div className="launcher-project-actions" aria-label="项目操作">
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-project-open-action"
|
||||
aria-label="打开项目"
|
||||
disabled={homeProject.projectBusy}
|
||||
onClick={() => void homeProject.pickAndOpenProject()}
|
||||
>
|
||||
<FolderOpen size={18} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>
|
||||
<div className="launcher-projects-controls">
|
||||
<label className="launcher-project-search">
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
aria-label="搜索项目"
|
||||
placeholder="搜索名称、路径、类型或状态"
|
||||
value={recentProjects.projectSearchQuery}
|
||||
onChange={(event) =>
|
||||
recentProjects.setProjectSearchQuery(event.target.value)
|
||||
}
|
||||
/>
|
||||
{recentProjects.projectSearchQuery ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="清除项目搜索"
|
||||
onClick={() => recentProjects.setProjectSearchQuery('')}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</label>
|
||||
<div className="launcher-project-actions" aria-label="项目操作">
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-project-open-action"
|
||||
aria-label="打开项目"
|
||||
disabled={homeProject.projectBusy}
|
||||
onClick={() => void homeProject.pickAndOpenProject()}
|
||||
>
|
||||
<FolderOpen size={16} aria-hidden="true" />
|
||||
{homeProject.projectAction === 'opening' ? '打开中…' : '打开项目'}
|
||||
</strong>
|
||||
<small>选择已有工作区或 Godot 工程</small>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-project-create-action"
|
||||
aria-label="新建项目"
|
||||
disabled={homeProject.projectBusy}
|
||||
onClick={() => void homeProject.pickAndCreateProject()}
|
||||
>
|
||||
<FolderPlus size={18} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-project-create-action"
|
||||
aria-label="新建项目"
|
||||
disabled={homeProject.projectBusy}
|
||||
onClick={() => void homeProject.pickAndCreateProject()}
|
||||
>
|
||||
<FolderPlus size={16} aria-hidden="true" />
|
||||
{homeProject.projectAction === 'creating'
|
||||
? '新建中…'
|
||||
: '新建项目'}
|
||||
</strong>
|
||||
<small>选择文件夹并创建工作区</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="launcher-project-table">
|
||||
{recentProjects.projectRows.length > 0 ? (
|
||||
recentProjects.projectRows.map((project) => (
|
||||
<article key={project.path}>
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-project-row-main"
|
||||
disabled={!project.canOpen}
|
||||
onClick={() => {
|
||||
homeProject.setProjectPath(project.path);
|
||||
void homeProject.openProject(project.path, 'open');
|
||||
}}
|
||||
>
|
||||
<strong>{project.name}</strong>
|
||||
<small>{project.path}</small>
|
||||
</button>
|
||||
<span>{project.status}</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!project.canOpen}
|
||||
onClick={() => {
|
||||
homeProject.setProjectPath(project.path);
|
||||
void homeProject.openProject(project.path, 'open');
|
||||
}}
|
||||
>
|
||||
打开
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`显示 ${project.path}`}
|
||||
disabled={!project.canReveal}
|
||||
onClick={() => {
|
||||
void recentProjects.handleRevealProjectDirectory(
|
||||
project.path,
|
||||
);
|
||||
}}
|
||||
>
|
||||
显示
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`移除 ${project.path}`}
|
||||
onClick={() =>
|
||||
recentProjects.handleRecentWorkspaceRemove(project.path)
|
||||
}
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<div className="launcher-empty-projects">
|
||||
<FolderKanban size={28} aria-hidden="true" />
|
||||
<strong>暂无项目</strong>
|
||||
<small>打开已有项目,或从上方新建一个项目</small>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<div className="launcher-project-list-shell" aria-label="项目列表">
|
||||
<div className="launcher-project-table-header">
|
||||
<span>项目</span>
|
||||
<span>类型</span>
|
||||
<span>状态</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
<div className="launcher-project-table">
|
||||
{rows.length > 0 ? (
|
||||
rows.map((project) => (
|
||||
<article key={project.path}>
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-project-row-main"
|
||||
aria-label={`打开项目 ${project.name}`}
|
||||
disabled={!project.canOpen}
|
||||
onClick={() => {
|
||||
homeProject.setProjectPath(project.path);
|
||||
void homeProject.openProject(project.path, 'open');
|
||||
}}
|
||||
>
|
||||
<span className="launcher-project-kind-icon">
|
||||
{project.projectKind === 'godot' ? (
|
||||
<Gamepad2 size={18} aria-hidden="true" />
|
||||
) : (
|
||||
<FolderKanban size={18} aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
<span className="launcher-project-name-cell">
|
||||
<strong>{project.name}</strong>
|
||||
<small title={project.path}>{project.path}</small>
|
||||
</span>
|
||||
</button>
|
||||
<span className="launcher-project-kind-cell">
|
||||
{projectKindLabel(project)}
|
||||
</span>
|
||||
<span
|
||||
className={`launcher-project-status launcher-project-status-${projectStatusTone(project)}`}
|
||||
>
|
||||
{projectStatusTone(project) === 'warning' ? (
|
||||
<CircleAlert size={14} aria-hidden="true" />
|
||||
) : null}
|
||||
{project.status}
|
||||
</span>
|
||||
<ProjectMoreMenu
|
||||
project={project}
|
||||
recentProjects={recentProjects}
|
||||
/>
|
||||
</article>
|
||||
))
|
||||
) : hasStoredProjects ? (
|
||||
<div className="launcher-empty-projects">
|
||||
<Search size={26} aria-hidden="true" />
|
||||
<strong>没有匹配的项目</strong>
|
||||
<small>换一个名称、路径、类型或状态试试</small>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => recentProjects.setProjectSearchQuery('')}
|
||||
>
|
||||
清除搜索
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="launcher-empty-projects">
|
||||
<FolderKanban size={28} aria-hidden="true" />
|
||||
<strong>暂无项目</strong>
|
||||
<small>使用右上角打开已有项目,或新建一个工作区</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -56,6 +56,10 @@ export type RecentProjectRow = {
|
||||
path: string;
|
||||
name: string;
|
||||
status: string;
|
||||
projectKind: 'web' | 'godot' | 'unknown';
|
||||
godotProjectRoot: string | null;
|
||||
recentRunStatus: string | null;
|
||||
recentRunStopReason: string | null;
|
||||
canReveal: boolean;
|
||||
canOpen: boolean;
|
||||
};
|
||||
@@ -275,15 +279,19 @@ export function buildRecentProjectRows(
|
||||
? '不是文件夹'
|
||||
: directoryStatus?.manifestError
|
||||
? '无法读取'
|
||||
: directoryStatus?.isGameCreatorProject === false
|
||||
? '未初始化'
|
||||
: directoryStatus?.recentRunStatus
|
||||
? `run: ${directoryStatus.recentRunStatus}${
|
||||
directoryStatus.recentRunStopReason
|
||||
? ` · ${directoryStatus.recentRunStopReason}`
|
||||
: ''
|
||||
}`
|
||||
: '本地项目';
|
||||
: directoryStatus?.isGodotProject === true &&
|
||||
directoryStatus?.isGameCreatorProject === false
|
||||
? '可导入'
|
||||
: directoryStatus?.isGameCreatorProject === false
|
||||
? '未初始化'
|
||||
: directoryStatus?.recentRunStatus
|
||||
? formatRecentProjectRunStatus(
|
||||
directoryStatus.recentRunStatus,
|
||||
directoryStatus.recentRunStopReason,
|
||||
)
|
||||
: directoryStatus?.isGodotProject
|
||||
? '可打开'
|
||||
: '本地项目';
|
||||
const canReveal =
|
||||
!recentWorkspaceRefreshing &&
|
||||
Boolean(directoryStatus) &&
|
||||
@@ -293,6 +301,14 @@ export function buildRecentProjectRows(
|
||||
path: workspace,
|
||||
name: projectName,
|
||||
status,
|
||||
projectKind: directoryStatus?.isGodotProject
|
||||
? 'godot'
|
||||
: directoryStatus?.isGameCreatorProject
|
||||
? 'web'
|
||||
: 'unknown',
|
||||
godotProjectRoot: directoryStatus?.godotProjectRoot ?? null,
|
||||
recentRunStatus: directoryStatus?.recentRunStatus ?? null,
|
||||
recentRunStopReason: directoryStatus?.recentRunStopReason ?? null,
|
||||
canReveal,
|
||||
canOpen:
|
||||
!recentWorkspaceRefreshing &&
|
||||
@@ -300,7 +316,34 @@ export function buildRecentProjectRows(
|
||||
directoryStatus?.exists !== false &&
|
||||
directoryStatus?.isDirectory !== false &&
|
||||
!directoryStatus?.manifestError &&
|
||||
directoryStatus?.isGameCreatorProject !== false,
|
||||
(directoryStatus?.isGameCreatorProject !== false ||
|
||||
directoryStatus?.isGodotProject === true),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function formatRecentProjectRunStatus(
|
||||
status: string,
|
||||
stopReason: string | null,
|
||||
) {
|
||||
const statusLabel =
|
||||
{
|
||||
completed: '已完成',
|
||||
done: '已完成',
|
||||
failed: '运行失败',
|
||||
running: '运行中',
|
||||
pending: '等待运行',
|
||||
cancelled: '已取消',
|
||||
}[status] ?? status;
|
||||
const stopReasonLabel = stopReason
|
||||
? ({
|
||||
'preview-running': '预览运行中',
|
||||
completed: '已完成',
|
||||
failed: '运行失败',
|
||||
cancelled: '已取消',
|
||||
}[stopReason] ?? stopReason)
|
||||
: null;
|
||||
return stopReasonLabel && stopReasonLabel !== statusLabel
|
||||
? `${statusLabel} · ${stopReasonLabel}`
|
||||
: statusLabel;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
|
||||
import {
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import type { LocalProjectDirectoryStatus } from '../../app/types';
|
||||
@@ -22,6 +28,7 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
const [recentWorkspaceRefreshKey, setRecentWorkspaceRefreshKey] = useState(0);
|
||||
const [recentWorkspaceRefreshing, setRecentWorkspaceRefreshing] =
|
||||
useState(false);
|
||||
const [projectSearchQuery, setProjectSearchQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const invoke = resolveTauriInvoke();
|
||||
@@ -99,11 +106,33 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
recentWorkspaceStatuses,
|
||||
recentWorkspaceRefreshing,
|
||||
);
|
||||
const filteredProjectRows = useMemo(() => {
|
||||
const normalizedQuery = projectSearchQuery.trim().toLocaleLowerCase();
|
||||
if (!normalizedQuery) {
|
||||
return projectRows;
|
||||
}
|
||||
return projectRows.filter((project) =>
|
||||
[
|
||||
project.name,
|
||||
project.path,
|
||||
project.status,
|
||||
project.godotProjectRoot ?? '',
|
||||
project.projectKind === 'godot'
|
||||
? 'godot'
|
||||
: project.projectKind === 'web'
|
||||
? 'gameagent web'
|
||||
: 'unknown',
|
||||
].some((value) => value.toLocaleLowerCase().includes(normalizedQuery)),
|
||||
);
|
||||
}, [projectRows, projectSearchQuery]);
|
||||
|
||||
return {
|
||||
recentWorkspaces,
|
||||
recentWorkspaceRefreshing,
|
||||
projectRows,
|
||||
filteredProjectRows,
|
||||
projectSearchQuery,
|
||||
setProjectSearchQuery,
|
||||
recentProjectRows: projectRows
|
||||
.filter((project) => project.canOpen)
|
||||
.slice(0, 3),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1497,6 +1497,105 @@ export function registerHomeProjectCreationTests() {
|
||||
}
|
||||
|
||||
export function registerRecentProjectsTests() {
|
||||
function openProjectMoreMenu(projectName: string) {
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: `${projectName}的更多操作` }),
|
||||
);
|
||||
return screen.getByRole('menu');
|
||||
}
|
||||
|
||||
it('renders and filters a compact project table with GameAgent and Godot metadata', async () => {
|
||||
const paths = [
|
||||
'C:\\Projects\\Skyrail',
|
||||
'C:\\Projects\\RootGodot',
|
||||
'C:\\Projects\\StudioWorkspace',
|
||||
];
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'inspect_local_project_directory') {
|
||||
const projectPath = String(args?.projectPath ?? '');
|
||||
if (projectPath === paths[0]) {
|
||||
return {
|
||||
projectPath,
|
||||
exists: true,
|
||||
isDirectory: true,
|
||||
isGameCreatorProject: true,
|
||||
isGodotProject: false,
|
||||
godotProjectRoot: null,
|
||||
projectName: '星轨工坊',
|
||||
recentRunStatus: 'done',
|
||||
recentRunStopReason: 'preview-running',
|
||||
};
|
||||
}
|
||||
if (projectPath === paths[1]) {
|
||||
return {
|
||||
projectPath,
|
||||
exists: true,
|
||||
isDirectory: true,
|
||||
isGameCreatorProject: false,
|
||||
isGodotProject: true,
|
||||
godotProjectRoot: '.',
|
||||
projectName: null,
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
projectPath,
|
||||
exists: true,
|
||||
isDirectory: true,
|
||||
isGameCreatorProject: true,
|
||||
isGodotProject: true,
|
||||
godotProjectRoot: 'engine',
|
||||
projectName: '云岛工作区',
|
||||
recentRunStatus: 'failed',
|
||||
recentRunStopReason: null,
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
window.localStorage.setItem(
|
||||
'genarrative-ai-game-creator.recent-workspaces.v1',
|
||||
JSON.stringify(paths),
|
||||
);
|
||||
|
||||
renderLauncherProjectsAt('/?launcher');
|
||||
|
||||
expect(await screen.findByText('星轨工坊')).not.toBeNull();
|
||||
expect(screen.getByRole('heading', { name: '项目' })).not.toBeNull();
|
||||
expect(screen.getByLabelText('项目列表')).not.toBeNull();
|
||||
expect(screen.getByText('GameAgent')).not.toBeNull();
|
||||
expect(screen.getByText('Godot')).not.toBeNull();
|
||||
expect(screen.getByText('Godot · engine')).not.toBeNull();
|
||||
expect(screen.getByText('已完成 · 预览运行中')).not.toBeNull();
|
||||
expect(screen.getByText('运行失败')).not.toBeNull();
|
||||
expect(
|
||||
(
|
||||
screen.getByRole('button', {
|
||||
name: '打开项目 RootGodot',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(false);
|
||||
|
||||
const search = screen.getByRole('searchbox', { name: '搜索项目' });
|
||||
fireEvent.change(search, { target: { value: 'godot' } });
|
||||
expect(screen.queryByText('星轨工坊')).toBeNull();
|
||||
expect(screen.getByText('RootGodot')).not.toBeNull();
|
||||
expect(screen.getByText('云岛工作区')).not.toBeNull();
|
||||
|
||||
fireEvent.change(search, { target: { value: 'engine' } });
|
||||
expect(screen.queryByText('RootGodot')).toBeNull();
|
||||
expect(screen.getByText('云岛工作区')).not.toBeNull();
|
||||
|
||||
fireEvent.change(search, { target: { value: '不存在的项目' } });
|
||||
expect(screen.getByText('没有匹配的项目')).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }));
|
||||
expect(screen.getByText('星轨工坊')).not.toBeNull();
|
||||
expect(screen.getByText('RootGodot')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('removes recent launcher projects without opening them', () => {
|
||||
const invoke = vi.fn(async () => undefined);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
@@ -1516,10 +1615,7 @@ export function registerRecentProjectsTests() {
|
||||
expect(screen.getByLabelText('最近项目')).not.toBeNull();
|
||||
expect(screen.getByText('暂无最近项目')).not.toBeNull();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '移除 /tmp/recent-one' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '显示 /tmp/recent-one' }),
|
||||
screen.queryByRole('button', { name: 'recent-one的更多操作' }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '刷新' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '清空' })).toBeNull();
|
||||
@@ -1533,7 +1629,9 @@ export function registerRecentProjectsTests() {
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '移除 /tmp/recent-one' }),
|
||||
within(openProjectMoreMenu('recent-one')).getByRole('menuitem', {
|
||||
name: '从列表移除',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.queryByText('/tmp/recent-one')).toBeNull();
|
||||
@@ -1544,7 +1642,9 @@ export function registerRecentProjectsTests() {
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '移除 /tmp/recent-two' }),
|
||||
within(openProjectMoreMenu('recent-two')).getByRole('menuitem', {
|
||||
name: '从列表移除',
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText('暂无项目')).not.toBeNull();
|
||||
expect(window.localStorage.length).toBe(0);
|
||||
@@ -1582,11 +1682,13 @@ export function registerRecentProjectsTests() {
|
||||
|
||||
expect(await screen.findByText('最近的项目')).not.toBeNull();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '显示 /tmp/recent-one' }),
|
||||
screen.queryByRole('button', { name: '最近的项目的更多操作' }),
|
||||
).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '显示 /tmp/recent-one' }),
|
||||
within(openProjectMoreMenu('最近的项目')).getByRole('menuitem', {
|
||||
name: '显示目录',
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1683,7 +1785,7 @@ export function registerRecentProjectsTests() {
|
||||
expect(screen.getByText('无法读取')).not.toBeNull();
|
||||
expect(screen.getByText('检查失败')).not.toBeNull();
|
||||
expect(screen.getByText('厨房突围')).not.toBeNull();
|
||||
expect(screen.getByText('run: done · preview-running')).not.toBeNull();
|
||||
expect(screen.getByText('已完成 · 预览运行中')).not.toBeNull();
|
||||
expect(
|
||||
(screen.getByText('missing-game').closest('button') as HTMLButtonElement)
|
||||
.disabled,
|
||||
@@ -1701,21 +1803,26 @@ export function registerRecentProjectsTests() {
|
||||
const brokenManifestOpenButton = screen
|
||||
.getByText('broken-manifest')
|
||||
.closest('button') as HTMLButtonElement;
|
||||
const missingRevealButton = screen.getByRole('button', {
|
||||
name: '显示 /tmp/missing-game',
|
||||
}) as HTMLButtonElement;
|
||||
const notAFolderRevealButton = screen.getByRole('button', {
|
||||
name: '显示 /tmp/not-a-folder',
|
||||
}) as HTMLButtonElement;
|
||||
const plainFolderRevealButton = screen.getByRole('button', {
|
||||
name: '显示 /tmp/plain-folder',
|
||||
}) as HTMLButtonElement;
|
||||
const brokenManifestRevealButton = screen.getByRole('button', {
|
||||
name: '显示 /tmp/broken-manifest',
|
||||
}) as HTMLButtonElement;
|
||||
const brokenStatusRevealButton = screen.getByRole('button', {
|
||||
name: '显示 /tmp/broken-status',
|
||||
}) as HTMLButtonElement;
|
||||
const missingRevealButton = within(
|
||||
openProjectMoreMenu('missing-game'),
|
||||
).getByRole('menuitem', { name: '显示目录' }) as HTMLButtonElement;
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
const notAFolderRevealButton = within(
|
||||
openProjectMoreMenu('not-a-folder'),
|
||||
).getByRole('menuitem', { name: '显示目录' }) as HTMLButtonElement;
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
const plainFolderRevealButton = within(
|
||||
openProjectMoreMenu('plain-folder'),
|
||||
).getByRole('menuitem', { name: '显示目录' }) as HTMLButtonElement;
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
const brokenManifestRevealButton = within(
|
||||
openProjectMoreMenu('broken-manifest'),
|
||||
).getByRole('menuitem', { name: '显示目录' }) as HTMLButtonElement;
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
const brokenStatusRevealButton = within(
|
||||
openProjectMoreMenu('broken-status'),
|
||||
).getByRole('menuitem', { name: '显示目录' }) as HTMLButtonElement;
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(missingOpenButton.disabled).toBe(true);
|
||||
expect(plainFolderOpenButton.disabled).toBe(true);
|
||||
expect(brokenManifestOpenButton.disabled).toBe(true);
|
||||
@@ -1746,7 +1853,7 @@ export function registerRecentProjectsTests() {
|
||||
{ projectPath: '/tmp/broken-status' },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('厨房突围').closest('button')!);
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开项目 厨房突围' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('项目总控对话')).not.toBeNull();
|
||||
});
|
||||
@@ -1792,7 +1899,7 @@ export function registerRecentProjectsTests() {
|
||||
expect(screen.queryByRole('button', { name: '刷新' })).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
|
||||
expect(await screen.findByText('自动检查后的项目')).not.toBeNull();
|
||||
expect(screen.getByText('run: done · preview-running')).not.toBeNull();
|
||||
expect(screen.getByText('已完成 · 预览运行中')).not.toBeNull();
|
||||
expect(
|
||||
(
|
||||
screen
|
||||
@@ -1835,13 +1942,15 @@ export function registerRecentProjectsTests() {
|
||||
expect(screen.getByLabelText('最近项目')).not.toBeNull();
|
||||
expect(screen.getByText('暂无最近项目')).not.toBeNull();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '移除 /tmp/slow-refresh-game' }),
|
||||
screen.queryByRole('button', { name: 'slow-refresh-game的更多操作' }),
|
||||
).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
|
||||
expect(await screen.findByText('检查中')).not.toBeNull();
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '移除 /tmp/slow-refresh-game' }),
|
||||
within(openProjectMoreMenu('slow-refresh-game')).getByRole('menuitem', {
|
||||
name: '从列表移除',
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByText('/tmp/slow-refresh-game')).toBeNull();
|
||||
|
||||
@@ -1859,7 +1968,7 @@ export function registerRecentProjectsTests() {
|
||||
|
||||
expect(screen.queryByText('/tmp/slow-refresh-game')).toBeNull();
|
||||
expect(screen.queryByText('慢速刷新旧项目')).toBeNull();
|
||||
expect(screen.queryByText('run: done · late')).toBeNull();
|
||||
expect(screen.queryByText('已完成 · late')).toBeNull();
|
||||
expect(screen.getByText('暂无项目')).not.toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AI 游戏创作项目开发工作台 PRD
|
||||
|
||||
更新时间:`2026-08-11`(资源卡预览、分区布局与依赖聚类,图片素材无限画布及全类型非破坏性资源编辑收口;现有 Godot 项目入口与项目根绑定)
|
||||
更新时间:`2026-08-15`(项目管理页紧凑表格与桌面可用性收口;资源卡预览、分区布局、非破坏性资源编辑及 Godot 双根合同保持不变)
|
||||
|
||||
## 1. 产品定位
|
||||
|
||||
@@ -119,6 +119,14 @@
|
||||
- 首次打开只在工作区根创建 `.agent/` 元数据;已有 `.agent/manifest.json` 时读取并按当前唯一文件布局校准 `godotProjectRoot`。Godot 源码、场景、资源和项目配置继续使用原目录结构,不复制工程,也不建立 `game/`、`assets/`、`memory/`、`exports/` 平行目录。
|
||||
- Godot 项目使用 `standard` Run Profile,避免套用 Web 原型的 `game/index.html`、本地 HTTP 预览和自主 Web 游戏完成门;本期不提供 Godot 内嵌运行预览。
|
||||
|
||||
### 3.9 项目管理页
|
||||
|
||||
- 项目页采用“标题与状态 + 本地搜索 + 打开项目 / 新建项目 + 紧凑项目表格”的桌面信息架构。可以参考成熟项目管理器的信息层级和密度,但不得复制 Unity 等外部产品的品牌、Logo、深色皮肤、专有图标、列名或文案;页面继续使用 Genarrative 平台主题和陶泥儿客户端壳。
|
||||
- 表格只展示现有权威数据:项目名称与工作区绝对路径、GameAgent / Godot 类型、目录 / manifest / 最近 Runtime 状态和行级操作。当前目录检查合同没有修改时间、编辑器版本、收藏或云状态,前端不得伪造这些列。
|
||||
- 可打开项目的主行点击后进入项目;显示目录和从最近列表移除收进键盘可达的行尾更多菜单。Escape 关闭菜单并把焦点还给触发按钮;移除只修改本机 WebView 最近项目记录,不删除磁盘文件。
|
||||
- 搜索只在已加载项目行中匹配名称、路径、类型、Godot 相对根和状态,不改 localStorage、不触碰项目目录、不新增后端或 Tauri 命令。无匹配状态提供清除搜索;真正空状态仍只引导用户使用顶部打开或新建,不追加第三个目录选择入口。
|
||||
- 正式视觉验收只覆盖 `1280×720` 最小横屏和 `1280×800` 默认窗口:工具栏保持单行,表头与项目列对齐,项目列表内部滚动,document/body 不出现页面级横向或纵向溢出。截图必须使用含 Web、Godot 和无效状态的 populated fixture;视频必须演示搜索、清除、行尾菜单及可观察的项目操作结果,不能只录静止页面或无结果点击。
|
||||
|
||||
## 4. 工作台状态机
|
||||
|
||||
### 4.1 主视窗
|
||||
|
||||
@@ -7159,6 +7159,10 @@
|
||||
|
||||
## 2026-08-10 AGC 打开现有 Godot 项目
|
||||
|
||||
- 项目管理 UI 决策(2026-08-15):项目页改为 Genarrative 自有浅色视觉的紧凑桌面项目表格,成熟项目管理器只作为“标题 / 搜索 / 主操作 / 表头 / 项目行 / 行尾菜单”的信息架构参考,不复制外部品牌、皮肤、专有图标和文案。顶部只有搜索、打开项目和新建项目;搜索不是路径输入。可打开项目的主行直接进入项目,显示目录与从最近列表移除收进行尾更多菜单。
|
||||
- 投影边界:表格只展示目录检查、manifest 与 Runtime 已有权威字段,包括名称、工作区路径、GameAgent / Godot 类型、`godotProjectRoot` 和状态;没有修改时间、编辑器版本、收藏、云同步或版本警告事实时不得伪造这些列。搜索只过滤当前项目行,不改 storage 或项目目录。
|
||||
- 验收边界:正式项目页只按 `1280×720` 最小横屏和 `1280×800` 默认窗口验收,工具栏单行、列表内部滚动、页面无横纵溢出。截图使用 populated fixture;视频必须展示搜索、菜单和可观察操作结果,不再接受长时间静止或只有 picker 调用的录屏。窄屏 / 竖屏不属于当前产品合同。
|
||||
|
||||
- 项目双根决策(2026-08-14 更新):项目组只保留通用“打开项目 / 新建项目”,不再提供独立 Godot 入口。用户选择目录始终是工作区根,也是 `.agent`、Session、Runner、沙箱、通用文件工具和外围资料的唯一授权根;实际 Godot 根由根目录或一层直接子目录中的普通文件 `project.godot` 唯一确定,并以工作区相对 `godotProjectRoot` 记录,根目录使用 `.`。不得把 Runtime 根切换成 Godot 子目录,也不得复制工程或建立第二套工作区。
|
||||
- 发现与歧义决策:根目录命中优先;根未命中时只检查一层直接子目录,唯一命中才通过,多个命中在任何 `.agent` 写入前失败关闭。候选目录与工程文件拒绝符号链接和 Windows reparse point,二层及更深不递归。未来只有 Godot 专属命令显式使用经过校验的相对 Godot cwd。
|
||||
- 元数据决策:首次导入只在工作区根创建并保留 `.agent/manifest.json`、`.agent/agent.db`、`.agent/logs/` 与 `.agent/runtime/`;不得创建默认 Web 原型的 `game/`、`assets/`、`memory/`、`exports/`。已有有效 `.agent` 项目继续复用身份;缺失或错误的可推导 `godotProjectRoot` 只在 Godot 打开边界按唯一文件布局校准,歧义时不改写。
|
||||
|
||||
@@ -4388,9 +4388,17 @@
|
||||
- 现象:源码已经移除项目页常驻路径输入框,Windows 客户端却仍显示 `/tmp/genarrative-ai-game-draft`,新“打开项目 / 新建项目”交互也没有出现。
|
||||
- 原因:`apps/ai-game-creator-shell/dist/` 是 Git 忽略的本地构建产物,可能跨提交保留旧 JS;复用旧 `dist`、旧 EXE 或旧安装包时,Tauri 会继续嵌入旧前端。测试模式曾把 `/tmp` 同时当作产品初值,也让旧构建和测试夹具的边界难以辨认。
|
||||
- 处理:产品前端项目页不持有默认路径;首页回车自动工作区仍只由原生 `document_dir()` 决定目标根,测试必须在 harness 内显式注入 `/tmp` fixture。普通与 game-chat release 在 Vite 构建后、Tauri 嵌入前扫描实际 `frontendDist`,命中精确旧路径、缺失产物或链接绕过时失败关闭;不能用仓库级源码扫描误伤合法测试 fixture。
|
||||
- 验证:运行 frontend dist guard 定向测试、AGC AppSurface 的双主按钮 / picker 防重复 / 首页回车自动创建回归、Tauri release `--no-bundle` smoke,并确认新 `dist` 不含旧路径;Windows 实机项目组不得出现常驻路径框或 `/tmp`,原生 picker 从系统默认位置打开。视觉验收同时检查桌面和窄屏双主按钮布局。
|
||||
- 验证:运行 frontend dist guard 定向测试、AGC AppSurface 的双主按钮 / picker 防重复 / 首页回车自动创建回归、Tauri release `--no-bundle` smoke,并确认新 `dist` 不含旧路径;Windows 实机项目组不得出现常驻路径框或 `/tmp`,原生 picker 从系统默认位置打开。视觉验收检查 `1280×720` 最小横屏与 `1280×800` 默认窗口的紧凑项目表格和原生 picker。
|
||||
- 关联:`apps/ai-game-creator-shell/src/app/constants.ts`、`apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts`、`apps/ai-game-creator-shell/src-tauri/src/commands.rs`、`apps/ai-game-creator-shell/src-tauri/build.rs`、`apps/ai-game-creator-shell/src-tauri/tauri.conf.json`。
|
||||
|
||||
## 参考成熟项目管理器不能变成品牌复刻或伪数据列(2026-08-15)
|
||||
|
||||
- 现象:根据外部产品截图重做项目页时,直接照搬其 Logo、深色皮肤、收藏 / 云图标、修改时间或编辑器版本列,页面看似成熟却展示 AGC 没有的数据真相,录屏也只剩静态摆拍。
|
||||
- 原因:把参考截图当成完整产品合同,没有先核对当前目录检查、manifest 和 Runtime 真正提供的字段,也没有定义视频必须证明的交互结果。
|
||||
- 处理:只借鉴标题、搜索、主操作、紧凑表头 / 项目行和行尾菜单的信息层级;继续使用 Genarrative theme/token,只展示项目名称、工作区路径、GameAgent / Godot 类型、Godot 相对根和真实状态。次要操作收进行尾菜单,搜索只做本地过滤。没有权威来源的列直接不做,不用占位或推断补齐。
|
||||
- 验证:DOM 与截图不得出现外部品牌或 unsupported 列;AppSurface 覆盖 populated / invalid / empty、搜索与菜单;Playwright 在 `1280×720` 测量无页面级溢出。视频控制在有用时长内,清楚展示搜索、清除、菜单、状态反馈和项目打开结果,每一段都有可观察变化。
|
||||
- 关联:`apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx`、`apps/ai-game-creator-shell/src/features/app-shell/model.ts`、`apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts`、`apps/ai-game-creator-shell/tests/appSurface/home.suite.ts`。
|
||||
|
||||
## game-chat 快车道首波与已提交回复不能被后续 revision 破坏(2026-08-03)
|
||||
|
||||
- 现象:首波从单个美术任务扩展为三个 Director 后,hydration 若仍只容忍 seed lane 的第一个任务在 manifest 短暂恢复 `Pending` 时收束,另外两个已启动 Director 会被卡住。另外默认 `llm.stream=false` 下的专业 final reply 虽已由 finalization 提交,但后续阶段推进项目 revision 后,早期回复会从 Runtime 查询中消失。
|
||||
|
||||
@@ -576,7 +576,7 @@ game-project/
|
||||
- 首页发送、项目组目录选择和本地文件选择必须使用 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 或工具台入口。
|
||||
- 首页最近项目只展示最近 3 个有效项目;项目组页在同一窗口管理最近项目,并且顶部只常驻“打开项目”“新建项目”两个主动作,不常驻路径输入框、目录显示按钮或独立 Godot 入口。两个动作都先打开原生目录选择器:“打开项目”读取已有 GameAgent 项目,或自动识别根目录 / 一层直接子目录中的唯一 Godot 工程并导入;普通未初始化目录提示改用“新建项目”。“新建项目”在用户选择工作区根后沿用非空目录确认,不自动重建无效历史路径。最近项目行继续提供打开、显示和移除;空状态不再追加第三个目录选择入口。按钮在桌面端并排、窄屏下纵向全宽,状态反馈保持可访问且不暴露 Linux 测试默认路径。
|
||||
- 首页最近项目只展示最近 3 个有效项目;项目组页在同一窗口使用紧凑桌面项目表格管理最近项目。顶部工具栏只放本地搜索、“打开项目”“新建项目”,不常驻路径输入框、目录显示按钮或独立 Godot 入口。两个项目动作都先打开绑定当前 `client` 的非阻塞原生目录选择器:“打开项目”读取已有 GameAgent 项目,或自动识别根目录 / 一层直接子目录中的唯一 Godot 工程并导入;普通未初始化目录提示改用“新建项目”。“新建项目”在用户选择工作区根后沿用非空目录确认,不自动重建无效历史路径。项目表格只投影名称、路径、GameAgent / Godot 类型、目录 / manifest / Runtime 状态;可打开行点击进入项目,显示目录和移除最近记录收进行尾更多菜单。搜索仅过滤当前行,不修改 storage;空状态不追加第三个目录选择入口。正式验收只覆盖 `1280×720` 最小横屏和 `1280×800` 默认窗口,列表内部滚动且无页面级溢出。
|
||||
- 项目开发页保留左侧栏和顶部栏,顶部展示项目名、路径和最近 run 状态;中间只挂载 active `project-supervisor` Session 的正式对话面、Runtime 状态、确认/Needs input 和专业 Agent 协作只读状态,底部保留附件导入结果。真正的项目开发画布仍未落地;专业 Agent picker、完整计划和工具台继续留在开发入口。
|
||||
|
||||
## v1 验收证据矩阵
|
||||
@@ -681,7 +681,7 @@ game-project/
|
||||
- 2026-07-10 补充:当前工具箱还开放 `agent.delegate`,审计记录类型为 `agent.runtime.agent.delegate`;该工具只把任务投递到目标 Agent 既有后台队列,不创建平行 runtime,不绕过目标 Agent 的锁和队列,也受 `agent.delegate` 项目权限策略保护。
|
||||
- 2026-07-10 补充:当前 Runtime 命令和后台工具箱都开放 `agent.schedule_ready`,审计记录类型为 `agent.runtime.ready_task.scheduled`;该命令把 manifest ready task 批量投递到对应 Agent 的既有后台队列,默认需要确认,确认前不会启动下游 Agent。
|
||||
- 普通用户可在聊天框输入 `/project /绝对路径` 生成待确认的 `project.create` 命令,用于授权并初始化本地项目目录;相对路径不会生成待确认命令;开发窗口仍可直接编辑项目路径。
|
||||
- 单窗口首页和项目组页可选择、打开、新建或显示当前输入的项目绝对路径;最近项目行也可显示目录,非法或相对路径不会调用系统文件管理器。
|
||||
- 单窗口首页和项目组页不保留常驻路径输入;项目组只展示最近项目只读检查后的工作区绝对路径。打开 / 新建使用原生目录选择器,行尾菜单可显示已确认存在的项目目录,非法或相对路径不会调用系统文件管理器。
|
||||
- 普通用户侧的生成、上传、运行、自检、预览状态 / 启动 / 打开 / 停止、记忆写入和画板资产导入都必须先完成 `/project` 初始化;未初始化时只提示设置本地项目,不落到默认临时目录。
|
||||
- 终端可用 `npm run ai-game-creator-shell:llm-status` 检查 LLM 客户端配置是否就绪;桌面 App 主窗口“配置”面板可读写 Tauri 应用配置目录中的 `game-creator.config.json`,`/llm-status` / 生成入口读取同一份配置,CLI 开发入口无 AppHandle 时才回退读取仓库旁边的配置模板和 gitignored 本机覆盖文件;不请求上游、不显示 API Key,缺配置时以非零状态退出或在聊天里提示未就绪。
|
||||
- 终端可用 `npm run ai-game-creator-shell:check` 跑 v1 开发验收:壳 typecheck、`platform-llm` 网关测试、共享契约测试、Tauri Rust 测试和无密钥本地 provider 端到端 smoke;已退役的 `platform-agent` 不再进入 workspace 或该门禁。
|
||||
@@ -787,13 +787,13 @@ game-project/
|
||||
- 聊天输入 `/internals` 只列出项目内部真相源读取命令,覆盖 `.agent/manifest.json`、`.agent/run.latest.json`、`.agent/spec.md`、`.agent/findings.md`、`.agent/policy.json`、`.agent/project.index.json`、`.agent/agent.db` 和 `.agent/conversations/project.jsonl`;该命令不调用 Tauri 读写、不读取文件、不新增普通用户内部文件面板,真正读取仍由用户发送 `/read` 并走 `file.read` 权限流。
|
||||
- 聊天输入 `/publish` 只使用主窗口当前已加载的 manifest、最近 run trace、预览状态、资产来源和最近命令摘要,在聊天里生成发布准备清单,列出原型通过状态、预览、任务、资产、音频、包装说明和试玩包导出状态,并提供 `/run`、`/trace`、`/agent-resume ` 或 `/export` 草稿;该命令不调用 Tauri 读写、不启动或打开预览、不读取文件、不新增普通用户面板,真正导出仍由用户发送 `/export` 并走确认流。
|
||||
- 开发窗口可从 Agent 状态列表进入单个专业 Agent 对话并管理其 Session;正式用户项目开发页只读展示底部专业 Agent 状态和悬浮详情,不提供单 Agent 对话入口、Session 控件或调度工具台。
|
||||
- v1 普通用户登录后直接进入单窗口客户端首页;同一窗口中切换首页、项目组、指南 / 反馈和项目开发页。项目组页管理最近项目、打开项目、新建项目和显示目录;打开项目只切换到项目开发页,不调用 `open_game_creator_workspace_window` 打开第二窗口。旧 Tauri 窗口 command 只保留兼容,不进入用户主流程。
|
||||
- v1 普通用户登录后直接进入单窗口客户端首页;同一窗口中切换首页、项目组、指南 / 反馈和项目开发页。项目组页以紧凑表格管理最近项目,顶部提供搜索、打开项目、新建项目,显示目录和移除最近记录进入行尾菜单;打开项目只切换到项目开发页,不调用 `open_game_creator_workspace_window` 打开第二窗口。旧 Tauri 窗口 command 只保留兼容,不进入用户主流程。
|
||||
- 2026-07-16 补充,2026-07-17 扩展:普通用户窗口顶部账户资产统一复用 `PlatformMudPointWalletEntry`;首屏余额来自 `/api/profile/dashboard`,展开时按需读取 `/api/profile/recharge-center` 中的泥点拆分。顶部资产“充值”与侧栏账户菜单“充值泥点”共用 `PlatformProfileRechargeModal`,桌面壳固定使用 `wechat_native` 下单并在弹窗内完成扫码与到账确认,成功后同步刷新顶部余额和泥点拆分。“使用详情”统一打开 `packages/shared` 中的 `PlatformProfileWalletLedgerModal`,由各宿主分别维护打开、加载、失败重试状态并读取 `/api/profile/wallet-ledger`,共享组件只承接账单来源文案、金额与日期展示及 loading / empty / error 视图,不发请求、不持有账户事实。AI 游戏创作壳的 Tailwind 入口必须显式扫描 `packages/shared/src/components`,避免共享组件的 utility 样式在构建时被遗漏。
|
||||
- 主窗口可通过系统文件管理器显示当前项目目录,也可在聊天输入 `/open-project` 走同一只读打开动作;该操作只打开本地目录,不初始化项目、不写项目文件、不切换工作区。主窗口头部显示最近 `.agent/run.latest.json` 的 run 状态摘要和当前项目预览状态,并通过“刷新状态”重新读取同一 trace,不新增状态数据库。
|
||||
- 首页、项目组页和项目开发页共用单窗口壳的全局运行时配置弹窗,读写 Tauri 应用配置目录中的 `game-creator.config.json`;正式 Supervisor 项目页缺配置时只显示错误,不自动打开该弹窗。API Key 仍不进入本地项目、trace、manifest 或聊天记录。
|
||||
- 首页发送和项目组新建都通过 Tauri 原生目录选择器选择项目路径;用户取消目录选择时不覆盖已有输入或草稿。
|
||||
- 最近项目列表只来自本机 WebView storage,可移除单项或清空;这些操作不触碰项目目录,也不触发窗口切换。项目组页会只读检查最近项目路径,已初始化项目优先显示 `.agent/manifest.json` 里的项目名并保留路径副信息;如果 `.agent/run.latest.json` 可读,则显示最近 run 的 status / stopReason;缺失、非目录、未初始化或检查失败的条目禁用打开,用户可移除后重新选择。刷新最近项目只重新执行这组只读检查,不改项目目录或最近项目列表;刷新未完成时按钮显示“刷新中”并禁用,最近项目条目显示“检查中”且临时禁用打开 / 显示操作,避免重复触发并让用户知道检查仍在进行;“显示”只通过系统文件管理器打开已确认存在的本地目录,未初始化但存在的目录也可显示,缺失、非目录或检查失败时禁用,不初始化项目、不写最近项目。
|
||||
- 项目组页“打开”只进入已初始化且 `.agent/manifest.json` 可读的 AI 游戏项目;路径不存在、不是文件夹或只是普通文件夹时留在当前页提示,不自动创建目录。“新建项目”和聊天 `project.create` 新建入口会先检查目标目录,目标目录已经存在且非空时必须弹出确认提醒;用户继续后才调用 `init_local_game_project`,默认项目名取目标文件夹名,再写入最近项目并切到项目开发页;用户取消或初始化失败时不切换视图、也不写入最近项目。
|
||||
- 首页发送以及项目组打开 / 新建都通过绑定当前 `client` 的 Tauri 非阻塞原生目录选择器选择项目路径;用户取消目录选择时不覆盖已有输入或草稿。
|
||||
- 最近项目列表只来自本机 WebView storage,可逐行移除;该操作不触碰项目目录,也不触发窗口切换。项目组页自动只读检查最近项目路径,已初始化项目优先显示 `.agent/manifest.json` 里的项目名并保留路径副信息;如果 `.agent/run.latest.json` 可读,则把 status / stopReason 转成面向用户的状态。缺失、非目录、manifest 损坏或检查失败的条目禁用打开;目录仍存在时可从行尾菜单显示,检查中临时禁用打开 / 显示。页面不恢复清空或手动刷新按钮,本地搜索也不改项目列表。
|
||||
- 项目组页“打开”直接进入已初始化且 `.agent/manifest.json` 可读的 AI 游戏项目;发现根目录或一层子目录中的唯一 Godot 工程时允许导入并进入项目。路径不存在、不是文件夹、普通未初始化目录或 Godot 歧义时留在当前页提示,且不写 `.agent`。“新建项目”和聊天 `project.create` 新建入口会先检查目标目录,目标目录已经存在且非空时必须弹出确认提醒;用户继续后才调用 `init_local_game_project`,默认项目名取目标文件夹名,再写入最近项目并切到项目开发页;用户取消或初始化失败时不切换视图、也不写入最近项目。
|
||||
- 聊天输入 `/commands` 会只读列出 Tauri runtime 暴露的受限命令白名单,读取失败或非 Tauri 环境下回退到共享契约默认列表;该命令不执行白名单命令,也不要求先初始化项目。
|
||||
- 聊天输入 `/smoke` 会生成待确认的 `command.run_limited` 内置命令,当前只映射到白名单 `game.static_smoke`,不开放任意命令解析。
|
||||
- 聊天输入 `/run` 会生成待确认的 `game.run_local` 内置命令,确认后复用白名单 `game.static_smoke` 运行当前 `game/index.html`,通过后启动只读本地 HTTP 预览并切换到客户端内运行视图;该命令不开放任意 shell。
|
||||
|
||||
Reference in New Issue
Block a user