Merge branch 'master' into fix/windows-acl
Project CI / Repository checks (pull_request) Successful in 2m42s
Project CI / Frontend tests (pull_request) Successful in 4m21s
Project CI / Backend tests (pull_request) Successful in 6m38s
Project CI / Native shell tests (pull_request) Successful in 18m47s

This commit is contained in:
2026-08-31 14:37:23 +08:00
21 changed files with 907 additions and 77 deletions
+18
View File
@@ -419,6 +419,7 @@ type AppProps = {
summaries: ProjectAgentRuntimeSummary[],
) => void;
onAgentResultsChange?: (results: ProjectAgentResultSummary[]) => void;
onMakeGameFromApprovedGdd?: (projectPath: string) => Promise<void>;
};
export function App({
@@ -437,6 +438,7 @@ export function App({
onPreviewChange,
onAgentRuntimeSummariesChange,
onAgentResultsChange,
onMakeGameFromApprovedGdd,
}: AppProps = {}) {
const { setTitle: setWindowTitle } = useWindowChrome();
// 做方案入口独立成链:立项策划需要委派、澄清 pending 与 GDD 审批,这些只存在于
@@ -10915,6 +10917,14 @@ export function App({
planGddError={planGddError}
onPlanGddRefresh={() => void hydratePlanGddState()}
onPlanGddDecision={decidePlanGdd}
onMakeGameFromApprovedGdd={
onMakeGameFromApprovedGdd
? () =>
onMakeGameFromApprovedGdd(
localProject?.projectPath ?? projectPath,
)
: undefined
}
runtime={projectSupervisorRuntime}
error={projectSupervisorRuntimeError}
runtimeByAgentId={agentRuntimeById}
@@ -11013,6 +11023,14 @@ export function App({
planGddError={planGddError}
onPlanGddRefresh={() => void hydratePlanGddState()}
onPlanGddDecision={decidePlanGdd}
onMakeGameFromApprovedGdd={
onMakeGameFromApprovedGdd
? () =>
onMakeGameFromApprovedGdd(
localProject?.projectPath ?? projectPath,
)
: undefined
}
queueAgentRunControlFromPanel={queueAgentRunControlFromPanel}
queueOrExecuteProjectIndex={queueOrExecuteProjectIndex}
queuePendingCommand={queuePendingCommand}
@@ -77,6 +77,7 @@ export function WorkspaceLauncherShell({
activeProjectAgentResults,
setAgentResults: setActiveProjectAgentResults,
resetLauncherHomeDraft,
startGameFromApprovedGdd,
createHomeDraftAutomatically,
openProject,
} = homeProject;
@@ -351,6 +352,7 @@ export function WorkspaceLauncherShell({
setActiveProjectAgentRuntimeSummaries
}
onAgentResultsChange={setActiveProjectAgentResults}
onMakeGameFromApprovedGdd={startGameFromApprovedGdd}
/>
}
/>
@@ -50,6 +50,7 @@ export type ProjectSupervisorComponentProps = {
summaries: ProjectAgentRuntimeSummary[],
) => void;
onAgentResultsChange?: (results: ProjectAgentResultSummary[]) => void;
onMakeGameFromApprovedGdd?: (projectPath: string) => Promise<void>;
};
export type WorkspaceLauncherShellProps = WorkspaceLauncherProps & {
@@ -19,6 +19,7 @@ import type {
LauncherProjectContext,
LocalGameProjectRevisionStatus,
LocalProjectDirectoryStatus,
LocalProjectFileResult,
PendingNonEmptyProject,
ProjectStartMode,
TauriInvoke,
@@ -48,6 +49,27 @@ type UseHomeProjectCreationOptions = {
rememberRecentWorkspace: (projectPath: string) => void;
};
const APPROVED_GDD_BUILD_PROMPT = [
'请按照附件中的已批准 GDD 开始建造这款游戏。',
'',
'这份 GDD 已覆盖游戏定位与一句话概念、类型与美术方向、游戏支柱、核心循环、目标用户、平台与输入事实、MVP 系统、暂不纳入范围、创作者提示和原型验证项。',
'',
'请先阅读并理解附件中的 fast_gdd.md,以它作为本次建造的主要依据,优先实现其中 MVP 范围内的可运行游戏原型。',
].join('\n');
function createTextAttachmentFile(content: string) {
const file = new File([content], 'fast_gdd.md', {
type: 'text/markdown',
lastModified: Date.now(),
});
if (typeof file.arrayBuffer !== 'function') {
Object.defineProperty(file, 'arrayBuffer', {
value: async () => new TextEncoder().encode(content).buffer,
});
}
return file;
}
export function useHomeProjectCreation({
setStatus,
setLauncherView,
@@ -77,6 +99,7 @@ export function useHomeProjectCreation({
const resetLauncherHomeDraft = useLauncherHomeDraftStore(
(state) => state.reset,
);
const approvedGddStartInFlightRef = useRef(false);
function validateProjectPath(nextProjectPath: string) {
const trimmedProjectPath = nextProjectPath.trim();
@@ -91,6 +114,50 @@ export function useHomeProjectCreation({
return trimmedProjectPath;
}
async function startGameFromApprovedGdd(nextProjectPath: string) {
if (approvedGddStartInFlightRef.current) {
return;
}
approvedGddStartInFlightRef.current = true;
const invoke = resolveTauriInvoke();
try {
if (!invoke) {
throw new Error('需要在陶泥儿客户端内运行');
}
const projectPath = nextProjectPath.trim();
if (!projectPath) {
throw new Error('当前项目路径无效');
}
setStatus('正在读取已批准 GDD');
const result = await invoke<LocalProjectFileResult>(
'read_local_project_file',
{
projectPath,
relativePath: 'game/fast_gdd.md',
commandId: 'file.read',
},
);
const file = createTextAttachmentFile(result.content);
await createHomeDraftAutomatically(
{
creationType: 'game',
prompt: APPROVED_GDD_BUILD_PROMPT,
attachments: [
{
id: `approved-gdd-${Date.now().toString(36)}`,
file,
},
],
},
'direct-build',
);
} finally {
approvedGddStartInFlightRef.current = false;
}
}
function enterProjectDevelopment(context: LauncherProjectContext) {
setCurrentProjectContext(context);
setActiveProjectPreview(context.manifest.preview ?? null);
@@ -600,6 +667,7 @@ export function useHomeProjectCreation({
projectBusy: projectAction !== null,
pendingNonEmptyProject,
resetLauncherHomeDraft,
startGameFromApprovedGdd,
createHomeDraft,
createHomeDraftAutomatically,
openProject,
@@ -42,6 +42,7 @@ type GddApprovalCardProps = {
action: PlanGddDecisionAction,
comment: string | null,
) => Promise<void>;
onMakeGame?: () => Promise<void>;
};
const stateLabels: Record<PlanGddStateViewV1['state'], string> = {
@@ -82,6 +83,7 @@ export function PlanGddSurface({
error,
onRefresh,
onDecision,
onMakeGame,
}: GddApprovalCardProps & { active?: boolean; projectPath: string }) {
const showProgress = stageProgressVisible(state, active);
const showCard = approvalCardVisible(state);
@@ -98,6 +100,7 @@ export function PlanGddSurface({
state={state}
active={active}
projectPath={projectPath}
onMakeGame={onMakeGame}
/>
) : null}
{showCard ? (
@@ -118,14 +121,18 @@ export function PlanGddStageProgress({
state,
active = false,
projectPath = '',
onMakeGame,
}: {
state: PlanGddStateViewV1 | null;
active?: boolean;
projectPath?: string;
onMakeGame?: () => Promise<void>;
}) {
const [detailsOpen, setDetailsOpen] = useState(false);
const [openError, setOpenError] = useState('');
const [opening, setOpening] = useState(false);
const [makingGame, setMakingGame] = useState(false);
const [makeGameError, setMakeGameError] = useState('');
if (!state || !stageProgressVisible(state, active)) {
return null;
}
@@ -198,6 +205,25 @@ export function PlanGddStageProgress({
>
{opening ? '正在打开' : '打开文件'}
</button>
{onMakeGame ? (
<button
type="button"
disabled={opening || makingGame || !projectPath.trim()}
onClick={() => {
setMakeGameError('');
setMakingGame(true);
void onMakeGame()
.catch((error: unknown) =>
setMakeGameError(
error instanceof Error ? error.message : String(error),
),
)
.finally(() => setMakingGame(false));
}}
>
{makingGame ? '正在启动' : '做成游戏'}
</button>
) : null}
</div>
{openError ? (
<small
@@ -207,6 +233,14 @@ export function PlanGddStageProgress({
{openError}
</small>
) : null}
{makeGameError ? (
<small
className="plan-gdd-stage-progress__delivery-error"
role="alert"
>
{makeGameError}
</small>
) : null}
</div>
) : null}
{detailsOpen && deliveredGdd ? (
@@ -68,6 +68,7 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
action: PlanGddDecisionAction,
comment: string | null,
) => Promise<void>;
onMakeGameFromApprovedGdd?: () => Promise<void>;
};
export function ProjectSupervisorView({
@@ -99,6 +100,7 @@ export function ProjectSupervisorView({
planGddError,
onPlanGddRefresh,
onPlanGddDecision,
onMakeGameFromApprovedGdd,
...runtimePanelProps
}: ProjectSupervisorViewProps) {
const submitLabel = needsUserInput
@@ -121,6 +123,7 @@ export function ProjectSupervisorView({
error={planGddError}
onRefresh={onPlanGddRefresh}
onDecision={onPlanGddDecision}
onMakeGame={onMakeGameFromApprovedGdd}
/>
<div
ref={messagesRef}
@@ -189,6 +189,7 @@ type ProjectWorkspaceChatPaneProps = {
action: PlanGddDecisionAction,
comment: string | null,
) => Promise<void>;
onMakeGameFromApprovedGdd?: () => Promise<void>;
queueAgentRunControlFromPanel: (action: 'kill' | 'retry' | 'resume') => void;
queueOrExecuteProjectIndex: () => Promise<void>;
queuePendingCommand: (command: PendingCommand) => void;
@@ -278,6 +279,7 @@ export function ProjectWorkspaceChatPane({
planGddError,
onPlanGddRefresh,
onPlanGddDecision,
onMakeGameFromApprovedGdd,
queueAgentRunControlFromPanel,
queueOrExecuteProjectIndex,
queuePendingCommand,
@@ -376,6 +378,7 @@ export function ProjectWorkspaceChatPane({
error={planGddError}
onRefresh={onPlanGddRefresh}
onDecision={onPlanGddDecision}
onMakeGame={onMakeGameFromApprovedGdd}
/>
<div className="chat-quick-actions" aria-label="预览快捷操作">
<button
+25
View File
@@ -5458,6 +5458,31 @@ iframe.preview-frame {
visibility: visible;
}
.game-resource-card-type-badge {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
display: inline-flex;
max-width: calc(100% - 16px);
align-items: center;
min-width: 0;
padding: 4px 8px;
overflow: hidden;
border: 1px solid rgb(255 255 255 / 72%);
border-radius: 999px;
background: rgb(75 48 38 / 84%);
color: #fff;
font-size: 10px;
font-weight: 900;
line-height: 1;
letter-spacing: 0.02em;
pointer-events: none;
text-overflow: ellipsis;
white-space: nowrap;
box-shadow: 0 8px 18px rgb(96 62 47 / 20%);
}
.game-resource-card-open {
position: absolute;
z-index: 1;
@@ -82,10 +82,10 @@ import {
normalizeInfiniteResourceCanvasViewport,
RESOURCE_CANVAS_DRAG_THRESHOLD,
RESOURCE_CANVAS_FIT_PADDING,
RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE,
RESOURCE_CANVAS_SECTION_MIN_HEIGHT,
RESOURCE_CANVAS_SECTION_MIN_WIDTH,
RESOURCE_CANVAS_SECTION_ORDER,
RESOURCE_CANVAS_VISIBLE_SECTION_ORDER,
type ResourceCanvasCardSize,
resourceCanvasCardSize,
resourceCanvasContentBounds,
@@ -123,6 +123,7 @@ import {
type ProjectResource,
type ProjectResourceCategory,
projectResourcesFromReadModels,
projectResourceTypeLabel,
} from './resourceProjectionModel';
import {
clampProjectResourceSectionZoom,
@@ -393,6 +394,7 @@ export type ProjectDevelopmentViewProps = {
};
const categoryOrder = RESOURCE_CANVAS_SECTION_ORDER;
const visibleCategoryOrder = RESOURCE_CANVAS_VISIBLE_SECTION_ORDER;
const categoryLabels: Record<ResourceCategory, string> = {
code: '游戏代码',
@@ -592,6 +594,7 @@ const ResourceCard = memo(function ResourceCard({
const [decodedIdentity, setDecodedIdentity] = useState<string | null>(null);
const Icon = categoryIcons[resource.category];
const kind = projectResourceCardPreviewKind(resource);
const resourceTypeLabel = projectResourceTypeLabel(resource);
const isMedia = kind === 'video' || kind === 'audio';
const mediaActive = activeMediaIdentity === previewIdentity;
const sourceUrl =
@@ -797,6 +800,13 @@ const ResourceCard = memo(function ResourceCard({
<span className="game-resource-card-visual" aria-hidden="true">
{visual}
</span>
<span
className="game-resource-card-type-badge"
data-resource-type={resourceTypeLabel}
title={resourceTypeLabel}
>
{resourceTypeLabel}
</span>
<button
type="button"
className="game-resource-card-open"
@@ -1243,6 +1253,10 @@ export default function ProjectDevelopmentView({
}),
[manifestTaskById, projectedResources, resourceGraph],
);
const canvasResources = useMemo(
() => resources.filter((resource) => resource.category !== 'code'),
[resources],
);
// 做方案在拿到第一个产物之前,左边的资源画布是空的(文档 0 项、项目版本 0 项),而澄清
// 问答、决定卡和 GDD 审批全挤在右边那一列里。这段时间先把画布收掉让对话铺满,一旦有第
// 一个已登记资源就自动恢复双栏。用 CSS 收而不是不渲染:画布里的页签、缩放和选中状态都
@@ -1288,7 +1302,7 @@ export default function ProjectDevelopmentView({
projectPath,
projectId: manifest.projectId,
mode: sortMode,
resources,
resources: canvasResources,
canvasRef: resourceCanvasRef,
eagerPreviewLimit: 12,
previewVersionByResourceId: resourcePreviewVersionByResourceId,
@@ -1382,7 +1396,7 @@ export default function ProjectDevelopmentView({
const normalizedSearch = searchText.trim().toLowerCase();
const visibleResources = useMemo(
() =>
resources.filter((resource) =>
canvasResources.filter((resource) =>
normalizedSearch
? [
resource.label,
@@ -1392,7 +1406,7 @@ export default function ProjectDevelopmentView({
].some((value) => value.toLowerCase().includes(normalizedSearch))
: true,
),
[normalizedSearch, resources],
[canvasResources, normalizedSearch],
);
const visibleResourceIds = useMemo(
() => new Set(visibleResources.map((resource) => resource.id)),
@@ -1401,7 +1415,7 @@ export default function ProjectDevelopmentView({
const visibleResourcesByCategory = useMemo(
() =>
new Map(
categoryOrder.map((category) => [
visibleCategoryOrder.map((category) => [
category,
visibleResources.filter((resource) => resource.category === category),
]),
@@ -1411,17 +1425,17 @@ export default function ProjectDevelopmentView({
const projectResourcesByCategory = useMemo(
() =>
new Map(
categoryOrder.map((category) => [
visibleCategoryOrder.map((category) => [
category,
resources.filter((resource) => resource.category === category),
canvasResources.filter((resource) => resource.category === category),
]),
),
[resources],
[canvasResources],
);
const resourceIdsByCategory = useMemo(
() =>
new Map(
categoryOrder.map((category) => [
visibleCategoryOrder.map((category) => [
category,
new Set(
(projectResourcesByCategory.get(category) ?? []).map(
@@ -1437,8 +1451,8 @@ export default function ProjectDevelopmentView({
[manifest.projectId, projectPath],
);
const resourcePageCategories = useMemo(
() => (resources.length > 0 ? categoryOrder : []),
[resources.length],
() => (canvasResources.length > 0 ? visibleCategoryOrder : []),
[canvasResources.length],
);
const activePageCategory =
resourcePageCategories.find(
@@ -1468,16 +1482,18 @@ export default function ProjectDevelopmentView({
return;
}
const categoriesWithNewResources = categoryOrder.filter((category) => {
if (category === viewedResourceCategory) {
return false;
}
const previousIds =
previousSnapshot.resourceIdsByCategory.get(category) ?? new Set();
return Array.from(resourceIdsByCategory.get(category) ?? []).some(
(resourceId) => !previousIds.has(resourceId),
);
});
const categoriesWithNewResources = visibleCategoryOrder.filter(
(category) => {
if (category === viewedResourceCategory) {
return false;
}
const previousIds =
previousSnapshot.resourceIdsByCategory.get(category) ?? new Set();
return Array.from(resourceIdsByCategory.get(category) ?? []).some(
(resourceId) => !previousIds.has(resourceId),
);
},
);
resourceCategorySnapshotRef.current = {
scopeKey: resourceCategoryScopeKey,
resourceIdsByCategory,
@@ -1683,9 +1699,11 @@ export default function ProjectDevelopmentView({
}
}, [activePageCategory, activeResourceCanvasViewport]);
const selectedResource =
resources.find((resource) => resource.id === selectedResourceId) ?? null;
canvasResources.find((resource) => resource.id === selectedResourceId) ??
null;
const focusedResource =
resources.find((resource) => resource.id === focusedResourceId) ?? null;
canvasResources.find((resource) => resource.id === focusedResourceId) ??
null;
const focusedResourceDependencyDetails = useMemo(() => {
if (!focusedResource) {
return {
@@ -2245,10 +2263,6 @@ export default function ProjectDevelopmentView({
bounds: resourceCanvasFitBounds,
canvasSize: { width: measuredWidth, height: measuredHeight },
padding: RESOURCE_CANVAS_FIT_PADDING,
maxScale:
activePageCategory === 'art'
? RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE
: undefined,
}),
);
return;
@@ -2853,10 +2867,6 @@ export default function ProjectDevelopmentView({
bounds: resourceCanvasFitBounds,
canvasSize,
padding: RESOURCE_CANVAS_FIT_PADDING,
maxScale:
activePageCategory === 'art'
? RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE
: undefined,
}),
);
}, [activePageCategory, resourceCanvasFitBounds, setResourceCanvasViewport]);
@@ -5035,7 +5045,7 @@ export default function ProjectDevelopmentView({
</div>
) : (
<div className="game-resource-section-stack">
{categoryOrder.map((category) => {
{visibleCategoryOrder.map((category) => {
const Icon = categoryIcons[category];
const sectionHeight =
resourceSectionHeights.sectionStates.get(category);
@@ -44,6 +44,14 @@ export const RESOURCE_CANVAS_FIT_PADDING = 16;
export const RESOURCE_CANVAS_SECTION_ORDER: readonly ProjectResourceCanvasSection[] =
['document', 'art', 'audio', 'code', 'version'];
/**
* Ordinary resource-canvas navigation intentionally omits game code. The
* complete five-section order above remains the internal layout/sidecar
* contract so existing code-resource coordinates are not rewritten.
*/
export const RESOURCE_CANVAS_VISIBLE_SECTION_ORDER: readonly ProjectResourceCanvasSection[] =
['document', 'art', 'audio', 'version'];
export type ResourceCanvasCardSize = {
width: number;
height: number;
@@ -1345,7 +1353,7 @@ export function fitResourceCanvasViewportToContent({
bounds,
canvasSize,
padding = RESOURCE_CANVAS_FIT_PADDING,
maxScale = MAX_SCALE,
maxScale = RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE,
}: {
bounds: { x: number; y: number; width: number; height: number };
canvasSize: { width: number; height: number };
@@ -55,6 +55,18 @@ export type ProjectResource = {
imageSequenceDurationMs?: number | null;
};
export type ProjectResourceTypeLabel =
| '图片'
| 'SVG'
| '视频'
| '音频'
| '文档'
| '任务产物'
| 'Agent 回执'
| '项目版本'
| '游戏代码'
| '未知';
const documentExtension =
/\.(md|markdown|mdx|txt|json|ya?ml|toml|csv|ini|conf|xml)$/iu;
const gameCodeExtension =
@@ -116,6 +128,55 @@ export function classifyProjectedResource(input: {
return null;
}
/**
* Converts an already projected resource into short user-facing type text.
* This is deliberately display-only: it derives from existing projection
* fields and does not add a backend/read-model attribute.
*/
export function projectResourceTypeLabel(
resource: Pick<
ProjectResource,
'category' | 'subtype' | 'path' | 'mediaType'
>,
): ProjectResourceTypeLabel {
const subtype = resource.subtype.trim().toLowerCase();
const path = resource.path.trim().toLowerCase();
const mediaType = resource.mediaType.trim().toLowerCase();
if (resource.category === 'version' || subtype === 'project-version') {
return '项目版本';
}
if (subtype === 'agent-result') {
return 'Agent 回执';
}
if (resource.category === 'code') {
return '游戏代码';
}
if (mediaType === 'image/svg+xml' || /\.svg$/iu.test(path)) {
return 'SVG';
}
if (
resource.category === 'audio' ||
mediaType.startsWith('audio/') ||
audioExtension.test(path)
) {
return '音频';
}
if (mediaType.startsWith('video/') || /\.(mp4|webm|mov)$/iu.test(path)) {
return '视频';
}
if (resource.category === 'document' || mediaType.startsWith('text/')) {
return '文档';
}
if (resource.category === 'art' || mediaType.startsWith('image/')) {
return '图片';
}
if (subtype === 'task-artifact') {
return '任务产物';
}
return '未知';
}
function resourcePriority(resource: ProjectResource) {
if (resource.manifestAssetId) {
return 4;
@@ -1,6 +1,8 @@
import { PROJECT_SUPERVISOR_PLAN_SOURCE } from '../../src/app/constants';
import type { ProjectSupervisorComponentProps } from '../../src/features/app-shell/model';
import { useHomeProjectCreation } from '../../src/features/app-shell/useHomeProjectCreation';
import { WorkspaceLauncherShell } from '../../src/features/app-shell/WorkspaceLauncher';
import type { LauncherView } from '../../src/view/layout';
import {
act,
agentRuntimeUserInputRequest,
@@ -27,6 +29,38 @@ import {
within,
} from './harness';
function ApprovedGddStartHarness() {
const [, setLauncherView] = React.useState<LauncherView>(
'project-development',
);
const [, setStatus] = React.useState('');
const [, setAgentChatProjectPath] = React.useState('');
const controller = useHomeProjectCreation({
setStatus,
setLauncherView,
setAgentChatProjectPath,
rememberRecentWorkspace: () => undefined,
});
if (controller.currentProjectContext) {
return React.createElement(
'p',
{ 'aria-label': '已进入自动游戏项目' },
controller.currentProjectContext.initialPrompt,
);
}
return React.createElement(
'button',
{
type: 'button',
onClick: () =>
void controller.startGameFromApprovedGdd('/tmp/planning-project'),
},
'直接用已批准 GDD 开始建造',
);
}
export function registerClientHomeTests() {
it('anchors the empty home input placeholder to the editor while the page scrolls', () => {
renderLauncherAt('/?launcher');
@@ -1502,6 +1536,73 @@ export function registerHomeProjectCreationTests() {
);
});
it('starts an automatic game project from the approved GDD', async () => {
const automaticProjectPath = '/tmp/approved-gdd-game';
const manifest = createGameCreationAppManifest(
'approved-gdd-game',
'已批准 GDD 游戏',
);
const gddContent = '# Fast GDD\n\n批准后的方案内容';
const invoke = vi.fn(async (command: string) => {
if (command === 'read_local_project_file') {
return {
path: 'game/fast_gdd.md',
absolutePath: '/tmp/planning-project/game/fast_gdd.md',
content: gddContent,
};
}
if (command === 'create_automatic_local_game_project') {
return {
projectPath: automaticProjectPath,
manifestPath: `${automaticProjectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'upload_local_asset') {
return {
id: 'approved-gdd-asset',
localPath: 'assets/uploads/fast_gdd.md',
absolutePath: `${automaticProjectPath}/assets/uploads/fast_gdd.md`,
manifestPath: `${automaticProjectPath}/.agent/manifest.json`,
};
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
render(React.createElement(ApprovedGddStartHarness));
fireEvent.click(
screen.getByRole('button', { name: '直接用已批准 GDD 开始建造' }),
);
fireEvent.click(
screen.getByRole('button', { name: '直接用已批准 GDD 开始建造' }),
);
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) => command === 'create_automatic_local_game_project',
),
).toHaveLength(1);
});
expect(screen.getByLabelText('已进入自动游戏项目').textContent).toContain(
'请按照附件中的已批准 GDD 开始建造这款游戏。',
);
expect(invoke).toHaveBeenCalledWith('read_local_project_file', {
projectPath: '/tmp/planning-project',
relativePath: 'game/fast_gdd.md',
commandId: 'file.read',
});
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('upload_local_asset', {
projectPath: automaticProjectPath,
fileName: 'fast_gdd.md',
mediaType: 'text/markdown',
bytes: Array.from(new TextEncoder().encode(gddContent)),
});
});
});
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) => {
@@ -1,6 +1,7 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { PlanGddStageProgress } from '../../src/features/project-workspace/GddApprovalCard';
import {
agentRuntimeUserInputRequest,
createPlanGddStateView,
@@ -9,8 +10,11 @@ import {
fireEvent,
it,
openMainProject,
React,
render,
renderAppAt,
screen,
vi,
waitFor,
within,
} from './harness';
@@ -382,6 +386,24 @@ export function registerPlanGddApprovalTests() {
expect(args).toEqual({ projectPath: harness.projectPath });
});
it('offers to make an approved GDD into a game reference on the home entry', async () => {
const onMakeGame = vi.fn(async () => undefined);
render(
React.createElement(PlanGddStageProgress, {
state: approvedPlanGddState(),
active: true,
projectPath: '/tmp/approved-gdd-project',
onMakeGame,
}),
);
fireEvent.click(screen.getByRole('button', { name: '做成游戏' }));
await waitFor(() => {
expect(onMakeGame).toHaveBeenCalledTimes(1);
});
});
it('keeps the delivery row hidden while the approval projection is still recovering', async () => {
// 恢复态下权威投影还没收敛,磁盘上那份 Markdown 未必是用户批的那版。此时给出口
// 等于让用户读一份可能已经失效的交付物。
@@ -6,6 +6,7 @@ import {
RESOURCE_CANVAS_CARD_WIDTH,
RESOURCE_CANVAS_COLUMN_GAP,
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE,
} from '../../src/view/project-development/resourceCanvasLayoutModel';
import { normalizeProjectResourceGraph } from '../../src/view/project-development/resourceDependencyGraphModel';
import { ResourceDependencyOverlay } from '../../src/view/project-development/ResourceDependencyOverlay';
@@ -429,6 +430,13 @@ export function registerProjectWorkbenchFoundationTests() {
localPath: 'assets/section.mp3',
source: { kind: 'generated', taskId: 'audio-asset-plan' },
},
{
id: 'section-code',
kind: 'game-code',
mediaType: 'text/javascript',
localPath: 'game/section.js',
source: { kind: 'generated', taskId: 'code-prototype' },
},
];
manifest.versions = [
{
@@ -448,9 +456,13 @@ export function registerProjectWorkbenchFoundationTests() {
);
addSectionResources(manifest);
let layoutRevision = 0;
let graphResourceIds: string[] = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
graphResourceIds = (
(args?.resources as Array<{ resourceId: string }> | undefined) ?? []
).map(({ resourceId }) => resourceId);
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
@@ -529,7 +541,12 @@ export function registerProjectWorkbenchFoundationTests() {
Array.from(outline.querySelectorAll('strong')).map(
(item) => item.textContent,
),
).toEqual(['设计文档', '美术资源', '音乐音效', '游戏代码', '项目版本']);
).toEqual(['设计文档', '美术资源', '音乐音效', '项目版本']);
expect(graphResourceIds).toContain('asset:section-code');
expect(screen.queryByRole('button', { name: /section\.js/ })).toBeNull();
expect(
screen.queryByRole('region', { name: '游戏代码资源画布' }),
).toBeNull();
expect(outline.querySelectorAll('small')).toHaveLength(0);
expect(
screen.getByRole('region', { name: '设计文档资源画布' }),
@@ -541,11 +558,12 @@ export function registerProjectWorkbenchFoundationTests() {
screen.getByRole('button', { name: /下一页\s*美术资源/ }),
).not.toBeNull();
fireEvent.click(within(outline).getByRole('button', { name: /游戏代码/ }));
expect(
screen.getByRole('region', { name: '游戏代码资源画布' }),
).not.toBeNull();
expect(screen.getByText('没有匹配资源')).not.toBeNull();
within(outline).queryByRole('button', { name: /游戏代码/ }),
).toBeNull();
expect(
screen.queryByRole('region', { name: '游戏代码资源画布' }),
).toBeNull();
fireEvent.click(within(outline).getByRole('button', { name: /文档/ }));
const dispatchPageWheel = (target: Element, deltaY = 160) => {
@@ -606,10 +624,12 @@ export function registerProjectWorkbenchFoundationTests() {
.map(Number);
const fittedViewport = readViewport();
const fittedBounds = readFitBounds();
expect(
Math.abs(fittedBounds[2]! * fittedViewport[2]! - 468) < 1 ||
Math.abs(fittedBounds[3]! * fittedViewport[2]! - 268) < 1,
).toBe(true);
const expectedDocumentFitScale = Math.min(
RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE,
468 / fittedBounds[2]!,
268 / fittedBounds[3]!,
);
expect(fittedViewport[2]).toBeCloseTo(expectedDocumentFitScale, 8);
const viewportBeforeZoom = fittedViewport;
const zoomWheel = new WheelEvent('wheel', {
@@ -626,6 +646,15 @@ export function registerProjectWorkbenchFoundationTests() {
});
expect(zoomWheelResult).toBe(false);
expect(readViewport()[2]).toBeGreaterThan(viewportBeforeZoom[2]!);
const documentViewportAfterZoom = readViewport();
fireEvent.click(within(outline).getByRole('button', { name: /美术资源/ }));
expect(
screen.getByRole('region', { name: '美术资源资源画布' }),
).not.toBeNull();
fireEvent.click(within(outline).getByRole('button', { name: /设计文档/ }));
await waitFor(() =>
expect(readViewport()).toEqual(documentViewportAfterZoom),
);
expect(
screen.getByRole('region', { name: '设计文档资源画布' }),
).not.toBeNull();
@@ -1051,10 +1080,16 @@ export function registerProjectWorkbenchFoundationTests() {
}),
);
await waitFor(() => {
expect(screen.queryByLabelText('资源栏目大纲')).toBeNull();
expect(screen.getAllByText('暂无已登记资源')).toHaveLength(4);
expect(
within(screen.getByLabelText('资源栏目大纲')).queryByRole('button', {
name: /有新资源/,
}),
screen
.getByLabelText(/资源(?:依赖|类型)视图/)
.classList.contains('game-resource-canvas--paged'),
).toBe(false);
expect(screen.queryByRole('button', { name: /game\.js/ })).toBeNull();
expect(
screen.queryByRole('region', { name: '游戏代码资源画布' }),
).toBeNull();
});
});
@@ -1224,14 +1259,8 @@ export function registerProjectWorkbenchFoundationTests() {
.getByLabelText(/资源(?:依赖|类型)视图/)
.classList.contains('game-resource-canvas--dependency'),
).toBe(false);
expect(screen.getAllByText('暂无已登记资源')).toHaveLength(5);
for (const label of [
'设计文档',
'美术资源',
'音乐音效',
'游戏代码',
'项目版本',
]) {
expect(screen.getAllByText('暂无已登记资源')).toHaveLength(4);
for (const label of ['设计文档', '美术资源', '音乐音效', '项目版本']) {
expect(screen.getByRole('region', { name: label })).not.toBeNull();
}
};
@@ -1394,6 +1423,9 @@ export function registerProjectWorkbenchFoundationTests() {
expect(heroCard?.textContent).not.toContain('hero.png');
expect(heroCard?.textContent).not.toContain('assets/hero.png');
expect(heroCard?.textContent).not.toContain('Agent 生成');
expect(
heroCard?.querySelector('[data-resource-type="图片"]')?.textContent,
).toBe('图片');
expect(
heroCard?.querySelector('.game-resource-card-open button'),
).toBeNull();
@@ -1411,7 +1443,12 @@ export function registerProjectWorkbenchFoundationTests() {
});
showResourcePage('设计文档');
act(() => observer.triggerVisible());
await screen.findByText(/这是安全的卡片正文摘要。/);
const documentSummary = await screen.findByText(/这是安全的卡片正文摘要。/);
expect(
documentSummary
.closest('.game-resource-card')
?.querySelector('[data-resource-type="文档"]'),
).not.toBeNull();
showResourcePage('美术资源');
expect(
invoke.mock.calls.some(
@@ -1425,6 +1462,9 @@ export function registerProjectWorkbenchFoundationTests() {
name: '播放 intro.mp4',
});
const videoCard = videoControl.closest('.game-resource-card');
expect(
videoCard?.querySelector('[data-resource-type="视频"]'),
).not.toBeNull();
const video = videoCard?.querySelector('video');
expect(video).not.toBeNull();
expect(video?.preload).toBe('auto');
@@ -1475,7 +1515,13 @@ export function registerProjectWorkbenchFoundationTests() {
expect(document.activeElement).toBe(stableVideoControl);
showResourcePage('音乐音效');
fireEvent.click(screen.getByRole('button', { name: '播放 theme.mp3' }));
const audioControl = screen.getByRole('button', { name: '播放 theme.mp3' });
expect(
audioControl
.closest('.game-resource-card')
?.querySelector('[data-resource-type="音频"]'),
).not.toBeNull();
fireEvent.click(audioControl);
await waitFor(() => {
expect(
invoke.mock.calls.some(
@@ -2800,6 +2846,82 @@ export function registerProjectWorkbenchFoundationTests() {
});
});
it('hides dependency edges whose code endpoint is not visible while preserving visible edges', async () => {
const visibleEdgeId = 'asset-reference:["art-a","art-b"]';
const hiddenEdgeId = 'asset-reference:["code","art-a"]';
const graph = normalizeProjectResourceGraph({
resourceIds: ['code', 'art-a', 'art-b'],
referenceEdges: [
{
id: visibleEdgeId,
kind: 'asset-reference',
sourceResourceId: 'art-a',
targetResourceId: 'art-b',
cyclic: false,
},
{
id: hiddenEdgeId,
kind: 'asset-reference',
sourceResourceId: 'code',
targetResourceId: 'art-a',
cyclic: false,
},
],
taskFlows: [],
connectionIndex: [],
producerAssignments: [],
dependencyDepths: [],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
});
const positions: ProjectResourceCanvasPosition[] = [
{
resourceId: 'code',
section: 'code',
x: 0,
y: 0,
manuallyPlaced: false,
},
{
resourceId: 'art-a',
section: 'art',
x: 0,
y: 0,
manuallyPlaced: false,
},
{
resourceId: 'art-b',
section: 'art',
x: 0,
y: 144,
manuallyPlaced: false,
},
];
render(
React.createElement(ResourceDependencyOverlay, {
graph,
positions,
section: 'art',
visibleResourceIds: new Set(['art-a', 'art-b']),
}),
);
const overlay = await screen.findByTestId(
'resource-dependency-overlay-art',
);
await waitFor(() => {
const edgeById = (edgeId: string) =>
Array.from(
overlay.querySelectorAll<SVGPathElement>('[data-edge-id]'),
).find((edge) => edge.getAttribute('data-edge-id') === edgeId);
expect(edgeById(visibleEdgeId)).not.toBeNull();
expect(edgeById(hiddenEdgeId)).toBeUndefined();
});
});
it('coalesces section scroll geometry, keeps partial endpoints stable, and cleans one dependency observer', async () => {
const referenceId = 'asset-reference:["resource-a","resource-b"]';
const graph = normalizeProjectResourceGraph({
@@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest';
import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import { projectResourcesFromReadModels } from '../src/view/project-development/resourceProjectionModel';
import {
projectResourcesFromReadModels,
projectResourceTypeLabel,
} from '../src/view/project-development/resourceProjectionModel';
describe('项目资源投影', () => {
it('只把明确资源投影到固定分类,未知任务产物不会伪装成项目版本', () => {
@@ -225,4 +228,119 @@ describe('项目资源投影', () => {
expect(versions[0]?.version?.childVersionIds).toEqual(['version-child']);
expect(versions[1]?.version?.parentVersionId).toBe('version-root');
});
it('按稳定优先级推导资源卡类型标识并为未知类型兜底', () => {
expect(
projectResourceTypeLabel({
category: 'version',
subtype: 'project-version',
path: 'versions/v1',
mediaType: '正式项目版本',
}),
).toBe('项目版本');
expect(
projectResourceTypeLabel({
category: 'document',
subtype: 'agent-result',
path: '专业 Agent 文本回执',
mediaType: 'Agent 历史文本回执',
}),
).toBe('Agent 回执');
expect(
projectResourceTypeLabel({
category: 'document',
subtype: 'task-artifact',
path: 'memory/plan.md',
mediaType: '项目文档',
}),
).toBe('文档');
expect(
projectResourceTypeLabel({
category: 'audio',
subtype: 'task-artifact',
path: 'audio/theme.wav',
mediaType: '音乐音效产物',
}),
).toBe('音频');
expect(
projectResourceTypeLabel({
category: 'art',
subtype: 'task-artifact',
path: 'assets/hero.svg',
mediaType: '美术产物',
}),
).toBe('SVG');
expect(
projectResourceTypeLabel({
category: 'art',
subtype: 'task-artifact',
path: 'assets/unknown',
mediaType: '美术产物',
}),
).toBe('图片');
expect(
projectResourceTypeLabel({
category: 'art',
subtype: 'character',
path: 'assets/hero.svg',
mediaType: 'image/svg+xml',
}),
).toBe('SVG');
expect(
projectResourceTypeLabel({
category: 'art',
subtype: 'video',
path: 'assets/intro.mp4',
mediaType: 'video/mp4',
}),
).toBe('视频');
expect(
projectResourceTypeLabel({
category: 'art',
subtype: 'character',
path: 'assets/hero.png',
mediaType: 'image/png',
}),
).toBe('图片');
expect(
projectResourceTypeLabel({
category: 'document',
subtype: 'attachment',
path: 'docs/rules.yaml',
mediaType: 'application/yaml',
}),
).toBe('文档');
expect(
projectResourceTypeLabel({
category: 'document',
subtype: 'unknown',
path: 'data/blob',
mediaType: 'application/octet-stream',
}),
).toBe('文档');
expect(
projectResourceTypeLabel({
category: 'code',
subtype: 'source',
path: 'game/main.ts',
mediaType: 'text/typescript',
}),
).toBe('游戏代码');
expect(
projectResourceTypeLabel({
category: 'art',
subtype: 'unknown',
path: 'assets/blob',
mediaType: 'application/octet-stream',
}),
).toBe('图片');
expect(
projectResourceTypeLabel({
category: 'unsupported' as never,
subtype: 'unknown',
path: 'data/blob',
mediaType: 'application/octet-stream',
}),
).toBe('未知');
});
});
@@ -18,6 +18,7 @@ import {
RESOURCE_CANVAS_COLUMN_GAP,
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE,
type ResourceCanvasCardSize,
resourceCanvasContentBounds,
resourceCanvasImageCardSize,
@@ -831,15 +832,23 @@ describe('resource canvas variable card geometry', () => {
expect(viewport.x + bounds.x * viewport.scale).toBeCloseTo(16, 8);
});
it('caps the initial fit without limiting explicit canvas zoom', () => {
const viewport = fitResourceCanvasViewportToContent({
bounds: { x: 0, y: 0, width: 180, height: 128 },
canvasSize: { width: 800, height: 600 },
maxScale: 1.5,
});
it.each(['document', 'art', 'audio', 'code', 'version'])(
'caps the initial fit for every resource category (%s) while keeping explicit zoom overrides available',
() => {
const viewport = fitResourceCanvasViewportToContent({
bounds: { x: 0, y: 0, width: 180, height: 128 },
canvasSize: { width: 800, height: 600 },
});
const explicitViewport = fitResourceCanvasViewportToContent({
bounds: { x: 0, y: 0, width: 180, height: 128 },
canvasSize: { width: 800, height: 600 },
maxScale: 2.5,
});
expect(viewport.scale).toBe(1.5);
});
expect(viewport.scale).toBe(RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE);
expect(explicitViewport.scale).toBe(2.5);
},
);
it('accepts card sizes directly on resource items for hook integration', () => {
const wide = {
@@ -322,7 +322,7 @@ type UpdateProjectResourceCanvasLayoutResult =
- type 模式资源集合变化时保留全部仍存在的坐标,只为新 ID 计算默认位置,并删除已确认失效的旧 ID。dependency 模式只永久保留 `manuallyPlaced=true` 的历史坐标;`manuallyPlaced=false` 属于可派生自动位置,在 Rust 关系图首次就绪、`dependencyDepth` 或资源拓扑身份签名(精确引用端点和聚合 task-flow 成员)变化后按最终拓扑确定性重算。签名以稳定资源 ID 的规范端点 / 成员序列生成固定大小摘要,不使用显示名称或浏览器测量值;自动重算不得移动手动坐标,协调结果与持久布局逐项一致时不得产生 CAS 写入。
- 搜索或筛选只隐藏卡片,不删除、压缩或重排其坐标;清空搜索后恢复原位置。
- 窗口尺寸变化只改变当前栏目的可视范围,不回写或裁切持久坐标,也不因资源 extent 或 resize 把已平移的 viewport 拉回内容边界。当前客户端继续以 `1280×800` 横屏合同验收。
- 任一栏目出现资源后,资源管理固定使用 `设计文档 -> 美术资源 -> 音乐音效 -> 游戏代码 -> 项目版本` 栏目分页画布;每个栏目按 `projectId + mode + category` 保留独立 viewport普通 wheel 切换栏目,`Ctrl/Cmd + wheel` 以指针位置为锚点缩放当前无限画布,空白拖动只平移当前栏目;非空状态不提供分区高度、分区内部滚动或分区内容倍率。搜索和详情开关不得重置 viewport,项目、mode 或栏目切换只恢复各自会话状态,显式复位才重新适配当前栏目内容。
- 任一可见资源出现后,普通用户资源管理固定使用 `设计文档 -> 美术资源 -> 音乐音效 -> 项目版本` 栏目分页画布;游戏代码仍保留在内部资源、布局和依赖事实中,但不进入普通资源画布的导航、分页、卡片、搜索或详情入口。每个可见栏目按 `projectId + mode + category` 保留独立 viewport普通 wheel 切换栏目,`Ctrl/Cmd + wheel` 以指针位置为锚点缩放当前无限画布,空白拖动只平移当前栏目;非空状态不提供分区高度、分区内部滚动或分区内容倍率。搜索和详情开关不得重置 viewport,项目、mode 或栏目切换只恢复各自会话状态,显式复位才重新适配当前栏目内容。
- 首次载入项目中的既有资源不显示未读标识。当前会话内,非当前栏目出现稳定 ID 的新资源时,在对应栏目名称右上角显示红点;当前栏目新增资源不显示红点,用户通过点击、滚轮或程序跳转进入该栏目后立即清除。未读状态只属于当前前端会话,并按 `projectPath + projectId` 隔离,切换项目时清空,不写入 manifest、布局 sidecar 或后端。
- 打开项目、切换 mode 或当前 mode 首次出现新资源时执行“读取 -> 协调 -> 必要时 CAS 写入”;dependency 模式必须先等待与当前 `projectPath + projectId + resource inputs` 匹配的 Rust 图进入 `ready``failed` 终态,等待期间不得创建 fallback、读取 sidecar、协调资源或入队保存。`failed` 只允许以空图降级初始化一次。项目或 mode 已切换后返回的旧异步结果必须丢弃。
- 同一 `projectPath + projectId + mode` 的首次读取与资源集合协调必须分开:资源集合变化不得取消已经发出的读取或保存。当前 scope 内资源自动协调写入使用单写者 FIFO,任一时刻最多一个 CAS 在途,后一笔必须使用前一笔成功返回的 revision。切换项目或 mode 后,旧 scope 的在途请求不能阻塞新 scope 队列;前端放弃旧请求槽位并丢弃其迟到响应,后端继续依靠 `expectedProjectId + expectedRevision + 系统锁` 仲裁已发出的请求。
@@ -355,7 +355,7 @@ type UpdateProjectResourceCanvasLayoutResult =
### 5.3 资源类型与替换兼容性(P1)
实现状态(2026-08-23):当前资源投影与栏目页顺序收口为“设计文档 -> 美术资源 -> 音乐音效 -> 游戏代码 -> 项目版本”。设计文档接收受支持的 UTF-8 文档、代码资产中的文档类型和合法 Agent 文本回执;项目版本只接收显式 `ProjectVersionResourceSummary` read model,未知任务产物不得兜底为版本;美术资源接收图片、SVG、动画和视频类产物;音乐音效接收 manifest 资产、上传登记资产和已完成任务 `artifacts` 明确声明的音频产物;游戏代码接收 Direct Codex / 任务产物登记的 HTML、CSS 和 JavaScript。无法识别的二进制任务产物和附件不进入资源画布。受控读取、中央聚焦、失败空态与媒体播放不改变 manifest 真相;编辑成功后只追加新的 asset 或版本子记录。
实现状态(2026-08-29):内部资源投影仍识别“设计文档、美术资源、音乐音效、游戏代码、项目版本”五类事实,但普通用户资源画布只展示“设计文档 -> 美术资源 -> 音乐音效 -> 项目版本”四个栏目;游戏代码不进入画布导航、分页、卡片、搜索或详情入口。设计文档接收受支持的 UTF-8 文档、代码资产中的文档类型和合法 Agent 文本回执;项目版本只接收显式 `ProjectVersionResourceSummary` read model,未知任务产物不得兜底为版本;美术资源接收图片、SVG、动画和视频类产物;音乐音效接收 manifest 资产、上传登记资产和已完成任务 `artifacts` 明确声明的音频产物;游戏代码继续接收 Direct Codex / 任务产物登记的 HTML、CSS 和 JavaScript,底层文件、manifest 事实、生成/编辑/运行能力、项目版本引用与依赖关系不变。无法识别的二进制任务产物和附件不进入资源画布。受控读取、中央聚焦、失败空态与媒体播放不改变 manifest 真相;编辑成功后只追加新的 asset 或版本子记录。
资源身份固定使用 manifest asset ID、正式 version ID、Agent ID + run ID 或已导入资源稳定路径;显示标题、来源文案变化不得改变 `resourceId`,从而避免布局、依赖边、选择和聚焦状态因改名失效。
@@ -533,12 +533,12 @@ type ProjectAgentMudPointAttribution = {
### 7.5 资源栏目分页无限画布验收
1. 完全空项目继续显示全部栏目的分区展览;任一栏目出现资源后,dependency / type 都切换为固定栏目分页画布,悬浮 Dock、底部下一页标题和普通 wheel 可访问全部栏目,空栏目也可打开空画布。
2. 每个 `projectId + dependency|type + document|art|audio|code|version` 组合保留独立 viewport;切换栏目、模式、项目和打开 / 关闭详情后恢复对应平移与缩放,窗口 resize、媒体测量和资源 extent 变化不得重置用户 viewport。
1. 完全空项目继续显示四个可见栏目的分区展览;任一可见栏目出现资源后,dependency / type 都切换为固定栏目分页画布,悬浮 Dock、底部下一页标题和普通 wheel 可访问全部可见栏目,空栏目也可打开空画布;游戏代码栏目和代码卡片不出现
2. 每个 `projectId + dependency|type + document|art|audio|version` 组合保留独立 viewport隐藏代码资源的历史内部坐标和 sidecar 会话状态不被删除或重写,切换栏目、模式、项目和打开 / 关闭详情后恢复对应可见栏目平移与缩放,窗口 resize、媒体测量和资源 extent 变化不得重置用户 viewport。
3. 当前栏目允许空白拖动无限平移;`Ctrl/Cmd + wheel` 以指针为锚点缩放,显式复位按包含负坐标资源在内的完整 bounds 适配内容。非空状态不显示分区高度、分区内部滚动或分区内容倍率操作。
4. 资源卡超过 `5px` 阈值后进入拖动,预览和 dependency 线同步移动;成功释放只提交一次 `manuallyPlaced=true` CAS,取消、移出释放、媒体控制点击和未超过阈值均不写布局。
5. dependency 引导线消费当前栏目的同类型精确引用,并与卡片共享同一 viewport transform;平移、缩放、拖动预览、搜索和 resize 后端点保持对齐,type 模式不渲染引导线。
6. 栏目分页、viewport 和资源卡拖动只修改工作台会话状态或资源布局 sidecar,不改 manifest、项目 mutation revision、Runtime verification、Agent 权限和预览状态;图片、视频、音频、文档、代码、版本卡片及非模态详情回归全部通过
6. 栏目分页、viewport 和资源卡拖动只修改工作台会话状态或资源布局 sidecar,不改 manifest、项目 mutation revision、Runtime verification、Agent 权限和预览状态;图片、SVG、视频、音频、文档、任务产物、Agent 回执、项目版本卡片及非模态详情回归全部通过,游戏代码仅保留内部事实而不进入普通资源画布
### 7.6 阶段七完整验收
@@ -15,6 +15,14 @@
- 关联文档:相关 PRD、技术文档、提交或 Issue
```
## 2026-08-30 批准 GDD 直接进入做游戏链路
- 背景:立项策划 GDD 批准后需要给用户一个进入做游戏的自然出口,产品决策改为点击按钮后直接开始建造。
- 决策:批准态 GDD 交付行提供“做成游戏”按钮。点击后读取当前项目的权威 `game/fast_gdd.md`,直接创建自动游戏工作区、导入 `text/markdown` 参考附件,并以固定建造指令自动启动 Direct Codex;不再回首页等待用户二次提交。该动作不复制原项目的 `approvedGddRef`、planning sidecar 或 approval receipt。
- 影响范围:AGC 前端 GDD 交付行与现有自动建项/附件导入/Direct Codex 链路;移除首页 RichInputArea 的 GDD 一次性预填链路;不新增 HTTP API、SpacetimeDB schema、迁移、OpenAPI 或正式构建绑定。
- 验证方式:批准态按钮直接创建工作区、导入附件、携带固定首条指令进入项目工作台且重复点击不重复创建的 appSurface 回归;类型检查、编码检查和 `git diff --check` 通过。
- 关联文档:`docs/technical/【技术方案】立项策划AgentFast GDD-2026-08-10.md`
---
## 2026-08-26 运行中自主扩图提案留在编排层
@@ -556,7 +556,7 @@ game-project/
- 阶段三的聚类已落在 `reconcileResourceCanvasLayout` 的 dependency 自动坐标派生步骤。它先按资源分类过滤 reference edge,并把 task-flow 按固定分类切成仅在同类 source / target 同时存在时有效的聚合超边;每个 section 再用精确边与 flow 临时节点建无向邻接表,以迭代遍历生成弱连通组。task-flow 只以“流节点 -> 成员”的线性成员关联参与布局,绝不展开 source × target 资源组合,不绘制 SVG,也不把搜索后的 visible set 用作输入。`dependencyDepth` 仍唯一决定横向业务层级;相关簇按 `minDependencyDepth + minStableResourceId` 排序,孤立集合置于所有相关簇之后,簇间使用单一布局常量留白。每个相关簇内先按稳定资源 ID 建同层初始序,再做固定两轮左至右 / 右至左的中位数扫描:精确引用读取相邻层的上下游 rank,task-flow 读取另一端成员 rank 的中位数,平局按稳定资源 ID 收口。dependency 自动位置使用 `48px` 列间走线区和 `40px` 行间走线区;每个相关簇以最大层行数确定高度,资源较少的层增加确定性半差偏移而在簇内居中,菱形 / 分叉两侧因此保持均衡。type 模式仍使用原 `16px` 行列间距。跨分类 read model 关系不进入前端聚类、边界偏置或拓扑签名,但 Rust 深度与原始图真相不改。显示坐标必须遵守前后端共享的 `0..=1_000_000` 上限;超深依赖在最后合法列确定性饱和,保留原始 `dependencyDepth`,同列资源继续按稳定顺序纵向避让。若任一自动 `x / y` 无法在合法域内落槽,协调必须在 IPC 前失败关闭,不持续提交必然被 Rust 拒绝的坐标。算法保持 `O(V + E)` 图遍历,加固定轮数的层内稳定排序和现有有界占用索引;4096 资源不允许全量配对。旧的 `manuallyPlaced=true` 坐标先占位并原样保留,聚类只派生自动坐标;同类型拓扑身份签名只记录有界的资源 ID 端点 / 成员,以便深度未变但邻接变化时触发重派生。图边、cluster ID 和签名都不写 sidecar。
- 中间主视窗提供 `resource-overview / asset-canvas / resource-editor / run` 四种状态。2026-08-10 起普通用户“新增资源”显示为禁用态且处理函数拒绝 create;所有现役资源从聚焦态“编辑资源”进入非破坏性派生。静态图片继续进入 refine 素材创作无限画布,SVG、视频、音频、文档/代码、Agent 回执和项目版本进入统一资源编辑壳并按能力分流;底层 create 合同仅保留兼容。编辑面板只替换中央区域,不覆盖右侧 Supervisor 或底部 Agent。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled``aria-disabled`;完成后才允许进入运行表现层。切回资源总览只修改前端展示态,不伪造后端预览暂停结果。
- 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执、已导入附件和已完成任务明确登记的产物派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,未完成任务或未在 `artifacts` 中登记的任意本地音频也不冒充正式资源。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
- 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源总览卡片拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供通用工具栏、工具侧边栏或可拖动标题栏。阶段四已补齐安全本地文档、扩展美术媒体与音频聚焦,正文独立滚动,视频 / 音频使用内置媒体控件,失败显示空态。2026-08-26 视觉验收修正:资源总览初次适配与复位最多以 `1.5` 倍缩放卡片,避免低尺寸卡片位图插值放大成糊图;用户主动缩放仍沿用通用画布倍率。美术资源聚焦态改为视口级大预览,保留原始资源读取与元数据,不生成第二份缩略图,图片 / 视频预览按弹窗可用高度展示并允许正文滚动。该资源总览边界不限制后续素材创作无限画布内的图片图层移动/缩放、生成和正式回写。
- 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源总览卡片拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供通用工具栏、工具侧边栏或可拖动标题栏。阶段四已补齐安全本地文档、扩展美术媒体与音频聚焦,正文独立滚动,视频 / 音频使用内置媒体控件,失败显示空态。2026-08-30 视觉验收修正:资源总览所有栏目初次适配与复位最多以 `1.5` 倍缩放卡片,避免单个低尺寸卡片插值放大成糊图;用户主动缩放仍沿用通用画布倍率,并按“排序模式 + 栏目”保留当前会话内的平移和缩放。美术资源聚焦态改为视口级大预览,保留原始资源读取与元数据,不生成第二份缩略图,图片 / 视频预览按弹窗可用高度展示并允许正文滚动。该资源总览边界不限制后续素材创作无限画布内的图片图层移动/缩放、生成和正式回写。
- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,并保留素材信息和数值微调面板;两个面板保持原有 `156px` 最小高度,没有真实数据时只让正文为空,不渲染预设字段、默认数值、未载入控件或自然语言功能占位,也不随空内容收缩。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;参数调整首版仍只保留本地 UI 草稿,不修改代码或 manifest。preview server 对 UTF-8 HTML 响应注入固定同源尺寸桥脚本;注入点通过真实 HTML tokenizer 边界定位,保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,并支持省略 `</body>` / `</html>`。桥以 `ResizeObserver` 观察 `documentElement / body` 根布局,结合页面 load、窗口 resize 与字体就绪重新测量;页面可见时另以 `500ms` 低频兜底探测至多 `512` 个元素的实际边界,探测截断时保留 body / scroll 上界,并按连续测量排除随 viewport 同步变化的 `100vh / 100% / bottom / right` 自反馈。相同尺寸元组去重后才以固定版本 `postMessage` 上报,不订阅整页 `MutationObserver`。宿主同时校验消息 origin 和 `event.source`,以实际内容宽高与当前容器宽高计算不超过 `1` 的等比缩放;首次适配后仍接受内容宽高的真实变化,但仅 viewport 回灌或重复内容尺寸不更新 React 状态。容器 resize 后回到原生视口重新测量;放得下时保持 `1:1`,超出时完整缩小并居中,iframe 禁止横纵滚动条,不能以 `overflow: hidden` 直接裁掉超出内容。非 UTF-8 HTML 原样返回,不因适配桥破坏已有预览。
- 右侧继续复用现有 Project Supervisor 会话、Runtime 澄清和确认链路;输入区展示 `严格审批 / 风险审批 / 无需审批` 独立面板。P0 只有严格审批可选;风险审批和无需审批保持视觉不可用但允许点击查看原因,不替代 Runtime 的逐动作权限、确认、sandbox 或 reconciliation 门禁。风险 Rank 算法记录在 `docs/project-memory/todos/【待解决】AI游戏创作高风险审批Rank-2026-07-20.md`,前端不得自行计算。
- 底部状态栏默认展示策划、美术、程序 3 组,并允许在同一栏展开数值、音频、发布组;状态来自 manifest 与当前 Supervisor run 的 Runtime,悬停显示当前任务与进度。累计泥点必须等待后端计费归因投影;Agent.md 编辑和自定义 Skill 在来源审核、版本、权限、sandbox 与回滚合同完备前不向普通用户开放。
@@ -3,7 +3,7 @@
- 日期:2026-08-10
- 适用范围:AI 游戏创作独立 App、Project Supervisor、Agent Runtime、本地项目策划 sidecar 与后续完整构建准入
> 当前口径(2026-08-25):以本文件中标注的 D11 / 最新修订和当前 `apps/ai-game-creator-shell` 实现为准。D6~D9 等被明确标注为作废或被取代的段落仅保留推导背景,不得作为现行拓扑、入口或 Runtime 真相;产品入口与 DirectProject 总体口径见 `docs/README.md` 和 App 实施计划。
> 当前口径(2026-08-30):以本文件中标注的 D11 / 最新修订和当前 `apps/ai-game-creator-shell` 实现为准。D6~D9 等被明确标注为作废或被取代的段落仅保留推导背景,不得作为现行拓扑、入口或 Runtime 真相;产品入口与 DirectProject 总体口径见 `docs/README.md` 和 App 实施计划。
## 1. 背景与目标
@@ -180,7 +180,7 @@ D9/D10 描述的「manifest ready-task 调度器在 Supervisor 下游启动策
| 构建引用 | `approvedGddRef {gddId, version, fingerprint}` |
| 现有 design 组 UI 名 | `设计实现组`,替代原“策划 Agent”卡片名称 |
| 新组件名 | `GDD 审批卡` |
| 固定入口动作 | `直接开建``开始完整制作``批准并开建` |
| 固定入口动作 | 当前客户端为 `做成游戏`:读取 `game/fast_gdd.md`,直接创建自动游戏工作区、导入参考附件并以固定建造指令启动 Direct Codex;不再回首页等待用户二次提交 |
`approvalRequestId``responseId` 是两个不同的持久 ID:前者由 Runtime 在 GDD 提交前生成、进入不可变 GDD,一张审批卡终身不变;后者由 UI 在用户执行一次决定时生成,并在传输重试中复用。不得继续用含义不明的单个 `requestId` 同时承担两种职责。
@@ -278,7 +278,7 @@ flowchart TD
SPLAN -->|"在自己的 runtime/session 上建 pending,向用户提问"| U
SPLAN -->|"answersSha256 绑回 delivery,再发 continuation 委派<br/>(最多 3 轮,受 clarification_round 上限约束,见第 23.5 节)"| PLANAGENT
PLANAGENT --> GDD["不可变 GDD + approve receipt"]
GDD -->|"用户动作:开始完整制作"| BUILD
GDD -->|"用户动作:做成游戏;读取并导入 game/fast_gdd.md"| BUILD["自动创建游戏工作区<br/>参考附件:fast_gdd.md<br/>固定建造指令"]
U -.->|"直接开建"| BUILD
BUILD --> DAG["现行 16 任务 DAG"]
U --> CHAT
@@ -1517,7 +1517,8 @@ type PlanningBaselineInput =
- 仅首页“做方案”新建项目提交 `standard + project-supervisor-plan`2026-08-13 按 D11 更正,旧值 `project-supervisor-plan-chat` 作废);“做游戏”和“做素材”保持 `autonomous-game-build` 直接开建。前端只提交 Supervisor 根 run 的身份,**不提交也不感知策划子 Agent**——后者由 Supervisor 在服务端通过 `agent.delegate` 派生,页面侧不得直接创建或引用它。
- 项目页新建、打开既有项目和 Godot 导入不新增“进入立项策划”入口,保持现行构建/打开语义;只有已存在 planning sidecar 或 active plan lineage 的项目恢复原有策划链路。
- 策划阶段聊天输入属于当前 run:有活跃决策卡/审批卡时,输入回到该卡片对应 action;无 active run 时才可创建新的 plan continuation。
- approved 后显示“开始完整制作”;“批准并开建”只是先审批、后开建的快捷交互,不合并后端命令或 durable 记录
- approved 后显示“做成游戏”。点击后读取当前有效的 `game/fast_gdd.md`,直接沿现有自动做游戏链路创建新的工作区、导入 `text/markdown` 参考附件,并以固定建造指令作为 `initialSupervisorMessage` 自动启动 Direct Codex;固定指令只说明 GDD 覆盖的栏目,不根据具体 GDD 内容生成总结。该动作不回首页等待二次提交,不把原项目的 `approvedGddRef` 复制到新项目
- 用户仍可在项目工作台继续补充需求;普通首页“做游戏”入口的手动提交行为保持不变。
### 18.2 GDD 审批卡
@@ -1529,7 +1530,7 @@ type PlanningBaselineInput =
- command 进行中禁用重复点击;另一个窗口先决定后,当前卡刷新为 `already-decided`,不能覆盖。
- `recoveryPending=true` 时显示可恢复状态,只允许重试同一 ID,不允许提交新版本或启动构建。
- 待审版本的 receipt 已存在时隐藏对应 stale pending 卡;hydrate 只恢复精确 project/session/run/action identity。
- approved 后只在所有必需投影恢复完成时启用完整构建按钮
- approved 后只在所有必需投影恢复完成时启用“做成游戏”;恢复态不提供该出口
决策卡继续复用现有用户输入卡,不新建平行提问系统。现有完整构建 design 组用户名称改为“设计实现组”,与新阶段“立项策划”区分;内部 Agent ID 不改。
@@ -1703,7 +1704,7 @@ receipt 永远压过 stale pending:一旦该版本存在有效 receiptpendi
| agent.db | 专用幂等 helpersame key conflict;日志达到普通容量、尾部截断与压缩后仍能补齐并保留决定记录 |
| source/security | durable exact identity;三个 action tool`file.read` / `file.list` / `plan.submit_gdd`)广告与执行;MCP 空且 webSearchEnabled=falsecontrol functions 单列;tool-plan/batch/ledger/context/repair/completion 全部跳过 collaborationplan retry 保留 source/profile;除 Runtime-owned submit 外的副作用工具拒绝 |
| Prompt | **2026-08-13 按 D11 改写**:不新增 compositionSupervisor 根 run 沿用现役 supervisor composition、策划子 Agent 复用 `runtime` composition(见第 4.2 节);`decision-checkpoint` 请求 kind 随 D10 作废(见第 5.1 节)。仍冻结:3 轮/单题/固定选项;回答后设计解释与 prototype item;平台事实;恢复摘要;direct-out 条件 |
| frontend | 首页“做方案”目录提交与 Enter 自动创建均为 `standard + project-supervisor-plan`;“做游戏/做素材”均保持 `autonomous-game-build`;项目页新建/打开不首次注入 planningstable approvalRequestId/responseIdbusystale cardhydrate strict input/view;无目录空态;receipt 隐藏 stale pendingcorrupt authority typed errorproject open/reload/resume/submit/decision 刷新;recovery pending;批准并开建两命令顺序 |
| frontend | 首页“做方案”目录提交与 Enter 自动创建均为 `standard + project-supervisor-plan`;“做游戏/做素材”均保持 `autonomous-game-build`;项目页新建/打开不首次注入 planningstable approvalRequestId/responseIdbusystale cardhydrate strict input/view;无目录空态;receipt 隐藏 stale pendingcorrupt authority typed errorproject open/reload/resume/submit/decision 刷新;recovery pending;批准 GDD 后“做成游戏”直接创建自动工作区、导入 `fast_gdd.md` 并自动启动 Direct Codex;重复点击不重复创建;普通首页链路不回归 |
| M2 integration | explicit approved/direct mode;锁内重验 receiptref 贯穿 task/run/completion/context;无 ref 非回归;恢复不换稿 |
关键强杀点逐项覆盖:
@@ -1994,7 +1995,7 @@ M0 完成不表示完整策划闭环已经上线。`M1A-1``M1A-4`、`M1B-1`
| `M1C-2b` | 澄清中转接线、轮次派生、预算注入 | `M1C-2a` | **实现与本包门禁已完成并已快进合回 `feat/five_min_design`**:首 child 的 revision 1 session、`NeedsUserInput → awaiting_user_input`、回答绑定后 continuation 的确定性 session 投影、审批后 `revise/reject` 用户修订谱系及 Provider 活跃时间 usage fact/fold 已接线;末次 `plan.submit_gdd` usage 在 receipt/session successor 落盘且 standalone/v4 anchors 精确消费后于同一项目锁内折叠,真实 receipt 回归证明累计值恰好推进一次,重复审批与 recovery reconcile 不二次推进。`planning_clarification_*` **13 passed / 0 failed**(M1C-2c 语义回归另见本包),另有 static deliveries 44、planning storage 13、planning submit 53、Provider usage 4、末次 usage receipt 1、barrier detail 3 条定向回归通过;格式、offline all-targets、编码与 diff 门禁通过。锁序承诺只适用于 **M1C-2b 新增的 planning 澄清写投影路径**`main_loop` 既有通用 completion blocker 的 execution→project 路径不在本包。第 4 轮信封在正常路径不可达:`agent.delegate` 已在工具边界按血缘上限硬拒并返回 failed observationcoordinator 的超三轮 reconciliation 仅用于损坏血缘纵深防御。审批 UI、hydrate、构建准入和下游完整构建不在本包范围 |
| `M1C-2c` | 决策卡 A/B 语义(第 23.9 节,2026-08-18 实现完成并合回):选项 → 台账映射改为 A/B 均 `confirmed/user_option`、信封 label 形状校验、planning role brief 与 Supervisor playbook/final-reply 文案(B 必须是真实岔路、第 3 项恒定且 description 须给出可执行验证方式、改口转述规则、提问纪律) | `M1C-2b` | **实现与门禁完成,已由 `6e4bd9703` 合回 `feat/five_min_design`**Runtime 已实现 A/B/固定第三项校验、B 不再生成 `default_pending``answerSummary` 逐字保真;非法 C/缺项 fail-closedA/B/自由填写回归已通过。`planning_clarification_*` 13、`project_planning` prompt 5、`planning_submit` 定向回归、prompt bundle、格式、编码、diff、offline all-targets 均通过;不含 M1D-1 前端、hydrate、构建准入或下游完整构建 |
| `M1D-1` | 前端 hydrate 与 GDD 审批卡;决策卡按第 23.9 节实现(label 动态渲染、默认焦点 A、Other 槽不变) | `M1C-2b` | **已完成并合入 `feat/five_min_design`(落地 `0052a80da`,其后 ESLint 修正 `5b11a0530`**:新增严格 `{projectPath}` hydrate command、`plan-gdd-state-view.v1` Rust read model、审批卡与独立 GDD 正文详情弹层;页面只消费 hydrate,决定 responseId 按审批请求/动作复用,`recoveryPending` 仅提供恢复重试;审批前置 pending 与错绑 session 继续 fail-closed。 |
| `M1D-2` | 入口分流与阶段进度 | `M1D-1` | **2026-08-19 更正 `bf2185fba` 的错误入口映射**:仅首页“做方案”新项目以 `standard + project-supervisor-plan` 启动;“做游戏/做素材”保持 `autonomous-game-build`,不再展示额外“直接开建”按钮;项目页新建、打开和 Godot 导入不新增策划入口,仅恢复已有 planning lineage。阶段进度显示轮次 x/3、当前版本和状态徽章;实际项目总控页面挂载 hydrate/审批卡,并将 `project-planning` / 设计组展示名收口。未接 M2 approved-GDD 构建绑定或完整构建按钮。 |
| `M1D-2` | 入口分流与阶段进度 | `M1D-1` | **2026-08-19 更正 `bf2185fba` 的错误入口映射**:仅首页“做方案”新项目以 `standard + project-supervisor-plan` 启动;“做游戏/做素材”保持 `autonomous-game-build`,不再展示额外“直接开建”按钮;项目页新建、打开和 Godot 导入不新增策划入口,仅恢复已有 planning lineage。阶段进度显示轮次 x/3、当前版本和状态徽章;实际项目总控页面挂载 hydrate/审批卡,并将 `project-planning` / 设计组展示名收口。**2026-08-30 变更:批准 GDD 后的“做成游戏”直接创建自动工作区、导入 `game/fast_gdd.md` 并自动启动 Direct Codex;仍未接入正式 `approvedGddRef` 构建绑定。** |
| `M1E` | 端到端与故障注入收口 | `M1D-2` | **已完成**planning 覆盖审计与 submit 拒绝上限收口完成。`PLAN_INVALID_REQUEST` / Provider input 或候选 GDD 的 `PLAN_SIZE_LIMIT` 每 child run 最多 5 次 rejected observation,第 5 次在 observation durable 后终态失败;counter durable,重启不清零。若在第五条 rejected observation 落盘与终态失败之间崩溃,恢复入口会按 durable counter 直接终态失败,不请求第六次 Provider tool-plan。既有不可变 GDD/receipt 的超限读取及 lineage 版本已耗尽均改走 reconciliation,不误耗 Provider 重试额度。第 21 节已存在的三轮、续跑、receipt/replay、投影恢复及 hydrate 回归复核通过;不为“拼接已有单测”新增脆弱大 E2E。 |
**2026-08-18 M1D 审查修复快照**:对 `14c00017c..bf2185fba` 做规格对照审查后,修复三条决定链路缺陷并补齐回归。① `decidePlanGdd` 的失败分支原来不 hydrate,命中后端任一 `PLAN_STALE_APPROVAL` 分支后卡片会停在已失效的 pending 身份上、`recoveryPending` 永不翻真导致「重试恢复」入口不渲染,现已按第 18.3 节在失败分支同样重灌(顺序钉死:`hydratePlanGddState` 入口会清空错误,必须先 hydrate 再写决定错误)。② responseId 复用键原为 `approvalRequestId:action`,不含 comment,违反第 13.2 节「改变 action/comment 必须换新 responseId」,现改为比对 `{action, comment}` 完整意图,判据方向为宁可多换不可少换。③ 第 18.2 节「`recoveryPending` 时不允许提交决定」原来只作用于三个触发按钮,已打开的评论弹层仍可提交,现已同门控并保留用户已输入内容。回归位于 `tests/appSurface/plan-gdd.suite.ts`,三条均经变异验证(逆转对应修复即变红);`appSurface.test.ts` 381 passed`agc:typecheck`、ESLint、编码检查通过。其中锁错误回传绝对路径、阶段进度轮次差一格与 design 组展示名收口三条已于同日补修(见 decision-log 同日两条);仅 hydrate 身份校验与落盘投影修复的顺序一条单列后续,未并入。
@@ -0,0 +1,216 @@
# 【技术说明】DirectProject 未消费用户上传权威文档
- 首次记录:2026-08-30
- 问题类型:DirectProject 上下文消费缺陷 / 可审计性缺陷
- 影响范围:用户上传文档并在正文中明确指定其为本次建造依据的“做游戏”链路
- 当前状态:待排期,本文只用于提 Issue,暂不修复
## 0. Issue 摘要
当用户在正文中明确说明“我上传了一份 GDD,里面包含某些具体内容,请按照这份 GDD 做游戏”时,做游戏 Agent 没有可靠地把该附件当作本轮权威规格来检索、读取和消费。
这不是“上传附件功能失败”:附件已经成功复制到新项目并登记。问题在于,DirectProject 只收到用户正文和项目路径,没有收到“用户上传了哪些附件、附件的真实项目路径、哪个附件被正文指认为权威参考”这类一等上下文;Agent 只能自行猜测并搜索项目文件。
当前实现虽然存在条件性的 native 文件检索路径,但该路径既不是稳定的应用层契约,也没有对应的 Direct 读取审计记录。因此一次 run 结束后无法可靠回答:Agent 是否发现了附件、是否读取了附件、读取结果是否进入了后续设计和代码决策。
## 1. 预期行为
本 Issue 讨论的预期行为有一个重要前提:**不是所有上传文档都自动视为 GDD,也不是所有附件都必须被读取。**
只有当用户在正文或交互中明确表达类似以下意图时,相关文档才应被视为本轮权威参考:
> 我上传了一份 GDD,里面有探测艇、脉冲射击、敌方弹幕、模块选择和棱镜母体,请按照这份 GDD 做游戏。
在这一前提下,Agent 应能够:
1. 知道本轮存在用户上传的参考文档;
2. 找到该文档在当前项目中的真实路径;
3. 读取文档内容;
4. 将文档内容用于后续游戏设计、代码和资源决策;
5. 在可共享、可复核的审计信息中留下足以判断上述行为是否发生的记录。
用户手写 GDD、通过外部功能生成后导入的 GDD、普通上传的 GDD,以及“做成游戏”入口带入的 GDD,在这里都属于同一个用户意图场景。是否来自“做方案”审批链路不是必要前提。
## 2. 实际现象
`gameagent-77b5aa31` 这次 2026-08-30 的 Direct run 中:
- 用户正文明确要求先阅读附件中的 `fast_gdd.md`,并以其作为主要依据;
- 文件成功复制到新项目:
```text
assets/uploads/upload-1788083777445-fast_gdd.md
```
- 原策划项目文件与上传副本大小均为 `7944` 字节,SHA-256 一致,说明复制没有损坏;
- 最终游戏却生成了《星光收集者》:星星收集、荆棘碰撞、左右移动、生命值;
- 原 GDD 的核心实体和循环(探测艇、脉冲射击、敌人、弹幕、模块、风险岔路、棱镜母体)没有体现在最终游戏中;
- 最终美术生成 prompt 只有“轻量、明快、暖色纸张质感背景、可爱的主角、可收集物、障碍物和简洁 HUD”等泛化描述;
- 浏览器验收的 `expectedText` 为空,没有对 GDD 语义做断言。
因此最终产物表现为一个内部自洽、但与用户指定 GDD 不同类型的通用收集类小游戏。
## 3. 代码层证据
### 3.1 附件不进入 Direct turn 的结构化输入
首页正文和附件在前端被分开处理;附件节点不会进入正文 prompt,而是作为单独的附件集合保存。
[richTextToPrompt.tsx](../../apps/ai-game-creator-shell/src/view/home/components/RichInputArea/richTextToPrompt.tsx:21)
进入项目工作台后,`ProjectSupervisor` 只收到项目路径、manifest、初始消息和创作类型,没有附件字段。
[WorkspaceLauncher.tsx](../../apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx:335)
[model.ts](../../apps/ai-game-creator-shell/src/features/app-shell/model.ts:29)
Direct Codex 调用的输入也只有:
```ts
{
projectPath,
prompt,
clientTurnId,
creationType
}
```
[App.tsx](../../apps/ai-game-creator-shell/src/App.tsx:5484)
其中没有附件列表、附件路径、附件 hash 或附件正文。
### 3.2 上传后路径被重写,Agent 不会自动得到真实路径
上传实现会把文件写入 `assets/uploads/upload-<timestamp>-<原文件名>`,例如 `fast_gdd.md` 会变成 `upload-...-fast_gdd.md`
[assets.rs](../../apps/ai-game-creator-shell/src-tauri/src/assets.rs:460)
上传结果会写入项目的 `.agent/manifest.json``.agent/agent.db`,但这些是项目持久化产物,不是自动注入到 Direct LLM 请求的上下文;`.agent` 还是 Direct 的控制面边界,不能作为普通项目文档让 Agent 读取。
### 3.3 代码中存在条件性的主动检索路径,但不是稳定契约
DirectProject 的 app-server 使用项目根作为 `cwd`,并在可用配置下保留 native shell / 命令能力,因此 Agent 理论上可以:
```text
搜索 fast_gdd
→ 找到 assets/uploads/upload-...-fast_gdd.md
→ 用 native 命令读取正文
```
[codex_app_server.rs](../../apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs:1482)
[codex_app_server.rs](../../apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs:1160)
但这条路径有三个问题:
- 需要 Agent 自己判断“这份附件值得检索”,应用没有把附件关系告诉它;
- `agc_list_project_files` 只能返回路径、大小和类型,不返回 Markdown 正文;
- DirectProject 没有接入旧 Agent Runtime 的 `file.read` / `project.search` 工具目录,读取能力取决于 Direct app-server 的 native 工具配置。
[direct_tools_mcp.rs](../../apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs:214)
[agent_native_tools.rs](../../apps/ai-game-creator-shell/src-tauri/src/agent/agent_native_tools.rs:1349)
因此当前代码不能称为“附件已可靠进入 Agent 上下文”,只能称为“Agent 在部分配置下可能自行发现项目文件”。
## 4. 持久化与取证现状
### 4.1 这次 Direct run 没有持久化读取记录
`gameagent-77b5aa31/.agent/agent.db` 共 12 条记录,类型只有:
```text
project.init 1
asset.register 5
canvas.asset_generate 3
conversation.message 3
```
其中没有:
```text
file.read
file.list
project.search
command.exec
agent.runtime.tool_observation
agent.runtime.action_receipt
```
这能证明上传、资源生成、游戏入口写入和对话消息被记录,但不能证明 Direct app-server 没有执行过 native 文件读取。
### 4.2 Direct app-server 的 native read 不在当前 Agent DB 审计范围内
Direct app-server 使用 ephemeral thread。stdout 中的 `item/started``item/completed``commandExecution` 等事件只在运行期间被解析成有限的活动状态,再通过 Tauri event 发给前端;当前实现没有把 native 命令、读取路径、读取结果或读取 hash 追加到项目 `agent.db`
[codex_app_server.rs](../../apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs:883)
[codex_app_server.rs](../../apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs:2496)
相对地,旧 Agent Runtime 的 `file.read` 会写入 `agent.runtime.action_receipt``agent.runtime.tool_observation`,因此旧路径可以审计到读取了哪个文件。
[main_loop.rs](../../apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs:3347)
这说明问题不是项目完全没有持久化能力,而是 DirectProject 的文件读取路径绕过了现有可审计工具链。
## 5. 问题定性
本 Issue 应定性为:
> 当用户明确把某个上传文档指定为本轮游戏制作的权威参考时,DirectProject 没有可靠地把“用户—附件—当前任务”的关系传给 Agent,也没有让文档发现、读取和后续消费形成可观察的工作流证据。结果是 Agent 可以从泛化的游戏目标出发完成一个可运行产物,却不一定消费用户指定的文档规格。
这属于用户意图和 Agent 上下文消费之间的契约缺失,不属于 GDD 审批状态传递错误,也不属于附件复制损坏。
## 6. 明确排除的归因与修复方向
以下内容不应作为本问题的主要根因,也不应直接作为本 Issue 的修复目标:
### 6.1 不把 `approvedGddRef` / approval receipt 缺失视为根因
用户手写 GDD、外部生成后导入的 GDD、普通文件上传的 GDD,都不一定存在 `approvedGddRef` 或审批 receipt,但只要用户在 prompt 中明确指定“按照这份 GDD 做游戏”,Agent 就应该能够正确消费它。
因此,缺失审批状态绑定不能解释这类通用失败。
### 6.2 不把所有上传文档强制当成 GDD
用户上传的文件可能是图片、素材说明、参考资料、README、代码片段或与游戏无关的文档。不能因为文件被上传,就默认它是策划案或本轮的权威规格。
只有用户明确建立“这份文档用于本轮任务”的关系时,才进入本文讨论的语义范围。
### 6.3 不采用“把所有上传文档正文直接拼进 prompt”作为通用修复
附件可能很大、可能是二进制、可能包含不可信内容,也可能只是可选参考。把所有上传文件正文无条件塞入 prompt 会混淆普通附件、参考资料和权威任务规格,也改变当前附件模型的边界。
本文不要求把上传文件正文统一注入 prompt。
### 6.4 不采用“所有附件必须先读取,否则一律阻断”作为通用硬门禁
对于用户没有要求使用的附件,系统不应强制 Agent 读取;对于非文本附件,也不能套用 Markdown 文本读取规则。
本文关注的是用户明确指定文档为权威参考时的消费缺失,不要求把所有附件都改造成强制读取工作流。
## 7. Issue 验收口径
本问题修复完成后,至少应能验证以下事实,但具体实现方式不在本文展开:
- 用户明确指定某个上传文档为本轮任务依据时,Agent 能够发现并读取对应文档;
- 用户未指定的普通附件不会被自动当成 GDD 或强制纳入任务;
- Agent 是否发现、读取以及使用该文档,应能从可共享的 run 审计产物或等价的可审计证据中判断;
- 同一语义在“做成游戏”、普通上传、外部生成后导入和用户手写文档等入口下不依赖审批 receipt 才成立;
- 文档被读取后,至少有一种可验证方式能判断其关键内容是否进入了后续任务上下文,而不是只记录了文件存在。
具体修复方案、上下文协议设计和持久化字段设计另行讨论,本 Issue 不预设实现方案。
## 8. 关联产物
- 策划 run ID`gameagent-cd7f6c81`
- 做游戏 run ID`gameagent-77b5aa31`
- 上传 GDD(项目相对路径):`assets/uploads/upload-1788083777445-fast_gdd.md`
- 建议随 Issue 附上或引用对应 run 的以下复核材料:
- Direct 对话:`.agent/conversations/project.jsonl`
- Direct Agent DB`.agent/agent.db`
- Direct manifest`.agent/manifest.json`
- 最终游戏:`game/index.html`
- 浏览器验证:`.agent/runtime/direct-codex-browser-validation/6/attempt-3/validation.json`
上述材料应以 Issue 附件、仓库归档或团队共享存储的形式提供;本文不依赖某位开发者电脑上的绝对路径。