diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index c6ae93ded..b54425f61 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -2172,6 +2172,15 @@ pub(crate) async fn archive_failed_local_project_resource_edit( archive_failed_local_project_resource_edit_at(input).await } +#[tauri::command] +pub(crate) fn normalize_local_project_raster_resource( + input: NormalizeLocalProjectRasterResourceInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + normalize_local_project_raster_resource_at(input) +} + #[tauri::command] pub(crate) fn import_canvas_asset( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index ff4356a90..8d635d1ba 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2622,6 +2622,7 @@ fn main() { request_local_project_resource_edit_service_identity_confirmation, confirm_local_project_resource_edit_service_identity, archive_failed_local_project_resource_edit, + normalize_local_project_raster_resource, import_canvas_asset, import_canvas_export, sync_canvas_project_assets, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index d8cbe4035..be4606274 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -480,6 +480,7 @@ type AppProps = { orchestrationMode?: 'single-supervisor' | 'professional-dag'; projectSupervisorOnly?: boolean; planningStartMode?: boolean; + activeVersionId?: ProjectSupervisorComponentProps['activeVersionId']; supervisorChatOnly?: boolean; initialSupervisorMessage?: string; initialCreationType?: HomeCreationType | null; @@ -515,6 +516,7 @@ export function App({ orchestrationMode = 'professional-dag', projectSupervisorOnly = false, planningStartMode = false, + activeVersionId = null, supervisorChatOnly = false, initialSupervisorMessage = '', initialCreationType = null, @@ -11092,10 +11094,11 @@ export function App({ const chatProjectAssets = manifest.assets.filter( (asset) => asset.localPath && !asset.localPath.startsWith('.agent/'), ); - // `@` 面板「当前版本素材」的版本来源。本次只透传 manifest 版本列表, - // activeVersionId 传 null 表示回退到 manifest 中最新的版本。 + // `@` 面板「当前版本素材」的版本来源:版本列表来自 manifest, + // 当前版本由工作台壳(`WorkspaceLauncherShell`)持有的那一份状态给出, + // 传 `null` 表示回退到 manifest 中最新的版本。 const chatProjectVersions = manifest.versions ?? []; - const chatActiveVersionId = null; + const chatActiveVersionId = activeVersionId; const visibleMainProjectCheckpoints = projectCheckpoints.slice(0, 5); const currentProjectTitle = localProject ? manifest.name.trim() || projectNameFromPath(localProject.projectPath) diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index c75b73aef..289614325 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -84,6 +84,11 @@ export function WorkspaceLauncherShell({ const activeProjectContextRef = useRef(currentProjectContext); const manifestMergeRef = useRef(null); activeProjectContextRef.current = currentProjectContext; + /** + * C7 当前游戏版本:唯一一份版本状态,同时喂给资源画布(「当前使用」高亮)与 + * `@` 面板(「当前版本素材」页签)。`null` 表示回退到 manifest 中最新的版本。 + */ + const [activeVersionId, setActiveVersionId] = useState(null); useEffect(() => { const nextTitle = @@ -112,6 +117,22 @@ export function WorkspaceLauncherShell({ currentProjectContext?.projectPath, ]); + // 换项目时回到「最新版本」,不把上一个项目的 versionId 带过去。 + useEffect(() => { + setActiveVersionId(null); + }, [currentProjectContext?.projectPath]); + + // 选中的版本已经不在了(例如随素材一并删除)时清回「最新版本」, + // 否则版本入口会显示最新版本、而资源卡按空态不高亮,两边口径对不上。 + const projectVersionIds = (currentProjectContext?.manifest.versions ?? []) + .map((version) => version.versionId) + .join('\u0000'); + useEffect(() => { + if (!activeVersionId) return; + if (projectVersionIds.split('\u0000').includes(activeVersionId)) return; + setActiveVersionId(null); + }, [activeVersionId, projectVersionIds]); + const applyManifestSnapshot = useCallback( (snapshot: ProjectManifestSnapshot) => { const current = activeProjectContextRef.current; @@ -329,6 +350,8 @@ export function WorkspaceLauncherShell({ agentRuntimeSummaries={activeProjectAgentRuntimeSummaries} agentResults={activeProjectAgentResults} planningStartMode={currentProjectContext.startMode === 'planning'} + activeVersionId={activeVersionId} + onActiveVersionChange={setActiveVersionId} onPlay={() => requestCurrentProjectPlay(currentProjectContext.projectPath) } @@ -344,6 +367,7 @@ export function WorkspaceLauncherShell({ initialSupervisorMessage={currentProjectContext.initialPrompt} initialCreationType={currentProjectContext.creationType} initialAttachments={currentProjectContext.attachments} + activeVersionId={activeVersionId} orchestrationMode="single-supervisor" projectSupervisorOnly planningStartMode={ diff --git a/apps/ai-game-creator-shell/src/features/app-shell/model.ts b/apps/ai-game-creator-shell/src/features/app-shell/model.ts index 8d8283eba..d613fcc59 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/model.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/model.ts @@ -40,6 +40,11 @@ export type ProjectSupervisorComponentProps = { orchestrationMode?: 'single-supervisor' | 'professional-dag'; projectSupervisorOnly?: boolean; planningStartMode?: boolean; + /** + * C7 当前游戏版本:由工作台壳持有,supervisor 里的 `@` 面板按它切「当前版本素材」。 + * `null` 表示回退到 manifest 中最新的版本。 + */ + activeVersionId?: string | null; playRequest?: { projectPath: string; requestId: number; diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/GameRunVersionPicker.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/GameRunVersionPicker.tsx new file mode 100644 index 000000000..91c7e652e --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/GameRunVersionPicker.tsx @@ -0,0 +1,115 @@ +import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import type { GameIterationVersion } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { formatIterationVersionLabel } from './resourceCanvasVersionBindingModel'; + +type GameRunVersionPickerProps = { + versions: readonly GameIterationVersion[]; + activeVersionId: string | null; + onSelectVersion: (versionId: string) => void; +}; + +/** + * 运行模块右上角的版本入口。 + * + * 数据只来自 manifest `versions[]`:没有版本时**不渲染**入口,不伪造版本。 + * 选择只改「哪个版本是当前版本」这条记录层状态;画面刷新由宿主重载既有预览完成。 + */ +export function GameRunVersionPicker({ + versions, + activeVersionId, + onSelectVersion, +}: GameRunVersionPickerProps) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + if (!open) return; + const handlePointerDown = (event: MouseEvent) => { + if (rootRef.current?.contains(event.target as Node)) return; + setOpen(false); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false); + }; + window.addEventListener('mousedown', handlePointerDown); + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('mousedown', handlePointerDown); + window.removeEventListener('keydown', handleKeyDown); + }; + }, [open]); + + if (versions.length === 0) { + return null; + } + + const currentVersion = + versions.find((version) => version.versionId === activeVersionId) ?? + versions[versions.length - 1]; + if (!currentVersion) { + return null; + } + + return ( +
+ + {open + ? createPortal( +
+ {versions.map((version) => { + const selected = version.versionId === currentVersion.versionId; + return ( + + ); + })} +
, + document.body, + ) + : null} +
+ ); +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css new file mode 100644 index 000000000..9490e5615 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css @@ -0,0 +1,241 @@ +/* + * 共享美术画布组件(ImageCanvasSelectedLayerToolbarView / ImageCanvasQuickEditPanelView) + * 的 AGC 宿主 chrome。 + * + * 这些 `image-canvas-editor__*` 类名在网页端由全局样式表 src/index.css 提供;AGC 是独立 + * 应用、不引入那张整站样式表,因此在这里按实际用到的类补一套宿主样式:定位、浮层层级、 + * 按钮与面板外观。组件本身与交互语义仍全部复用美术画布的实现。 + */ + +/* 主题变量:与网页端 .image-canvas-editor 作用域下的一组变量保持一致。 */ +.game-resource-canvas-toolbar-host, +.image-canvas-editor__portal-theme, +.image-canvas-editor__portal-menu { + --image-canvas-brand-accent: var(--platform-accent, #c7653d); + --image-canvas-brand-accent-strong: #6f2f21; + --image-canvas-brand-fill: var( + --platform-button-primary-fill, + linear-gradient(135deg, #df7f40, #b95d3a) + ); + --image-canvas-brand-text-on-fill: var( + --platform-button-primary-text, + #fffaf5 + ); + --image-canvas-brand-border: rgba(204, 117, 76, 0.34); + --image-canvas-brand-border-strong: rgba(199, 101, 61, 0.64); + --image-canvas-brand-border-soft: rgba(226, 203, 184, 0.82); + --image-canvas-brand-soft: rgba(234, 204, 179, 0.28); + --image-canvas-brand-soft-strong: rgba(238, 208, 183, 0.46); + --image-canvas-brand-shadow: rgba(182, 98, 63, 0.16); + --image-canvas-brand-focus-ring: rgba(204, 117, 76, 0.18); +} + +/* 选中工具条:贴在资源卡正上方浮出,层级高于卡片与画布标题栏。 */ +.game-resource-canvas-toolbar-host + .image-canvas-editor__floating-toolbar { + position: absolute; + z-index: 60; + display: inline-flex; + max-width: min(560px, 92vw); + align-items: center; + flex-wrap: wrap; + gap: 0.3rem; + border: 1px solid var(--image-canvas-brand-border-soft); + border-radius: 0.5rem; + background: #ffffff; + padding: 0.28rem; + box-shadow: 0 18px 34px rgba(15, 23, 42, 0.18); + transform: translate(-50%, -100%); + pointer-events: auto; +} + +.game-resource-canvas-toolbar-host + .image-canvas-editor__floating-toolbar + button { + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--image-canvas-brand-border-soft); + border-radius: 0.375rem; + background: #ffffff; + color: #334155; + cursor: pointer; + transition: + transform 160ms ease, + background-color 160ms ease, + border-color 160ms ease, + color 160ms ease; +} + +.game-resource-canvas-toolbar-host + .image-canvas-editor__floating-toolbar + button:hover:not([disabled]) { + transform: translateY(-1px); + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft); + color: var(--image-canvas-brand-accent-strong); +} + +.game-resource-canvas-toolbar-host + .image-canvas-editor__floating-toolbar + button[disabled] { + opacity: 0.6; + cursor: not-allowed; +} + +.game-resource-canvas-toolbar-host + .image-canvas-editor__floating-toolbar + button.image-canvas-editor__floating-toolbar-text-button { + width: auto; + min-width: 0; + gap: 0.35rem; + padding: 0.34rem 0.62rem; + font-size: 0.74rem; + font-weight: 850; + white-space: nowrap; +} + +.game-resource-canvas-toolbar-host + .image-canvas-editor__floating-toolbar-divider { + display: block; + width: 1px; + height: 1.1rem; + background: var(--image-canvas-brand-border-soft); +} + +/* 快速编辑浮层面板:锚在资源卡正下方。 */ +.game-resource-canvas-toolbar-host .image-canvas-editor__generation-composer { + position: absolute; + z-index: 70; + display: flex; + width: min(360px, 92vw); + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem; + border: 1px solid var(--image-canvas-brand-border-soft); + border-radius: 0.75rem; + background: #ffffff; + box-shadow: 0 22px 40px rgba(15, 23, 42, 0.22); + transform: translateX(-50%); + pointer-events: auto; +} + +.game-resource-canvas-toolbar-host .image-canvas-editor__generation-prompt { + width: 100%; + min-height: 4.5rem; + resize: none; + border: 1px solid var(--image-canvas-brand-border-soft); + border-radius: 0.5rem; + padding: 0.5rem 0.6rem; + background: #fffdfa; + color: #1f2937; + font-size: 0.82rem; + line-height: 1.5; +} + +.game-resource-canvas-toolbar-host .image-canvas-editor__reference-strip { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.game-resource-canvas-toolbar-host + .image-canvas-editor__generation-composer-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + flex-wrap: wrap; +} + +.game-resource-canvas-toolbar-host .image-canvas-editor__option-cluster { + display: inline-flex; + align-items: center; + gap: 0.3rem; + flex-wrap: wrap; +} + +.game-resource-canvas-toolbar-host .image-canvas-editor__option-popover-anchor { + position: relative; + display: inline-flex; +} + +.game-resource-canvas-toolbar-host + .image-canvas-editor__option-popover { + position: absolute; + bottom: calc(100% + 6px); + left: 0; + z-index: 80; + min-width: 12rem; + border: 1px solid var(--image-canvas-brand-border-soft); + border-radius: 0.6rem; + background: #ffffff; + padding: 0.5rem; + box-shadow: 0 18px 32px rgba(15, 23, 42, 0.2); +} + +.game-resource-canvas-toolbar-host + .image-canvas-editor__option-popover-sections { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.game-resource-canvas-toolbar-host + .image-canvas-editor__option-popover-items { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} + +.game-resource-canvas-toolbar-host + .image-canvas-editor__option-popover-choice { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.28rem 0.5rem; + border: 1px solid var(--image-canvas-brand-border-soft); + border-radius: 0.375rem; + background: #fffdfa; + color: #334155; + font-size: 0.74rem; + cursor: pointer; +} + +.game-resource-canvas-toolbar-host + .image-canvas-editor__generation-submit { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.4rem 0.9rem; + border: 1px solid transparent; + border-radius: 0.5rem; + background: var(--image-canvas-brand-fill); + color: var(--image-canvas-brand-text-on-fill); + font-size: 0.78rem; + font-weight: 850; + cursor: pointer; +} + +.game-resource-canvas-toolbar-host + .image-canvas-editor__generation-submit:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.game-resource-canvas-toolbar-host .image-canvas-editor__generate-status { + margin: 0; + font-size: 0.74rem; + color: #8c6252; +} + +@media (max-width: 720px) { + .game-resource-canvas-toolbar-host + .image-canvas-editor__floating-toolbar { + max-width: 94vw; + } + + .game-resource-canvas-toolbar-host .image-canvas-editor__generation-composer { + width: 94vw; + } +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasQuickEditModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasQuickEditModel.ts new file mode 100644 index 000000000..5d5535c5b --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasQuickEditModel.ts @@ -0,0 +1,15 @@ +import type { CanvasLayer } from '../../../../../src/components/image-editor/ImageCanvasEditorTypes'; +import type { QuickEditPanelState } from '../../../../../src/components/image-editor/ImageCanvasEditorTypes'; +import { createQuickEditPanelDraft } from '../../../../../src/components/image-editor/ImageCanvasGenerationDialogModel'; + +/** + * AGC 资源卡快速编辑面板的初始草稿。 + * + * 直接在网页端美术画布的草稿工厂上包一层,保证两个画布的默认模型、比例与尺寸 + * 取值完全相同;AGC 侧不再自带第二套默认值。 + */ +export function createResourceQuickEditPanelDraft( + sourceLayer: CanvasLayer, +): QuickEditPanelState { + return createQuickEditPanelDraft(sourceLayer, {}); +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasToolbarModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasToolbarModel.ts new file mode 100644 index 000000000..b4e11bbec --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasToolbarModel.ts @@ -0,0 +1,167 @@ +import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import type { + CanvasAssetKind, + CanvasLayer, + CanvasMediaType, +} from '../../../../../src/components/image-editor/ImageCanvasEditorTypes'; +import type { ImageCanvasSelectedToolbarAction } from '../../../../../src/components/image-editor/ImageCanvasSelectedLayerToolbarView'; +import { canonicalProjectedResourceMediaType } from '../../view/project-development/resourceEditModel'; +import type { ProjectResource } from '../../view/project-development/resourceProjectionModel'; + +/** 快速编辑与改造都走 `derive_local_project_resource`,只接受这三种栅格图片格式。 */ +const RESOURCE_RASTER_MEDIA_TYPES = new Set([ + 'image/png', + 'image/jpeg', + 'image/webp', +]); + +const CANVAS_ASSET_KINDS = new Set([ + 'audio', + 'spec', + 'character', + 'character-animation', + 'icon', + 'icon-spritesheet', + 'icon-spec', + 'publication-material', + 'ui-design', + 'video', + 'sound-effect', + 'background-music', + 'scene', +]); + +export function resourceCanvasMediaType( + resource: Pick, +): CanvasMediaType | undefined { + const mediaType = canonicalProjectedResourceMediaType({ + mediaType: resource.mediaType, + path: resource.path, + } as ProjectResource); + if (mediaType.startsWith('image/')) { + return 'image'; + } + if (mediaType.startsWith('video/')) { + return 'video'; + } + if (mediaType.startsWith('audio/') || resource.category === 'audio') { + return 'audio'; + } + return undefined; +} + +export function resourceCanvasAssetKind( + resource: Pick, +): CanvasAssetKind | null { + return CANVAS_ASSET_KINDS.has(resource.subtype) + ? (resource.subtype as CanvasAssetKind) + : null; +} + +export function isResourceRasterImage( + resource: Pick, +) { + return RESOURCE_RASTER_MEDIA_TYPES.has( + canonicalProjectedResourceMediaType({ + mediaType: resource.mediaType, + path: resource.path, + } as ProjectResource), + ); +} + +/** + * 任务产物类型的资源只有先被正规化成正式 manifest 素材,才能作为派生源。 + * + * 与 Rust `normalize_local_project_raster_resource_at` 的门禁对齐:必须是已完成 + * 任务登记在 artifacts 里的栅格图片产物。 + */ +export function canNormalizeResourceIntoManifestAsset( + manifest: GameCreationAppManifest, + resource: Pick< + ProjectResource, + 'manifestAssetId' | 'producerTaskId' | 'path' | 'mediaType' | 'category' + >, +) { + if (resource.manifestAssetId !== null) { + return false; + } + const producerTaskId = resource.producerTaskId?.trim(); + if (!producerTaskId || !isResourceRasterImage(resource)) { + return false; + } + return manifest.tasks.some( + (task) => + task.id === producerTaskId && + task.status === 'completed' && + task.artifacts.includes(resource.path), + ); +} + +export function resolveResourceCanvasToolbarActions( + manifest: GameCreationAppManifest, + resource: ProjectResource, +): ReadonlySet { + const supported = new Set(); + const mediaType = resourceCanvasMediaType(resource); + const canDeriveFromResource = + resource.manifestAssetId !== null || + canNormalizeResourceIntoManifestAsset(manifest, resource); + if (mediaType === 'image') { + if (isResourceRasterImage(resource) && canDeriveFromResource) { + supported.add('quick-edit'); + } + if (canDeriveFromResource) { + supported.add('redraw'); + } + if ( + resourceCanvasAssetKind(resource) === 'character' && + canDeriveFromResource + ) { + supported.add('character-animation'); + } + return supported; + } + if ( + (mediaType === 'video' || mediaType === 'audio') && + canDeriveFromResource + ) { + supported.add('redraw'); + } + return supported; +} + +/** + * 把资源卡投影成美术画布 `CanvasLayer`,让工具条、命中测试与几何判定共用同一套语义。 + */ +export function projectResourceToCanvasLayer({ + resource, + x, + y, + width, + height, + src = '', +}: { + resource: ProjectResource; + x: number; + y: number; + width: number; + height: number; + src?: string; +}): CanvasLayer { + return { + id: resource.id, + resourceId: resource.id, + title: resource.label, + src, + mediaType: resourceCanvasMediaType(resource), + x, + y, + width, + height, + originalWidth: width, + originalHeight: height, + zIndex: 0, + sourceType: 'uploaded', + assetKind: resourceCanvasAssetKind(resource), + }; +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasVersionBindingModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasVersionBindingModel.ts new file mode 100644 index 000000000..b65737cf1 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasVersionBindingModel.ts @@ -0,0 +1,71 @@ +import type { + GameCreationAppManifest, + GameIterationVersion, + GameIterationVersionCreatedReason, +} from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import type { ProjectResource } from '../../view/project-development/resourceProjectionModel'; +import { resolveActiveIterationVersion } from '../project-workspace/resourceReferences'; + +/** + * 版本创建原因的中文标签口径。运行模块版本入口与资源详情共用同一份映射, + * 不在各调用点各写一遍。 + */ +export const ITERATION_VERSION_REASON_LABELS: Record< + GameIterationVersionCreatedReason, + string +> = { + initial: '初始版本', + 'resource-replacement': '资源替换', + 'agent-revision': '智能体修订', +}; + +/** 版本在 UI 里的可读标识:创建原因 + 创建时间。 */ +export function formatIterationVersionLabel( + version: Pick, +) { + const reason = + ITERATION_VERSION_REASON_LABELS[version.createdReason] ?? + version.createdReason; + const createdAt = new Date(version.createdAt); + const time = Number.isFinite(createdAt.getTime()) + ? createdAt.toLocaleString('zh-CN') + : String(version.createdAt); + return `${reason} · ${time}`; +} + +/** + * C5 / C7 卡片边框:一个资源是否正在被「当前版本」使用。 + * + * 版本绑定是资源记录的权威口径,`slotId` 恒等映射为 `asset:{assetId}`, + * `resourceId` 指向 manifest 资产 ID。当前版本由 `activeVersionId` 决定, + * 解析口径复用 `@` 面板那一条:传 `null` / 不传时回退到 manifest 中最新的版本。 + */ +export function currentVersionResourceBindingIds( + manifest: Pick, + activeVersionId: string | null = null, +): Set { + const currentVersion = resolveActiveIterationVersion( + manifest.versions, + activeVersionId, + ); + if (!currentVersion) { + return new Set(); + } + return new Set( + currentVersion.resourceBindings.flatMap((binding) => + binding.slotId === `asset:${binding.resourceId}` + ? [binding.resourceId] + : [], + ), + ); +} + +export function isResourceUsedByCurrentVersion( + resource: Pick, + currentVersionBindingIds: ReadonlySet, +) { + return ( + resource.manifestAssetId !== null && + currentVersionBindingIds.has(resource.manifestAssetId) + ); +} diff --git a/apps/ai-game-creator-shell/src/main.tsx b/apps/ai-game-creator-shell/src/main.tsx index b6eb91323..ce8bef64f 100644 --- a/apps/ai-game-creator-shell/src/main.tsx +++ b/apps/ai-game-creator-shell/src/main.tsx @@ -1,5 +1,6 @@ import '@genarrative/image-canvas-react/styles.css'; import './styles.css'; +import './features/resource-canvas/resourceCanvasChrome.css'; import React from 'react'; import { createRoot } from 'react-dom/client'; diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 5776568fe..585e696c5 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -6862,7 +6862,7 @@ iframe.preview-frame { min-height: var(--resource-card-height); padding: 0; overflow: visible; - border: 1px solid #eadbd4; + border: 0; border-radius: 12px; background: #fff; color: #4e382f; @@ -6892,6 +6892,14 @@ iframe.preview-frame { box-shadow: 0 8px 22px rgb(195 105 62 / 15%); } +/* C5 卡片边框:只有被当前版本绑定使用的素材带边框,其余资源卡无边框。 */ +.game-resource-card.is-current-version { + border: 1px solid #d87342; + box-shadow: + 0 6px 18px rgb(96 62 47 / 10%), + 0 0 0 2px rgb(216 115 66 / 14%); +} + .game-resource-card.is-relation-version-binding { border-color: #d87342; box-shadow: @@ -6975,7 +6983,7 @@ iframe.preview-frame { transform-origin: bottom right; } -.game-resource-card-open { +.game-resource-card-select { position: absolute; z-index: 1; inset: 0; @@ -6989,7 +6997,7 @@ iframe.preview-frame { touch-action: manipulation; } -.game-resource-card-open:focus-visible { +.game-resource-card-select:focus-visible { outline: 3px solid rgb(213 123 81 / 55%); outline-offset: -4px; } @@ -7661,6 +7669,7 @@ iframe.preview-frame { } .game-run-surface { + position: relative; display: grid; grid-template-rows: minmax(300px, 1fr) auto; grid-row: 2 / -1; @@ -7671,6 +7680,82 @@ iframe.preview-frame { background: transparent; } +/* C7 版本入口:运行模块右上角,没有版本时不渲染。 */ +.game-run-version-picker { + position: absolute; + top: 20px; + right: 20px; + z-index: 5; +} + +.game-run-version-trigger { + display: inline-flex; + max-width: min(18rem, 60vw); + min-height: 30px; + align-items: center; + padding: 0 12px; + border: 1px solid #e5cfc4; + border-radius: 999px; + background: rgb(255 250 246 / 92%); + color: #8a4a30; + cursor: pointer; + font-size: 12px; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.game-run-version-trigger:hover, +.game-run-version-trigger:focus-visible { + border-color: #cc8060; + outline: 0; + box-shadow: 0 3px 10px rgb(158 87 57 / 14%); +} + +.game-run-version-menu { + display: grid; + gap: 2px; + max-height: min(20rem, 60vh); + min-width: 14rem; + padding: 6px; + border: 1px solid #e5cfc4; + border-radius: 12px; + background: #fffaf6; + box-shadow: 0 12px 28px rgb(120 70 45 / 18%); + overflow-y: auto; + z-index: 80; +} + +.game-run-version-menu button { + display: grid; + gap: 2px; + padding: 7px 9px; + border: 0; + border-radius: 8px; + background: transparent; + color: #50382f; + cursor: pointer; + font-size: 12px; + text-align: left; +} + +.game-run-version-menu button small { + color: #a08073; + font-size: 10px; +} + +.game-run-version-menu button:hover, +.game-run-version-menu button:focus-visible { + background: #fdeee5; + outline: 0; +} + +.game-run-version-menu button.is-selected { + background: #f8e2d6; + font-weight: 700; +} + .game-run-preview { position: relative; display: block; diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index a0cdc3b0c..979b6044c 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -1,4 +1,18 @@ -import type { CanvasViewport } from '@genarrative/image-canvas-core'; +import type { + CanvasMarqueeState, + CanvasViewport, +} from '@genarrative/image-canvas-core'; +import { + createCanvasMarqueeState, + getPointerClient, + resolveQuickEditPanelStyle, + resolveSelectedToolbarStyle, + selectLayersInsideMarquee, +} from '@genarrative/image-canvas-core'; +import { + CanvasChromeButton, + SelectionOverlay, +} from '@genarrative/image-canvas-react'; import { AtSign, Code2, @@ -13,6 +27,7 @@ import { Minus, Music2, Pause, + Pencil, Play, Plus, RotateCcw, @@ -40,8 +55,6 @@ import { useState, type WheelEvent as ReactWheelEvent, } from 'react'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; import { PlatformResourceFilterBar } from '../../../../../packages/shared/src/components/PlatformResourceFilterBar'; import type { @@ -56,6 +69,12 @@ import { assetTagsMatchSelection, buildGameCreationAppAssetTagLibrary, } from '../../../../../packages/shared/src/contracts/gameCreationAppAssetTagLibrary'; +import type { + CanvasLayer, + QuickEditPanelState, +} from '../../../../../src/components/image-editor/ImageCanvasEditorTypes'; +import { ImageCanvasQuickEditPanelView } from '../../../../../src/components/image-editor/ImageCanvasQuickEditPanelView'; +import { ImageCanvasSelectedLayerToolbarView } from '../../../../../src/components/image-editor/ImageCanvasSelectedLayerToolbarView'; import { LocalGamePreviewFrame, type LocalGamePreviewInspectSelection, @@ -66,6 +85,17 @@ import { RESOURCE_REFERENCE_FILTERS, type ResourceReferenceFilter, } from '../../features/project-workspace/resourceReferences'; +import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker'; +import { createResourceQuickEditPanelDraft } from '../../features/resource-canvas/resourceCanvasQuickEditModel'; +import { + canNormalizeResourceIntoManifestAsset, + projectResourceToCanvasLayer, + resolveResourceCanvasToolbarActions, +} from '../../features/resource-canvas/resourceCanvasToolbarModel'; +import { + currentVersionResourceBindingIds, + isResourceUsedByCurrentVersion, +} from '../../features/resource-canvas/resourceCanvasVersionBindingModel'; import { ensureUiDesignResourceForPrototype } from '../../features/ui-editor/uiDesignResourceBridge'; import { currentPlatformSessionGeneration, @@ -143,15 +173,9 @@ import { type ResourceDependencyOverlayHandle, } from './ResourceDependencyOverlay'; import { + canonicalProjectedResourceMediaType, defaultDerivedResourceName, - type ProjectResourceEditCapability, - resolveProjectCharacterAnimationCapability, - resolveProjectResourceEditCapability, } from './resourceEditModel'; -import { - type ResourceEditSubmitInput, - ResourceEditSurface, -} from './ResourceEditSurface'; import { type ProjectAgentResultSummary, type ProjectAttachmentResult, @@ -215,24 +239,6 @@ type WebKitGestureEvent = Event & { scale?: number; }; -type ResourceEditorRoute = { - flowId: string; - operationId: string; - idempotencyKey: string; - resource: ProjectResource | null; - capability: Extract; - generationMode: 'create' | 'derive'; -}; - -function defaultCreatedResourceName( - editKind: ResourceEditorRoute['capability']['editKind'], -) { - if (editKind === 'video') return '新视频'; - if (editKind === 'sound-effect') return '新音效'; - if (editKind === 'background-music') return '新背景音乐'; - return '新资源'; -} - type UiEditorRoute = { resourceId: string; resourceLabel: string; @@ -402,6 +408,12 @@ export type ProjectDevelopmentViewProps = { planningStartMode?: boolean; supervisor: ReactNode; walletEntry?: ReactNode; + /** + * 当前游戏版本 id。`null` 表示回退到 manifest 中最新的版本。 + * 与 `@` 面板共用同一份状态,资源画布的「当前使用」高亮也从这里取版本。 + */ + activeVersionId?: string | null; + onActiveVersionChange?: (versionId: string) => void; onHomeOpen: () => void; onProjectsOpen: () => void; onPlay?: () => void; @@ -457,44 +469,6 @@ const approvalOptions: Array<{ }, ]; -function formatMediaDuration(duration: number | null) { - if (duration === null || !Number.isFinite(duration) || duration < 0) { - return '载入后显示'; - } - const totalSeconds = Math.floor(duration); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - return `${minutes}:${seconds.toString().padStart(2, '0')}`; -} - -function formatVersionCreatedAt(createdAt: number) { - const value = new Date(createdAt); - return Number.isFinite(value.getTime()) - ? value.toLocaleString('zh-CN') - : String(createdAt); -} - -function SafeProjectMarkdown({ content }: { content: string }) { - return ( - ( - {children} - ), - img: ({ alt }) => ( - - {alt ? `图片:${alt}` : '文档图片已省略'} - - ), - }} - > - {content} - - ); -} - function summarizeAgent( manifest: GameCreationAppManifest, group: AgentSummary['group'], @@ -543,6 +517,7 @@ const ResourceCard = memo(function ResourceCard({ resource, selected, relationState, + usedByCurrentVersion, x, y, rotation = 0, @@ -552,7 +527,6 @@ const ResourceCard = memo(function ResourceCard({ cardSize, activeMediaIdentity, onSelect, - onOpenEditor, onPointerDown, onPointerMove, onPointerUp, @@ -567,6 +541,7 @@ const ResourceCard = memo(function ResourceCard({ resource: ProjectResource; selected: boolean; relationState: 'version-binding' | null; + usedByCurrentVersion: boolean; x: number; y: number; rotation?: number; @@ -575,8 +550,7 @@ const ResourceCard = memo(function ResourceCard({ preview: ProjectResourceCardPreviewState; cardSize: ResourceCanvasCardSize; activeMediaIdentity: string | null; - onSelect: (resourceId: string) => void; - onOpenEditor: (resource: ProjectResource) => void; + onSelect: (resourceId: string, options?: { append?: boolean }) => void; onPointerDown: ( event: ReactPointerEvent, resource: ProjectResource, @@ -796,9 +770,12 @@ const ResourceCard = memo(function ResourceCard({ ref={cardRef} className={`game-resource-card${selected ? ' is-selected' : ''}${ relationState ? ` is-relation-${relationState}` : '' - }${dragging ? ' is-dragging' : ''}`} + }${usedByCurrentVersion ? ' is-current-version' : ''}${ + dragging ? ' is-dragging' : '' + }`} data-resource-card-id={resource.id} data-preview-kind={kind} + data-used-by-current-version={usedByCurrentVersion ? 'true' : undefined} style={ { '--resource-x': `${x}px`, @@ -812,12 +789,10 @@ const ResourceCard = memo(function ResourceCard({ onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerCancel} - onClick={() => { - if (resource.subtype === 'UI' || resource.subtype === 'ui-prototype') { - onOpenEditor(resource); - return; - } - onSelect(resource.id); + onClick={(event) => { + onSelect(resource.id, { + append: event.shiftKey || event.ctrlKey || event.metaKey, + }); }} > ) : null} - {mode === 'resources' && - !resourceEditorRoute && - !uiEditorRoute ? ( + {mode === 'resources' && !uiEditorRoute ? ( <> {pendingResourceEdits.length > 0 || pendingResourceEditsLoadState === 'failed' ? ( @@ -5028,363 +5014,8 @@ export default function ProjectDevelopmentView({ onBack={() => setUiEditorRoute(null)} /> - ) : mode === 'resources' && resourceEditorRoute ? ( - ) : mode === 'resources' ? ( <> - {focusedResource ? ( -
-
- - - - - - {categoryLabels[focusedResource.category]} - - - {focusedResource.label} - - - - - {resolveProjectCharacterAnimationCapability( - focusedResource, - ) ? ( - - ) : null} - - {focusedResource.manifestAssetId ? ( - - ) : null} - {focusedResource.manifestAssetId ? ( - - ) : null} - - -
-
- {focusedResource.category === 'art' && - focusedResourceCardPreview.status === 'loaded' && - focusedResourceCardPreview.preview.sourceUrl ? ( -
- {focusedPreviewMediaType?.startsWith('video/') ? ( -
- ) : null} -
-
-
资源路径
-
{focusedResource.path}
-
-
-
资源类型
-
{focusedPreviewMediaType}
-
-
-
资源来源
-
{focusedResource.sourceLabel}
-
- {focusedResource.taskTitle ? ( -
-
来源任务
-
{focusedResource.taskTitle}
-
- ) : null} -
-
依赖层级
-
{focusedResource.dependencyDepth}
-
-
-
依赖状态
-
- {focusedResourceDependencyDetails.cyclic - ? '同类型依赖环' - : '正常'} -
-
-
-
引用上游
-
- {focusedResourceDependencyDetails.upstreamLabels - .length > 0 - ? focusedResourceDependencyDetails.upstreamLabels.join( - '、', - ) - : '暂无同类型引用'} -
-
-
-
引用下游
-
- {focusedResourceDependencyDetails.downstreamLabels - .length > 0 - ? focusedResourceDependencyDetails.downstreamLabels.join( - '、', - ) - : '暂无同类型引用'} -
-
-
-
任务流
-
- {focusedResourceDependencyDetails.taskFlowLabels - .length > 0 - ? focusedResourceDependencyDetails.taskFlowLabels.join( - ';', - ) - : '暂无同类型任务流'} -
-
- {focusedResourceIsAudio ? ( -
-
音频时长
-
{formatMediaDuration(mediaDuration)}
-
- ) : null} - {focusedResource.version ? ( - <> -
-
版本 ID
-
{focusedResource.version.versionId}
-
-
-
项目修订
-
{focusedResource.version.projectRevision}
-
-
-
父版本
-
- {focusedResource.version.parentVersionId ?? - '首个版本'} -
-
-
-
直接子版本
-
- {focusedResource.version.childVersionIds.length > - 0 - ? focusedResource.version.childVersionIds.join( - '、', - ) - : '暂无'} -
-
-
-
创建原因
-
- { - { - initial: '初始版本', - 'resource-replacement': '资源替换', - 'agent-revision': 'Agent 修订', - }[focusedResource.version.createdReason] - } -
-
-
-
创建时间
-
- {formatVersionCreatedAt( - focusedResource.version.createdAt, - )} -
-
-
-
资源绑定
-
- {focusedResource.version.resourceBindings.length > - 0 - ? focusedResource.version.resourceBindings - .map( - (binding) => - `${binding.slotId} → ${binding.resourceId}`, - ) - .join(';') - : '暂无'} -
-
- - ) : null} -
- {focusedResourceIsAudio ? ( -
- {focusedResourceCardPreview.status === 'loaded' && - focusedResourceCardPreview.preview.sourceUrl ? ( -
- ) : null} - {focusedResource.category === 'document' ? ( -
- {focusedResourceCardPreview.status === 'loaded' && - focusedResourceCardPreview.preview.content !== - undefined ? ( - focusedResourceCardPreview.preview.content.trim() ? ( - - ) : ( -

文档为空

- ) - ) : focusedResourceCardPreview.status === 'failed' ? ( -

{focusedResourceCardPreview.error}

- ) : ( -

正在载入文档…

- )} -
- ) : null} - {focusedResource.category === 'code' ? ( -
- {focusedResourceCardPreview.status === 'loaded' && - focusedResourceCardPreview.preview.content !== - undefined ? ( -
-                            
-                              {focusedResourceCardPreview.preview.content}
-                            
-                          
- ) : focusedResourceCardPreview.status === 'failed' ? ( -

{focusedResourceCardPreview.error}

- ) : ( -

正在载入游戏代码…

- )} -
- ) : null} -
-
- ) : null}
+ } + overlay={ + resourceBookView === 'main' ? null : ( +
+ {selectedResourceLayer && + selectedToolbarStyle && + ((selectedToolbarActions?.size ?? 0) > 0 || + Boolean(selectedResource?.manifestAssetId) || + selectedResourceOpensUiEditor) ? ( + + {selectedResourceOpensUiEditor ? ( + + } + onClick={() => + openResourceUiEditor(selectedResource) + } + > + UI 编辑器 + + ) : null} + {selectedResource?.manifestAssetId ? ( + } + onClick={() => + setResourceClassificationAssetId( + selectedResource.manifestAssetId, + ) + } + > + 分类与标签 + + ) : null} + {selectedResource?.manifestAssetId ? ( + } + onClick={() => { + setResourceRenameError(null); + setResourceRenameAssetId( + selectedResource.manifestAssetId, + ); + }} + > + 重命名 + + ) : null} + + } + onOpenQuickEditPanel={openResourceQuickEditPanel} + onOpenRedrawPanel={() => {}} + onOpenCropExpandPanel={() => {}} + onRemoveBackground={() => {}} + onPerfectPixel={() => {}} + isPerfectPixelProcessing={false} + isPerfectPixelPendingConfirmation={false} + isSplittingIconSpritesheet={false} + isPersistingAssetKind={false} + onSplitIconSpritesheet={() => {}} + onExtractUiDesignAssets={() => {}} + onOpenCharacterAnimationPanel={() => {}} + onDownloadLayer={() => {}} + /> + ) : null} + {quickEditPanel && quickEditPanelStyle + ? (() => { + return ( + { + void submitResourceQuickEdit(); + }} + /> + ); + })() + : null} +
+ ) + } onCollapse={returnToResourceBookMain} onPointerDown={handleResourceCanvasPointerDown} onPointerMove={handleResourceCanvasPointerMove} @@ -5602,15 +5333,6 @@ export default function ProjectDevelopmentView({ onPointerMove={handleResourceCanvasPointerMove} onPointerUp={stopResourceCanvasPan} onPointerCancel={stopResourceCanvasPan} - onScroll={(event) => { - resourceListScrollPositionsRef.current.set( - resourceListScrollKey, - { - left: event.currentTarget.scrollLeft, - top: event.currentTarget.scrollTop, - }, - ); - }} > {sortMode === 'dependency' && dependencyRelationshipDescriptions.length > 0 ? ( @@ -5794,6 +5516,11 @@ export default function ProjectDevelopmentView({ ) : (
+
{embeddedPreviewUrl ? ( Promise<() => void>; }; }; + // 资源画布复用网页端美术画布的组件,这条依赖链是明确的跨端耦合: + // ImageCanvasSelectedLayerToolbarView → ImageCanvasGenerationModel →(值导入 + // ApiClientError)→ 主仓 src/services/apiClient.ts → src/services/host-bridge/hostBridge.ts。 + // hostBridge 读取的全局只在主仓 `src/vite-env.d.ts` 里声明过,AGC 侧必须补同名字段 + // 才能过 strict 检查。以后往 AGC 引 `src/components/image-editor/` 的组件,都要检查这条链。 + ReactNativeWebView?: { + postMessage?: (message: string) => void; + }; + wx?: { + miniProgram?: { + navigateTo?: (options: { + url: string; + success?: (result?: unknown) => void; + fail?: (error: { errMsg?: string }) => void; + }) => void; + postMessage?: (message: unknown) => void; + }; + }; + WeixinJSBridge?: unknown; } diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index 843b4b079..36fe691b8 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -332,35 +332,35 @@ async function submitChat(value: string) { fireEvent.click(screen.getByRole('button', { name: '发送' })); } -// 同一张资源卡上有两个按钮:打开详情与 V3 新增的 @ 引用按钮,二者的可访问名都包含 -// 资源文件名,因此按文件名正则查询会同时命中两个元素。资源详情按钮的可访问名模板固定为 -// `打开资源详情:<分类标签> <资源文件名>`(分类标签内不含空格),这里按模板做精确匹配。 +// 同一张资源卡上有两个按钮:选中资源与 V3 新增的 @ 引用按钮,二者的可访问名都包含 +// 资源文件名,因此按文件名正则查询会同时命中两个元素。选中按钮的可访问名模板固定为 +// `选中资源:<分类标签> <资源文件名>`(分类标签内不含空格),这里按模板做精确匹配。 function escapeRegExpLiteral(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -function resourceDetailButtonName(label: string) { - return new RegExp(`^打开资源详情:\\S+ ${escapeRegExpLiteral(label)}$`); +function resourceSelectButtonName(label: string) { + return new RegExp(`^选中资源:\\S+ ${escapeRegExpLiteral(label)}$`); } -function getResourceDetailButton(label: string) { - return screen.getByRole('button', { name: resourceDetailButtonName(label) }); +function getResourceSelectButton(label: string) { + return screen.getByRole('button', { name: resourceSelectButtonName(label) }); } -function findResourceDetailButton( +function findResourceSelectButton( label: string, options?: { timeout?: number; interval?: number }, ) { return screen.findByRole( 'button', - { name: resourceDetailButtonName(label) }, + { name: resourceSelectButtonName(label) }, options, ); } -function queryResourceDetailButton(label: string) { +function queryResourceSelectButton(label: string) { return screen.queryByRole('button', { - name: resourceDetailButtonName(label), + name: resourceSelectButtonName(label), }); } @@ -1293,10 +1293,10 @@ export { describe, emptyProjectPolicy, expect, - findResourceDetailButton, + findResourceSelectButton, fireEvent, GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, - getResourceDetailButton, + getResourceSelectButton, it, mockRoleAgentReply, nativeClipboardMock, @@ -1304,7 +1304,7 @@ export { pickProjectFromLauncher, ProjectDevelopmentView, projectSupervisorResponseStream, - queryResourceDetailButton, + queryResourceSelectButton, React, readFileSync, render, diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 12907344c..e7c0eb06e 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -9,13 +9,13 @@ import { createGameCreationAppManifest, createProjectSupervisorRuntimeHarness, expect, - findResourceDetailButton, + findResourceSelectButton, fireEvent, - getResourceDetailButton, + getResourceSelectButton, it, nativeClipboardMock, pickProjectFromLauncher, - queryResourceDetailButton, + queryResourceSelectButton, React, render, renderAppAt, @@ -316,12 +316,12 @@ export function registerClientHomeTests() { pickProjectFromLauncher(projectPath); const runButton = await screen.findByRole('tab', { name: '运行' }); expect(runButton.getAttribute('data-unavailable')).toBe('true'); - expect(queryResourceDetailButton('live-hero.png')).toBeNull(); + expect(queryResourceSelectButton('live-hero.png')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '同步最新 manifest' })); await openResourceBookCategory('美术资源'); - expect(await findResourceDetailButton('live-hero.png')).not.toBeNull(); + expect(await findResourceSelectButton('live-hero.png')).not.toBeNull(); await openResourceBookCategory('项目版本'); expect( await screen.findByRole('button', { name: /版本 1/ }), @@ -462,7 +462,7 @@ export function registerClientHomeTests() { pickProjectFromLauncher(projectPath); const runButton = await screen.findByRole('tab', { name: '运行' }); expect(runButton.getAttribute('data-unavailable')).toBe('true'); - expect(queryResourceDetailButton('runtime-live-hero.png')).toBeNull(); + expect(queryResourceSelectButton('runtime-live-hero.png')).toBeNull(); await waitFor(() => { expect(runtimeHarness.listen).toHaveBeenCalledWith( 'game-creator-manifest-invalidated', @@ -488,7 +488,7 @@ export function registerClientHomeTests() { await openResourceBookCategory('美术资源'); expect( - await findResourceDetailButton('runtime-live-hero.png', { + await findResourceSelectButton('runtime-live-hero.png', { timeout: 5_000, }), ).not.toBeNull(); @@ -663,14 +663,14 @@ export function registerClientHomeTests() { fireEvent.click(screen.getByRole('button', { name: '项目组' })); pickProjectFromLauncher(secondProjectPath); await openResourceBookCategory('美术资源'); - expect(await findResourceDetailButton('second.png')).not.toBeNull(); + expect(await findResourceSelectButton('second.png')).not.toBeNull(); await act(async () => { resolveStaleRefresh(staleFirstManifest); await staleRefresh; }); - expect(queryResourceDetailButton('stale-first.png')).toBeNull(); - expect(getResourceDetailButton('second.png')).not.toBeNull(); + expect(queryResourceSelectButton('stale-first.png')).toBeNull(); + expect(getResourceSelectButton('second.png')).not.toBeNull(); }); it('starts from the client home and opens a project in the same window', async () => { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 6e6c1bba9..97f9208b3 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -25,17 +25,17 @@ import { createProjectSupervisorRuntimeHarness, emptyProjectPolicy, expect, - findResourceDetailButton, + findResourceSelectButton, fireEvent, GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, type GameCreationAgentRunTrace, - getResourceDetailButton, + getResourceSelectButton, it, mockRoleAgentReply, pickProjectFromLauncher, ProjectDevelopmentView, projectSupervisorResponseStream, - queryResourceDetailButton, + queryResourceSelectButton, React, readFileSync, render, @@ -717,7 +717,7 @@ export function registerProjectWorkbenchFoundationTests() { ), ).toEqual(['设计文档', '美术资源', '音乐音效', '项目版本']); expect(graphResourceIds).toContain('asset:section-code'); - expect(queryResourceDetailButton('section.js')).toBeNull(); + expect(queryResourceSelectButton('section.js')).toBeNull(); expect(screen.queryByRole('region', { name: '游戏代码' })).toBeNull(); expect(outline.querySelectorAll('small')).toHaveLength(0); expect(screen.getByRole('region', { name: '设计文档' })).not.toBeNull(); @@ -844,18 +844,12 @@ export function registerProjectWorkbenchFoundationTests() { fireEvent.click( screen.getByRole('button', { - name: /打开资源详情:设计文档 section\.md/, + name: /选中资源:设计文档 section\.md/, }), ); - expect(screen.getByRole('dialog', { name: 'section.md' })).not.toBeNull(); + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); dispatchViewWheel(); expect(screen.getByRole('region', { name: '设计文档' })).not.toBeNull(); - fireEvent.click( - within(screen.getByRole('dialog', { name: 'section.md' })).getByRole( - 'button', - { name: '收起资源' }, - ), - ); fireEvent.click( within( document.querySelector( @@ -905,7 +899,7 @@ export function registerProjectWorkbenchFoundationTests() { const dragCard = screen .getByRole('button', { - name: /打开资源详情:设计文档 section\.md/, + name: /选中资源:设计文档 section\.md/, }) .closest('.game-resource-card'); expect(dragCard).not.toBeNull(); @@ -953,8 +947,9 @@ export function registerProjectWorkbenchFoundationTests() { await openResourceBookCategory('设计文档'); const viewportBeforePan = readViewport(); + // 与美术画布一致:左键在空白处是框选,平移用中键(或按住空格)。 fireEvent.pointerDown(dependencyCanvas, { - button: 0, + button: 1, clientX: 100, clientY: 100, }); @@ -968,7 +963,7 @@ export function registerProjectWorkbenchFoundationTests() { }); fireEvent.pointerDown(dependencyCanvas, { - button: 0, + button: 1, clientX: 100, clientY: 100, }); @@ -1289,7 +1284,7 @@ export function registerProjectWorkbenchFoundationTests() { .getByLabelText(/资源(?:依赖|类型)视图/) .classList.contains('game-resource-canvas--paged'), ).toBe(false); - expect(queryResourceDetailButton('game.js')).toBeNull(); + expect(queryResourceSelectButton('game.js')).toBeNull(); expect(screen.queryByRole('region', { name: '游戏代码' })).toBeNull(); }); }); @@ -1387,7 +1382,7 @@ export function registerProjectWorkbenchFoundationTests() { fireEvent.click(within(outline).getByRole('button', { name: /美术资源/ })); expect( await screen.findByRole('button', { - name: '打开资源详情:美术资源 local-imported-image.png', + name: '选中资源:美术资源 local-imported-image.png', }), ).not.toBeNull(); }); @@ -1611,7 +1606,7 @@ export function registerProjectWorkbenchFoundationTests() { const showResourcePage = (label: string) => openResourceBookCategory(label); await showResourcePage('美术资源'); - let heroDetailButton = await findResourceDetailButton('hero.png'); + let heroDetailButton = await findResourceSelectButton('hero.png'); const heroCard = heroDetailButton.closest('.game-resource-card'); expect(heroCard?.textContent).not.toContain('hero.png'); expect(heroCard?.textContent).not.toContain('assets/hero.png'); @@ -1729,26 +1724,20 @@ export function registerProjectWorkbenchFoundationTests() { expect(screen.queryByRole('dialog', { name: 'theme.mp3' })).toBeNull(); await showResourcePage('美术资源'); - heroDetailButton = await findResourceDetailButton('hero.png'); + heroDetailButton = await findResourceSelectButton('hero.png'); fireEvent.click(heroDetailButton); - const heroFocus = await screen.findByRole('dialog', { name: 'hero.png' }); - expect(within(heroFocus).getByText('assets/hero.png')).not.toBeNull(); - expect(within(heroFocus).getByText('Agent 生成')).not.toBeNull(); - expect(within(heroFocus).getByText('image/png')).not.toBeNull(); + const heroToolbar = await screen.findByRole('toolbar', { + name: '图片工具栏', + }); expect( - within(heroFocus).getByRole('button', { name: '生成动画' }).className, - ).toContain('game-resource-focus-action'); - expect( - within(heroFocus).getByRole('button', { name: '编辑资源' }).className, - ).toContain('game-resource-focus-action'); + within(heroToolbar).getByRole('button', { name: '快速编辑' }), + ).not.toBeNull(); + expect(screen.queryByRole('dialog', { name: 'hero.png' })).toBeNull(); expect( invoke.mock.calls.filter( ([command]) => command === 'read_local_project_image_preview', ), ).toHaveLength(1); - fireEvent.click( - within(heroFocus).getByRole('button', { name: '收起资源' }), - ); fireEvent.click(screen.getByRole('button', { name: '按类型' })); expect(observer.observedCount()).toBeGreaterThanOrEqual(2); @@ -1772,9 +1761,9 @@ export function registerProjectWorkbenchFoundationTests() { const search = screen.getByLabelText('搜索项目资源'); fireEvent.change(search, { target: { value: 'assets/hero.png' } }); await waitFor(() => expect(pause.mock.instances).toContain(typeVideo)); - expect(getResourceDetailButton('hero.png')).not.toBeNull(); - expect(queryResourceDetailButton('intro.mp4')).toBeNull(); - const typeHeroCard = getResourceDetailButton('hero.png').closest( + expect(getResourceSelectButton('hero.png')).not.toBeNull(); + expect(queryResourceSelectButton('intro.mp4')).toBeNull(); + const typeHeroCard = getResourceSelectButton('hero.png').closest( '.game-resource-card', ); await waitFor(() => @@ -1902,7 +1891,7 @@ export function registerProjectWorkbenchFoundationTests() { document.querySelectorAll('.game-resource-card-visual > img'), ).toHaveLength(48); }); - const wideImageCard = getResourceDetailButton( + const wideImageCard = getResourceSelectButton( 'image-0.png', ).closest('.game-resource-card'); await waitFor(() => { @@ -1938,7 +1927,7 @@ export function registerProjectWorkbenchFoundationTests() { ); await openResourceBookCategory('美术资源'); await waitFor(() => - expect(getResourceDetailButton('next.png')).not.toBeNull(), + expect(getResourceSelectButton('next.png')).not.toBeNull(), ); await waitFor(() => expect(observer.observedCount()).toBe(1)); act(() => observer.triggerVisible()); @@ -2031,10 +2020,10 @@ export function registerProjectWorkbenchFoundationTests() { }), ); const rootVersionButton = screen.getByRole('button', { - name: /打开资源详情:项目版本 版本 1/, + name: /选中资源:项目版本 版本 1/, }); const childVersionButton = screen.getByRole('button', { - name: /打开资源详情:项目版本 版本 2/, + name: /选中资源:项目版本 版本 2/, }); const rootVersionCard = rootVersionButton.closest('.game-resource-card'); const childVersionCard = childVersionButton.closest('.game-resource-card'); @@ -2043,26 +2032,14 @@ export function registerProjectWorkbenchFoundationTests() { expect(childVersionCard?.textContent).not.toContain('version-root'); fireEvent.click(childVersionButton); - const versionFocus = screen.getByRole('dialog', { name: '版本 2' }); - expect(within(versionFocus).getByText('version-child')).not.toBeNull(); - expect(within(versionFocus).getByText('version-root')).not.toBeNull(); - expect(within(versionFocus).getByText('Agent 修订')).not.toBeNull(); - expect( - within(versionFocus).getByText( - 'player → asset-player;historical → asset-removed', - ), - ).not.toBeNull(); + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); expect(screen.queryByText('asset:asset-removed')).toBeNull(); - - fireEvent.click( - within(versionFocus).getByRole('button', { name: '收起资源' }), - ); fireEvent.click( within(screen.getByLabelText('资源栏目大纲')).getByRole('button', { name: /美术资源/, }), ); - const playerCard = getResourceDetailButton('player.png').closest( + const playerCard = getResourceSelectButton('player.png').closest( '.game-resource-card', ); expect(playerCard?.classList.contains('is-relation-version-binding')).toBe( @@ -2071,7 +2048,7 @@ export function registerProjectWorkbenchFoundationTests() { fireEvent.click(screen.getByRole('button', { name: '按依赖' })); await waitFor(() => { - const dependencyPlayerCard = getResourceDetailButton( + const dependencyPlayerCard = getResourceSelectButton( 'player.png', ).closest('.game-resource-card'); expect( @@ -2171,7 +2148,7 @@ export function registerProjectWorkbenchFoundationTests() { resourceCanvas.scrollTop = 36; const artReceiptButton = screen.getByRole('button', { - name: /打开资源详情:设计文档 美术资源计划 Agent 文本回执/, + name: /选中资源:设计文档 美术资源计划 Agent 文本回执/, }); const artReceiptCard = artReceiptButton.closest('.game-resource-card'); expect(artReceiptCard).not.toBeNull(); @@ -2180,7 +2157,7 @@ export function registerProjectWorkbenchFoundationTests() { 'utf8', ); expect(styles).toMatch( - /\.game-resource-card-open\s*\{[^}]*cursor:\s*pointer[^}]*touch-action:\s*manipulation/s, + /\.game-resource-card-select\s*\{[^}]*cursor:\s*pointer[^}]*touch-action:\s*manipulation/s, ); expect(styles).toMatch( /\.game-resource-card-media-control\s*\{[^}]*transform:\s*scale\(var\(--genarrative-image-canvas-inverse-scale,\s*1\)\)[^}]*transform-origin:\s*bottom\s+right/s, @@ -2189,11 +2166,12 @@ export function registerProjectWorkbenchFoundationTests() { /\.game-resource-canvas\s*\{[^}]*user-select:\s*none/s, ); expect(styles).toMatch(/\.game-resource-card\.is-dragging/); + expect(styles).toMatch(/\.game-resource-card\.is-current-version/); fireEvent.click(artReceiptButton); const workbenchStage = screen.getByLabelText('项目主视窗'); expect(workbenchStage.getAttribute('data-resource-view-state')).toBe( - 'resources.focused.document', + 'resources.selected.document', ); expect(screen.getByLabelText('搜索项目资源')).not.toBeNull(); for (const label of [ @@ -2211,7 +2189,6 @@ export function registerProjectWorkbenchFoundationTests() { .getAttribute('aria-pressed'), ).toBe('true'); expect(screen.getByLabelText('资源类型视图')).not.toBeNull(); - expect(document.querySelector('.game-resource-focus-backdrop')).toBeNull(); expect(screen.getByLabelText('陶泥儿 Agent 对话')).not.toBeNull(); expect(screen.getByLabelText('子 Agent 状态栏')).not.toBeNull(); expect( @@ -2222,79 +2199,16 @@ export function registerProjectWorkbenchFoundationTests() { expect(screen.getByLabelText('资源类型视图').hasAttribute('inert')).toBe( false, ); - const receiptFocus = screen.getByRole('dialog', { - name: '美术资源计划 Agent 文本回执', + // 选中资源后浮出的是画布工具条,不再是"资源详情"面板。 + expect(screen.queryByRole('dialog')).toBeNull(); + expect(artReceiptButton.getAttribute('aria-pressed')).toBe('true'); + const receiptToolbar = screen.getByRole('toolbar', { + name: '图片工具栏', }); - expect(receiptFocus.hasAttribute('aria-modal')).toBe(false); + // 分类与标签入口已从详情面板搬到选中工具条。 expect( - await within(receiptFocus).findByRole('heading', { name: '美术计划' }), + within(receiptToolbar).getByRole('button', { name: '分类与标签' }), ).not.toBeNull(); - expect(within(receiptFocus).getByText('仅有美术计划')).not.toBeNull(); - expect(within(receiptFocus).getByText('没有图片文件')).not.toBeNull(); - expect(within(receiptFocus).getByText('等待实际素材生成。')).not.toBeNull(); - expect( - receiptFocus.querySelector('.game-resource-focus-body'), - ).not.toBeNull(); - expect(receiptFocus.querySelector('ul')).not.toBeNull(); - expect(receiptFocus.querySelector('strong')).not.toBeNull(); - expect(receiptFocus.closest('.game-workbench-stage')).toBe(workbenchStage); - expect(receiptFocus.closest('.game-resource-focus-layer')).toBeNull(); - expect(styles).toMatch( - /\.game-resource-focus\s*\{(?=[^}]*position:\s*absolute)(?=[^}]*top:\s*50%)(?=[^}]*left:\s*50%)(?=[^}]*width:\s*min\(560px,\s*calc\(100%\s*-\s*32px\)\))(?=[^}]*max-height:\s*calc\(100%\s*-\s*32px\))(?=[^}]*transform:\s*translate\(-50%,\s*-50%\))[^}]*\}/s, - ); - expect(styles).not.toMatch( - /\.game-resource-focus\s*\{[^}]*(?:position:\s*fixed|right:)/s, - ); - expect(styles).not.toMatch( - /\.game-resource-focus-titlebar\s*\{[^}]*cursor:/s, - ); - expect(styles).not.toMatch(/\.game-resource-focus-backdrop/); - - fireEvent.click( - within(receiptFocus).getByRole('button', { name: '收起资源' }), - ); - expect(workbenchStage.getAttribute('data-resource-view-state')).toBe( - 'resources.list', - ); - expect( - (screen.getByLabelText('搜索项目资源') as HTMLInputElement).value, - ).toBe('美术资源计划'); - expect( - screen - .getByRole('button', { name: '按类型' }) - .getAttribute('aria-pressed'), - ).toBe('true'); - const restoredCanvas = screen.getByLabelText( - '资源类型视图', - ) as HTMLDivElement; - expect(restoredCanvas.scrollLeft).toBe(48); - expect(restoredCanvas.scrollTop).toBe(36); - const restoredCard = screen.getByRole('button', { - name: /打开资源详情:设计文档 美术资源计划 Agent 文本回执/, - }); - expect(restoredCard?.getAttribute('aria-pressed')).toBe('true'); - expect( - screen - .getByRole('button', { name: '按类型' }) - .getAttribute('aria-pressed'), - ).toBe('true'); - fireEvent.click(restoredCard); - expect( - screen.getByRole('dialog', { - name: '美术资源计划 Agent 文本回执', - }), - ).not.toBeNull(); - fireEvent.keyDown(window, { key: 'Escape' }); - expect( - screen.queryByRole('dialog', { - name: '美术资源计划 Agent 文本回执', - }), - ).toBeNull(); - expect( - screen - .getByRole('button', { name: '按类型' }) - .getAttribute('aria-pressed'), - ).toBe('true'); }); it('loads registered documents, art media, video, and audio with safe failure states inside central focus', async () => { @@ -2430,52 +2344,25 @@ export function registerProjectWorkbenchFoundationTests() { ); await openResourceBookCategory('设计文档'); - fireEvent.click(await findResourceDetailButton('design.md')); - const documentFocus = await screen.findByRole('dialog', { - name: 'design.md', - }); - expect( - within(documentFocus).getByRole('heading', { name: '本地设计文档' }), - ).not.toBeNull(); - expect(documentFocus.querySelector('script')).toBeNull(); - expect(documentFocus.querySelector('a')).toBeNull(); - expect(within(documentFocus).getByText('图片:远程图片')).not.toBeNull(); - expect(documentFocus.hasAttribute('aria-modal')).toBe(false); - await openResourceBookCategory('美术资源'); - fireEvent.click(getResourceDetailButton('icon.svg')); + fireEvent.click(await findResourceSelectButton('design.md')); + // 选中资源浮出的是画布工具条;"资源详情"面板已随 C3 交互合同删除。 + expect(screen.queryByRole('dialog', { name: 'design.md' })).toBeNull(); + await openResourceBookCategory('美术资源'); + fireEvent.click(getResourceSelectButton('icon.svg')); + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); expect(screen.queryByRole('dialog', { name: 'design.md' })).toBeNull(); - const artDetails = await screen.findByRole('dialog', { name: 'icon.svg' }); - expect(within(artDetails).getByText('assets/icon.svg')).not.toBeNull(); - expect(within(artDetails).getByText('image/svg+xml')).not.toBeNull(); - expect(within(artDetails).getByText('依赖层级')).not.toBeNull(); - expect( - within(artDetails).getByRole('img', { name: 'icon.svg 预览' }), - ).not.toBeNull(); - fireEvent.click( - within(artDetails).getByRole('button', { name: '收起资源' }), - ); fireEvent.click( screen.getByRole('button', { - name: '打开资源详情:美术资源 intro.mp4', + name: '选中资源:美术资源 intro.mp4', }), ); - const videoDetails = await screen.findByRole('dialog', { - name: 'intro.mp4', - }); - expect(within(videoDetails).getByText('assets/intro.mp4')).not.toBeNull(); - expect(within(videoDetails).getByText('video/mp4')).not.toBeNull(); - expect( - within(videoDetails).getByLabelText('intro.mp4 视频预览'), - ).not.toBeNull(); - fireEvent.click( - within(videoDetails).getByRole('button', { name: '收起资源' }), - ); + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); await openResourceBookCategory('音乐音效'); fireEvent.click( screen.getByRole('button', { - name: '打开资源详情:音乐音效 bgm.mp3', + name: '选中资源:音乐音效 bgm.mp3', }), ); expect( @@ -2485,29 +2372,16 @@ export function registerProjectWorkbenchFoundationTests() { args?.category === 'audio', ), ).toBe(false); - const audioDetails = screen.getByRole('dialog', { name: 'bgm.mp3' }); - fireEvent.click( - within(audioDetails).getByRole('button', { name: '播放 bgm.mp3' }), - ); - const audio = (await screen.findByLabelText( - 'bgm.mp3 音频播放器', - )) as HTMLAudioElement; - expect(audio.controls).toBe(true); - expect(audio.getAttribute('src')).toBe('blob:mock-attachment-preview'); - expect(screen.getByText('载入后显示')).not.toBeNull(); - expect(invoke).toHaveBeenCalledWith( - 'read_local_project_media_preview', - expect.objectContaining({ category: 'audio' }), - ); - fireEvent.click( - within(screen.getByRole('dialog', { name: 'bgm.mp3' })).getByRole( - 'button', - { name: '收起资源' }, - ), - ); + // 音频资源的选中工具条只保留美术画布同名动作;详情面板已删除。 + const audioToolbar = screen.getByRole('toolbar', { + name: '素材工具栏', + }); + expect( + within(audioToolbar).getByRole('button', { name: '改造' }), + ).not.toBeNull(); await openResourceBookCategory('设计文档'); - fireEvent.click(getResourceDetailButton('blocked.md')); + fireEvent.click(getResourceSelectButton('blocked.md')); expect((await screen.findByRole('alert')).textContent).toBe( '当前项目策略要求先确认读取文档,确认后请关闭详情并重试', ); @@ -2588,18 +2462,8 @@ export function registerProjectWorkbenchFoundationTests() { ); await openResourceBookCategory('音乐音效'); - fireEvent.click(await findResourceDetailButton('focus.mp3')); - const audioDetails = await screen.findByRole('dialog', { - name: 'focus.mp3', - }); - fireEvent.click( - within(audioDetails).getByRole('button', { name: '播放 focus.mp3' }), - ); - const audio = (await within(audioDetails).findByLabelText( - 'focus.mp3 音频播放器', - )) as HTMLAudioElement; - audio.focus(); - expect(document.activeElement).toBe(audio); + fireEvent.click(await findResourceSelectButton('focus.mp3')); + expect(screen.getByRole('toolbar', { name: '素材工具栏' })).not.toBeNull(); const updatedManifest = { ...manifest, @@ -2620,10 +2484,7 @@ export function registerProjectWorkbenchFoundationTests() { }), ); - expect(document.activeElement).toBe(audio); - expect(screen.getByRole('dialog', { name: 'focus.mp3' })).not.toBe( - document.activeElement, - ); + expect(screen.getByRole('toolbar', { name: '素材工具栏' })).not.toBeNull(); rendered.rerender( React.createElement(ProjectDevelopmentView, { @@ -2633,14 +2494,12 @@ export function registerProjectWorkbenchFoundationTests() { ); await waitFor(() => { - expect(screen.queryByRole('dialog', { name: 'focus.mp3' })).toBeNull(); - expect(document.activeElement).toBe( - screen.getByLabelText('搜索项目资源'), - ); + expect(screen.queryByRole('toolbar', { name: '素材工具栏' })).toBeNull(); + expect(screen.queryByRole('toolbar', { name: '图片工具栏' })).toBeNull(); }); expect( screen - .queryAllByTitle('打开资源详情') + .queryAllByTitle('选中资源') .some((card) => card.getAttribute('aria-pressed') === 'true'), ).toBe(false); }); @@ -2903,8 +2762,8 @@ export function registerProjectWorkbenchFoundationTests() { ); fireEvent.change(search, { target: { value: '' } }); - let sourceCard = getResourceDetailButton('spec-source.json'); - const targetCard = getResourceDetailButton('ui-dependency.json'); + let sourceCard = getResourceSelectButton('spec-source.json'); + const targetCard = getResourceSelectButton('ui-dependency.json'); const referenceSelector = '[data-edge-kind="asset-reference"]' + '[data-source-resource-id="asset:dependency-spec"]' + @@ -2972,14 +2831,9 @@ export function registerProjectWorkbenchFoundationTests() { fireEvent.click(sourceCardContainer!); fireEvent.click(targetCard.closest('.game-resource-card')!); - const targetFocus = screen.getByRole('dialog', { - name: /ui-dependency\.json/u, - }); - fireEvent.click( - within(targetFocus).getByRole('button', { name: '收起资源' }), - ); + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); overlay = await screen.findByTestId('resource-dependency-overlay-document'); - sourceCard = getResourceDetailButton('spec-source.json'); + sourceCard = getResourceSelectButton('spec-source.json'); await waitFor(() => expect(overlay.querySelector(referenceSelector)).not.toBeNull(), ); @@ -3393,7 +3247,7 @@ export function registerProjectWorkbenchFoundationTests() { expect(dependencyLayoutReads).toBe(0); expect( screen.queryByRole('button', { - name: '打开资源详情:设计文档 延迟依赖图回执', + name: '选中资源:设计文档 延迟依赖图回执', }), ).toBeNull(); @@ -3403,7 +3257,7 @@ export function registerProjectWorkbenchFoundationTests() { }); expect( await screen.findByRole('button', { - name: '打开资源详情:设计文档 延迟依赖图回执', + name: '选中资源:设计文档 延迟依赖图回执', }), ).not.toBeNull(); expect(dependencyLayoutReads).toBe(1); @@ -3476,7 +3330,7 @@ export function registerProjectWorkbenchFoundationTests() { const view = render(renderView([firstResult])); await openResourceBookCategory('设计文档'); const firstCardButton = await screen.findByRole('button', { - name: '打开资源详情:设计文档 稳定依赖图回执', + name: '选中资源:设计文档 稳定依赖图回执', }); const firstCard = firstCardButton.closest('.game-resource-card'); expect(firstCard).not.toBeNull(); @@ -3503,7 +3357,7 @@ export function registerProjectWorkbenchFoundationTests() { expect(firstCard?.isConnected).toBe(true); expect( screen.getByRole('button', { - name: '打开资源详情:设计文档 稳定依赖图回执', + name: '选中资源:设计文档 稳定依赖图回执', }), ).not.toBeNull(); @@ -3513,7 +3367,7 @@ export function registerProjectWorkbenchFoundationTests() { }); expect( await screen.findByRole('button', { - name: '打开资源详情:设计文档 刷新期间新增回执', + name: '选中资源:设计文档 刷新期间新增回执', }), ).not.toBeNull(); expect(firstCard?.isConnected).toBe(true); @@ -3725,7 +3579,7 @@ export function registerProjectWorkbenchFoundationTests() { await waitFor(() => expect(layoutReads).toBe(1)); await openResourceBookCategory('设计文档'); const cards = [0, 1, 2].map((depth) => - getResourceDetailButton(`truncated-depth-${depth}.json`).closest( + getResourceSelectButton(`truncated-depth-${depth}.json`).closest( '.game-resource-card', ), ); @@ -3819,7 +3673,7 @@ export function registerProjectWorkbenchFoundationTests() { renderWorkbench(); await openResourceBookCategory('设计文档'); const cardButton = await screen.findByRole('button', { - name: '打开资源详情:设计文档 布局持久化回执', + name: '选中资源:设计文档 布局持久化回执', }); const card = cardButton.closest('.game-resource-card'); expect(card).not.toBeNull(); @@ -3862,7 +3716,7 @@ export function registerProjectWorkbenchFoundationTests() { clientY: 60, }); fireEvent.click(card!); - expect(screen.queryByRole('dialog', { name: '布局持久化回执' })).toBeNull(); + expect(screen.queryByRole('toolbar', { name: '图片工具栏' })).toBeNull(); await waitFor(() => { expect(card?.getAttribute('style')).toContain( @@ -3888,16 +3742,14 @@ export function registerProjectWorkbenchFoundationTests() { ); fireEvent.click(card!); - expect( - screen.getByRole('dialog', { name: '布局持久化回执' }), - ).not.toBeNull(); + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); cleanup(); renderWorkbench(); await openResourceBookCategory('设计文档'); const restoredCard = ( await screen.findByRole('button', { - name: '打开资源详情:设计文档 布局持久化回执', + name: '选中资源:设计文档 布局持久化回执', }) ).closest('.game-resource-card'); await waitFor(() => { @@ -3955,7 +3807,7 @@ export function registerProjectWorkbenchFoundationTests() { ); await openResourceBookCategory('美术资源'); - const cardButton = await findResourceDetailButton('clickable.png'); + const cardButton = await findResourceSelectButton('clickable.png'); fireEvent.pointerDown(cardButton, { pointerId: 7, button: 0, @@ -3974,9 +3826,7 @@ export function registerProjectWorkbenchFoundationTests() { }); fireEvent.click(cardButton.closest('.game-resource-card')!); - expect( - screen.getByRole('dialog', { name: 'clickable.png' }), - ).not.toBeNull(); + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); expect( invoke.mock.calls.some( ([command, args]) => @@ -4040,7 +3890,7 @@ export function registerProjectWorkbenchFoundationTests() { ); await openResourceBookCategory('美术资源'); - const cardButton = await findResourceDetailButton('cancelable.png'); + const cardButton = await findResourceSelectButton('cancelable.png'); const card = cardButton.closest('.game-resource-card'); expect(card).not.toBeNull(); Object.defineProperties(card!, { @@ -4062,9 +3912,7 @@ export function registerProjectWorkbenchFoundationTests() { fireEvent.pointerCancel(card, { pointerId: 41 }); fireEvent.click(card!); - expect( - screen.getByRole('dialog', { name: 'cancelable.png' }), - ).not.toBeNull(); + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); }); it('maps manifest asset kinds into stable type layout ordering', async () => { @@ -4221,7 +4069,7 @@ export function registerProjectWorkbenchFoundationTests() { await openResourceBookCategory('设计文档'); expect( await screen.findByRole('button', { - name: '打开资源详情:设计文档 自动排版失败回执', + name: '选中资源:设计文档 自动排版失败回执', }), ).not.toBeNull(); }); @@ -4355,7 +4203,7 @@ export function registerProjectWorkbenchFoundationTests() { /
\{walletEntry\}<\/div>/, ); expect(projectDevelopmentSource).toMatch( - /const showRunUnavailableHint\s*=\s*!runAvailable\s*&&\s*!focusedResource\s*&&\s*!resourceEditorRoute\s*&&\s*!uiEditorRoute/s, + /const showRunUnavailableHint\s*=\s*!runAvailable\s*&&\s*selectedResourceIds\.length === 0\s*&&\s*!uiEditorRoute/s, ); expect(projectDevelopmentSource).toMatch( /aria-describedby=\{\s*showRunUnavailableHint\s*\?\s*'run-unavailable-hint'\s*:\s*undefined\s*\}/s, @@ -4523,13 +4371,9 @@ export function registerProjectWorkbenchFoundationTests() { fireEvent.change(screen.getByLabelText('搜索项目资源'), { target: { value: 'hero.png' }, }); - fireEvent.click(await findResourceDetailButton('hero.png')); - const heroDetails = screen.getByRole('dialog', { name: 'hero.png' }); - expect(within(heroDetails).getByText('assets/hero.png')).not.toBeNull(); - expect(within(heroDetails).getByText('image/png')).not.toBeNull(); - expect(within(heroDetails).getByText('引用上游')).not.toBeNull(); - expect(heroDetails.querySelector('img')).toBeNull(); - expect(screen.queryByRole('img', { name: 'hero.png 图片预览' })).toBeNull(); + fireEvent.click(await findResourceSelectButton('hero.png')); + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); + expect(screen.queryByRole('dialog', { name: 'hero.png' })).toBeNull(); }); it('marks an unvalidated UI prototype as a candidate image', async () => { @@ -4576,7 +4420,7 @@ export function registerProjectWorkbenchFoundationTests() { ); await openResourceBookCategory('美术资源'); - const candidate = await findResourceDetailButton( + const candidate = await findResourceSelectButton( 'ui-prototype.png(待视觉验收)', ); const candidateCard = candidate.closest('.game-resource-card'); diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx index 5293b7e64..e340032f2 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx @@ -13,11 +13,13 @@ import ProjectDevelopmentView from '../src/view/project-development'; import { act, createGameCreationAppManifest, - findResourceDetailButton, + findResourceSelectButton, fireEvent, render, screen, + setComposerText, waitFor, + within, } from './appSurface/harness'; const projectPath = '/tmp/live-canvas-integration'; @@ -473,39 +475,36 @@ describe('project resource live canvas integration', () => { }; } - it('derives a document non-destructively and retries with the original operation identity', async () => { + it('derives a new art asset non-destructively from the quick edit panel and reuses the original operation identity', async () => { const { deriveCalls } = installTauri({ failFirstDerive: true }); render(); - fireEvent.click(await findResourceDetailButton('rules.md')); - fireEvent.click(screen.getByRole('button', { name: '编辑资源' })); - expect(await screen.findByText('编辑现有资源')).not.toBeNull(); - fireEvent.change(screen.getByLabelText('编辑提示词'), { - target: { value: '把角色头发设定改为红色' }, + fireEvent.click(screen.getByRole('button', { name: '打开美术资源' })); + fireEvent.click(await findResourceSelectButton('source-art.png')); + const toolbar = await screen.findByRole('toolbar', { + name: '图片工具栏', }); - fireEvent.click(screen.getByRole('button', { name: '生成派生资源' })); + fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' })); + const panel = await screen.findByRole('dialog', { + name: '快速编辑图片', + }); + await setComposerText( + within(panel).getByLabelText('快速编辑提示词'), + '把角色头发设定改为红色', + ); + fireEvent.click(within(panel).getByRole('button', { name: '修改' })); expect(await screen.findByRole('alert')).not.toBeNull(); - fireEvent.click(screen.getByRole('button', { name: '使用原请求重试' })); + fireEvent.click(within(panel).getByRole('button', { name: '修改' })); await waitFor(() => expect(deriveCalls).toHaveLength(2)); expect(deriveCalls[0]?.operationId).toBe(deriveCalls[1]?.operationId); expect(deriveCalls[0]?.idempotencyKey).toBe(deriveCalls[1]?.idempotencyKey); + expect(deriveCalls[0]?.editKind).toBe('image-reference'); + expect(deriveCalls[0]?.generationMode).toBe('derive'); expect(deriveCalls[0]).not.toHaveProperty('accessToken'); expect(deriveCalls[0]).not.toHaveProperty('apiKey'); expect(deriveCalls[1]).not.toHaveProperty('accessToken'); expect(deriveCalls[1]).not.toHaveProperty('apiKey'); - const operationId = String(deriveCalls[1]?.operationId); - expect( - await screen.findByRole('dialog', { - name: new RegExp(operationId, 'u'), - }), - ).not.toBeNull(); - fireEvent.click(screen.getByRole('button', { name: '收起资源' })); - expect( - await screen.findByRole('button', { - name: '打开资源详情:设计文档 rules.md', - }), - ).not.toBeNull(); }); it('lists an unfinished edit and resumes it using only the private-ledger operation id', async () => { @@ -529,11 +528,14 @@ describe('project resource live canvas integration', () => { expect(resumeCalls[0]).not.toHaveProperty('prompt'); expect(resumeCalls[0]).not.toHaveProperty('endpoint'); expect(resumeCalls[0]).not.toHaveProperty('idempotencyKey'); + // 恢复完成后新素材被选中;不再有"资源详情"面板。 expect( - await screen.findByRole('dialog', { - name: /recovered-rules\.md/, - }), - ).not.toBeNull(); + ( + await screen.findByRole('button', { + name: /选中资源:\S+ \S*recovered-rules\.md$/, + }) + ).getAttribute('aria-pressed'), + ).toBe('true'); }); it('显式确认旧 Key-bound 服务后只用原 operation 继续恢复', async () => { @@ -683,8 +685,6 @@ describe('project resource live canvas integration', () => { expect(resumeCalls[0]?.operationId).toBe(resumableOperationId); expect(resumeCalls[0]?.operationId).not.toBe(reconciliationOperationId); - fireEvent.click(await screen.findByRole('button', { name: '收起资源' })); - fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (2)', diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasVersionBindingModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasVersionBindingModel.test.ts new file mode 100644 index 000000000..49c606858 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasVersionBindingModel.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from 'vitest'; + +import { + currentVersionResourceBindingIds, + formatIterationVersionLabel, + isResourceUsedByCurrentVersion, +} from '../src/features/resource-canvas/resourceCanvasVersionBindingModel'; + +const versions = [ + { + versionId: 'version-root', + parentVersionId: null, + projectRevision: 3, + resourceBindings: [ + { slotId: 'asset:asset-player', resourceId: 'asset-player' }, + ], + createdReason: 'initial' as const, + createdAt: 1_760_000_000_000, + }, + { + versionId: 'version-child', + parentVersionId: 'version-root', + projectRevision: 4, + resourceBindings: [ + { slotId: 'asset:asset-town', resourceId: 'asset-town' }, + // 悬空绑定的 slotId 不是恒等映射,不参与「当前使用」判定。 + { slotId: 'player', resourceId: 'asset-player' }, + ], + createdReason: 'agent-revision' as const, + createdAt: 1_760_003_600_000, + }, +]; + +describe('运行模块版本绑定判定', () => { + test('falls back to the newest manifest version when no version is selected', () => { + expect(Array.from(currentVersionResourceBindingIds({ versions }))).toEqual([ + 'asset-town', + ]); + }); + + test('resolves the binding ids of the explicitly selected version', () => { + expect( + Array.from( + currentVersionResourceBindingIds({ versions }, 'version-root'), + ), + ).toEqual(['asset-player']); + }); + + test('returns no bindings for a missing version or a manifest without versions', () => { + expect( + currentVersionResourceBindingIds({ versions }, 'version-missing').size, + ).toBe(0); + expect(currentVersionResourceBindingIds({}).size).toBe(0); + expect(currentVersionResourceBindingIds({ versions: [] }).size).toBe(0); + }); + + test('marks only registered assets bound by the current version as in use', () => { + const bindingIds = currentVersionResourceBindingIds( + { versions }, + 'version-root', + ); + + expect( + isResourceUsedByCurrentVersion( + { manifestAssetId: 'asset-player' }, + bindingIds, + ), + ).toBe(true); + expect( + isResourceUsedByCurrentVersion( + { manifestAssetId: 'asset-town' }, + bindingIds, + ), + ).toBe(false); + // 未登记为 manifest 素材的资源永远不算「当前使用」。 + expect( + isResourceUsedByCurrentVersion({ manifestAssetId: null }, bindingIds), + ).toBe(false); + }); + + test('labels a version with its Chinese created reason and creation time', () => { + expect(formatIterationVersionLabel(versions[0])).toBe( + `初始版本 · ${new Date(1_760_000_000_000).toLocaleString('zh-CN')}`, + ); + expect(formatIterationVersionLabel(versions[1])).toBe( + `智能体修订 · ${new Date(1_760_003_600_000).toLocaleString('zh-CN')}`, + ); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceRename.test.tsx b/apps/ai-game-creator-shell/tests/resourceRename.test.tsx index 09e00b924..ecf4f5ea0 100644 --- a/apps/ai-game-creator-shell/tests/resourceRename.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceRename.test.tsx @@ -5,9 +5,7 @@ import type { GameCreationAppManifest } from '../../../packages/shared/src/contr import { cleanup, createGameCreationAppManifest, - findResourceDetailButton, fireEvent, - getResourceDetailButton, ProjectDevelopmentView, React, render, @@ -94,7 +92,13 @@ async function openHeroCard(name = 'hero.png') { ), ).not.toBeNull(); }); - return findResourceDetailButton(name); + // C3 后点卡是「选中 + 浮出工具条」,重命名入口在工具条上。 + fireEvent.click( + await screen.findByRole('button', { + name: new RegExp(`^选中资源:美术资源 ${name}$`), + }), + ); + return screen.findByRole('toolbar', { name: '图片工具栏' }); } function renderWorkbench( @@ -167,9 +171,8 @@ describe('素材重命名前端链路', () => { createManifestWithHero('assets/hero.png'), onManifestChange, ); - fireEvent.click(await openHeroCard()); - - fireEvent.click(await screen.findByRole('button', { name: '重命名' })); + const toolbar = await openHeroCard(); + fireEvent.click(within(toolbar).getByRole('button', { name: '重命名' })); const field = (await screen.findByLabelText( '新文件名', )) as HTMLInputElement; @@ -200,7 +203,11 @@ describe('素材重命名前端链路', () => { rendered.rerenderWith(renamedManifest); await waitFor(() => { - expect(getResourceDetailButton('hero-v2.png')).not.toBeNull(); + expect( + screen.getByRole('button', { + name: '选中资源:美术资源 hero-v2.png', + }), + ).not.toBeNull(); }); }); @@ -214,9 +221,8 @@ describe('素材重命名前端链路', () => { }); renderWorkbench(createManifestWithHero('assets/hero.png')); - fireEvent.click(await openHeroCard()); - - fireEvent.click(await screen.findByRole('button', { name: '重命名' })); + const toolbar = await openHeroCard(); + fireEvent.click(within(toolbar).getByRole('button', { name: '重命名' })); const field = (await screen.findByLabelText( '新文件名', )) as HTMLInputElement; diff --git a/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx b/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx new file mode 100644 index 000000000..eb7a98c86 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx @@ -0,0 +1,243 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import { + cleanup, + createGameCreationAppManifest, + createGameCreationAppSeedTasks, + fireEvent, + ProjectDevelopmentView, + React, + render, + screen, + waitFor, + within, +} from './appSurface/harness'; + +const PROJECT_PATH = '/tmp/workbench-version-switch'; + +function installResourceCardIntersectionObserver() { + class ResourceCardIntersectionObserver { + readonly root = null; + readonly rootMargin = '160px'; + readonly thresholds = [0]; + readonly observed = new Set(); + + constructor(readonly callback: IntersectionObserverCallback) {} + + observe(element: Element) { + this.observed.add(element); + } + + unobserve(element: Element) { + this.observed.delete(element); + } + + disconnect() { + this.observed.clear(); + } + + takeRecords() { + return []; + } + } + + Object.defineProperty(window, 'IntersectionObserver', { + configurable: true, + value: ResourceCardIntersectionObserver, + }); +} + +function createVersionedManifest(): GameCreationAppManifest { + const manifest = createGameCreationAppManifest( + 'workbench-version-switch', + '版本切换测试', + ); + // 运行模块的可用性与版本入口无关,这里用一条已完成原型任务把它打开。 + manifest.tasks = createGameCreationAppSeedTasks().map((task) => + task.id === 'code-prototype' + ? { ...task, status: 'completed' as const } + : task, + ); + manifest.assets = [ + { + id: 'asset-player', + kind: 'character', + mediaType: 'image/png', + localPath: 'assets/player.png', + source: { kind: 'generated' }, + }, + { + id: 'asset-town', + kind: 'scene', + mediaType: 'image/png', + localPath: 'assets/town.png', + source: { kind: 'generated' }, + }, + ]; + manifest.versions = [ + { + versionId: 'version-root', + parentVersionId: null, + projectRevision: 3, + resourceBindings: [ + { slotId: 'asset:asset-player', resourceId: 'asset-player' }, + ], + createdReason: 'initial', + createdAt: 1_760_000_000_000, + }, + { + versionId: 'version-child', + parentVersionId: 'version-root', + projectRevision: 4, + resourceBindings: [ + { slotId: 'asset:asset-town', resourceId: 'asset-town' }, + ], + createdReason: 'agent-revision', + createdAt: 1_760_003_600_000, + }, + ]; + return manifest; +} + +function renderWorkbench( + manifest: GameCreationAppManifest, + props: { + activeVersionId?: string | null; + onActiveVersionChange?: (versionId: string) => void; + } = {}, +) { + const { rerender } = render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: PROJECT_PATH, + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + onPlay: vi.fn(), + ...props, + }), + ); + return { + rerenderWith(next: Partial>) { + rerender( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: PROJECT_PATH, + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + onPlay: vi.fn(), + ...props, + ...next, + }), + ); + }, + }; +} + +async function openArtCategory() { + fireEvent.click(screen.getByRole('button', { name: '按类型' })); + const outline = await screen.findByLabelText('资源栏目大纲'); + fireEvent.click(within(outline).getByRole('button', { name: /美术资源/ })); + await waitFor(() => { + expect( + document.querySelector('[data-resource-book-view="child"]'), + ).not.toBeNull(); + }); +} + +function cardFor(label: string) { + return screen + .getByRole('button', { name: `选中资源:美术资源 ${label}` }) + .closest('.game-resource-card'); +} + +afterEach(() => { + cleanup(); +}); + +describe('C7 运行模块版本切换', () => { + test('keeps the version entry hidden without versions and shows the latest one by default', async () => { + installResourceCardIntersectionObserver(); + const manifest = createVersionedManifest(); + manifest.versions = []; + const withoutVersions = renderWorkbench(manifest); + fireEvent.click(screen.getByRole('tab', { name: '运行' })); + expect(screen.queryByLabelText(/^当前版本:/)).toBeNull(); + + withoutVersions.rerenderWith({ manifest: createVersionedManifest() }); + const trigger = await screen.findByLabelText(/^当前版本:/); + expect(trigger.getAttribute('aria-label')).toContain('智能体修订'); + }); + + test('switches the current version through the entry and reports it to the host', async () => { + installResourceCardIntersectionObserver(); + const manifest = createVersionedManifest(); + const onActiveVersionChange = vi.fn(); + renderWorkbench(manifest, { onActiveVersionChange }); + + fireEvent.click(screen.getByRole('tab', { name: '运行' })); + fireEvent.click(await screen.findByLabelText(/^当前版本:/)); + + const menu = await screen.findByRole('listbox', { + name: '切换游戏版本', + }); + expect(within(menu).getAllByRole('option')).toHaveLength(2); + // 当前版本(最新的那个)已在菜单里标记为选中。 + expect( + within(menu) + .getByRole('option', { name: /智能体修订/ }) + .getAttribute('aria-selected'), + ).toBe('true'); + + fireEvent.click(within(menu).getByRole('option', { name: /初始版本/ })); + expect(onActiveVersionChange).toHaveBeenCalledWith('version-root'); + }); + + test('drives the "current use" card highlight from the active version', async () => { + installResourceCardIntersectionObserver(); + const manifest = createVersionedManifest(); + const rendered = renderWorkbench(manifest); + await openArtCategory(); + + // 默认当前版本是 manifest 里最新的那个:只有它绑定的资源算「当前使用」。 + expect(cardFor('town.png')?.classList.contains('is-current-version')).toBe( + true, + ); + expect( + cardFor('player.png')?.classList.contains('is-current-version'), + ).toBe(false); + + rendered.rerenderWith({ activeVersionId: 'version-root' }); + await waitFor(() => { + expect( + cardFor('player.png')?.classList.contains('is-current-version'), + ).toBe(true); + }); + expect(cardFor('town.png')?.classList.contains('is-current-version')).toBe( + false, + ); + + // 选中的版本已经不存在时按空态处理(与 `@` 面板同一口径),不残留旧高亮; + // 工作台壳会在版本消失时把选择清回「最新版本」。 + rendered.rerenderWith({ activeVersionId: 'version-removed' }); + await waitFor(() => { + expect( + cardFor('town.png')?.classList.contains('is-current-version'), + ).toBe(false); + }); + expect( + cardFor('player.png')?.classList.contains('is-current-version'), + ).toBe(false); + }); +}); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 20daf3451..26c3a7cdc 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5059,3 +5059,12 @@ - 原因:`image-canvas-editor__*` 的规则(约 918 条)全部在网页端全局样式表 `src/index.css` 里;AGC 只引入了 `@genarrative/image-canvas-react/styles.css`(178 行,只有 `genarrative-image-canvas__*` 视口样式)和 `apps/ai-game-creator-shell/src/styles.css`。 - 处理:口径是“复用组件 + AGC 宿主给样式”——在 `apps/ai-game-creator-shell/src/styles.css` 里为实际用到的 class 补宿主 chrome 样式(浮出定位、层级、按钮外观)。**不要**让 AGC import 网页端 `src/index.css`(整站全局表会连带引入无关 reset 与主题),也不要在同一次改动里做数百条规则的抽取重构。 - 关联:`src/index.css`、`packages/image-canvas-react/src/styles.css`、`apps/ai-game-creator-shell/src/styles.css`。 + +## AGC 复用网页端 `src/components/image-editor/` 组件会拖进网页端服务层(2026-09-10) + +- 现象:AGC 侧刚把 `ImageCanvasSelectedLayerToolbarView` 引进来,`npm run ai-game-creator-shell:typecheck` 立刻报十几条**不在自己代码里**的类型错误,全部指向主仓 `src/services/host-bridge/hostBridge.ts`(`Property 'wx' / 'ReactNativeWebView' does not exist on type 'Window'`)。 +- 原因:跨端组件有一条**值级**依赖链:`ImageCanvasSelectedLayerToolbarView` → `ImageCanvasGenerationModel`(`error instanceof ApiClientError` 是值导入,不能退化成 type-only)→ `src/services/apiClient.ts` → `hostBridge.ts`。`hostBridge.ts` 读取的 `wx` / `ReactNativeWebView` / `WeixinJSBridge` 只在主仓 `src/vite-env.d.ts` 里声明过,而 AGC 的 `apps/ai-game-creator-shell/tsconfig.json` 是 `strict: true`,主仓不是。 +- 处理:在 `apps/ai-game-creator-shell/src/vite-env.d.ts` 的 `interface Window` 上补**与主仓同名同形**的字段(纯类型声明、零运行时影响)。**不要**为了让它过而关闭 AGC 的 `noImplicitAny`(等于为借一个组件把整个 App 的类型门禁降级),**也不要**因此在 AGC 里另写一套平行组件。 +- 运行时结论(已核实,可放心):`hostBridge.ts` 的模块作用域只有常量与缓存声明,**没有导入即执行的副作用**;`window.wx` 只在函数体内访问,`ReactNativeWebView` 有 `typeof window !== 'undefined'` 守卫。所以这条链被编进 AGC bundle 是惰性的。 +- 通用规则:**今后任何往 AGC 引 `src/components/image-editor/` 组件的改动,都要先检查这条链**(组件 → `ImageCanvasGenerationModel` → `apiClient` → `hostBridge`),并确认新增的跨端全局在 AGC 侧有声明。 +- 关联:`apps/ai-game-creator-shell/src/vite-env.d.ts`、`src/vite-env.d.ts`、`src/components/image-editor/ImageCanvasGenerationModel.ts`、`src/services/apiClient.ts`、`src/services/host-bridge/hostBridge.ts`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 28d0e9959..eb3b27d36 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1301,3 +1301,12 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过 - 回收判据与删除必须基于同一次读到的锁文件快照:payload 只解析一次,删除前重新核对字节,只有内容仍是判定时的内容才 unlink;文件已消失或被替换时重试 `create_new`,不把并发回收当成错误。 - 启动诊断日志改为 `StartupLogSlot`:优先用已经生效的配置目录(含 `--config-dir`),否则退到平台配置根(Windows APPDATA、macOS Application Support、其它平台 `XDG_CONFIG_HOME` / `~/.config`),成功后再切换到真实配置目录。`startup.*.failed` 与 `show_startup_error_dialog` 不再是死分支;日志路径未知时同样给出用户可见提示。Windows 启动失败恢复系统消息框并附诊断日志路径,其它平台写 stderr,同一进程只提示一次。 - 边界与验证:残留的 `agent-runner.lock` / `agent-runner.gui-owner.lock` 是 OS 独占句柄锁,进程退出即释放,文件本身不阻塞下次启动;真正阻塞启动的是仍有活进程持锁。验证覆盖 `project_lock_recovery` 11 条(死 PID、非法进程号、空锁宽限、PID 复用时间推断、PID 复用身份不一致、身份一致不抢锁、旧格式活持有者不抢锁、新鲜空锁不抢锁、存活未知保守回收、mtime 未知保守回收、并发替换或已消失时不删除)、`diagnostic_log` 7 条,以及真实二进制双实例:第二个实例写入 `startup.runner.owner-lock.failed` 并弹出可见提示。 + +## 2026-09-10 AGC 资源工作台 V3:资源记录口径收敛、删除新口径、引用页签与 AI 润色 + +- **版本绑定恒等化**:游戏版本“使用了哪些资源”的权威记录就是 `.agent/manifest.json` 的 `versions[].resourceBindings`;`slotId` 恒为 `asset:{assetId}`,`resourceId` 指向 manifest 资产 ID。运行期资源观察方案(代码注入 / Phaser wrapper / Vite 虚拟模块 / 脚手架哈希校验 / `.agent` 使用侧车)整体撤回并按四不写删除:`project/asset_usage.rs` 与其 16 条测试、`GAME_ITERATION_IDENTITY_BINDING_PREFIX`、`asset_is_bound_to_runtime_slot`、`remove_asset_usage_references` 全部移除,`game_iteration_resource_bindings` 退回纯恒等映射。 +- **素材删除新口径**:`delete_local_project_asset` 新增必填 `deleteReferencedVersions`,三分支语义(无引用直接删 / 有引用且 false 只删素材并保留悬空绑定 / true 连带删除引用版本)在**同一次 manifest 写入**内原子完成;新增只读命令 `read_local_project_asset_references` 供前端在确认弹窗里列出“被哪些版本使用”。版本数组“只允许追加”的唯一例外由 `mutate_manifest_at_allowing_version_removals` 承担——放行集合按**写入前的 manifest** 求值,其余既有版本仍须原样原序保留、新增只能追加,被放行版本不得出现在追加段(防止“删除后重排”绕过);其它写入路径继续传空集。 +- **Agent 资源检索投影**:`agc_list_registered_assets` 的投影在 `mediaType` 之后固定新增 `category` 与 `tags`,取值直接透出 manifest 已解析的分类(**不在投影层重新按 kind 派生**,否则会覆盖用户显式分类);不改 manifest 结构、不改对外 OpenAPI 或 SpacetimeDB schema。 +- **素材重命名**:新增 `rename_local_project_asset`(`project/asset_rename.rs`)。语义是**磁盘文件改名 + manifest `localPath` 更新、资产 `id` 不变**(manifest 没有 `name` 字段,显示名来自 `fileName(localPath)`)。校验覆盖新名非空、不含路径分隔符与 `..`、不跨目录、扩展名必须一致、目标不得已存在、原文件必须是真实普通文件;持项目写锁按“读 manifest → 磁盘改名 → 更新 `localPath` → 写 manifest → 推进 revision”执行,**写失败把文件改回原名**,回滚也失败时两个错误都报出并标记 `reconciliation-required`;同名重命名是空操作。已知边界:**不改游戏源码里对旧 `assets/` 的引用**,不做引用扫描与自动替换。 +- **聊天引用与润色**:`ResourceReferenceInput` 新增 `activeVersionId?: string | null` 与 `versions?: GameIterationVersion[]` 两个可选 prop;`@` 面板两个页签(当前版本素材 / 全部画布素材)各自持有独立 `{query, filter, selectedResourceIds}`。版本解析口径 `resolveActiveIterationVersion` / `currentIterationVersionAssets`:传 `null` 回退 manifest 最新的版本,悬空绑定自然丢弃,空版本或空绑定显示空态而不报错。新增 `polish_local_project_prompt(prompt, context)`(提示词 `src-tauri/prompts/local-project-prompt-polish.md`),复用与 `suggest_automatic_project_name` 同一条短文本通道:`codex_app_server` 模式走 home direct codex,其余走 `LlmClient` 单轮请求;入参有界(4 000 / 1 000 字符)、输出 2 048 token 有界、空回复一律判失败以保留原文。**计费不自建**:泥点扣费仍在 `server-rs` 的 `/api/llm/chat/completions` 与 `/api/llm/responses` 路由(`prepare_llm_router_billing` / `settle_llm_router_usage`),本次 server-rs 零改动。发送前提醒是 portal 独立弹窗,判据为“提醒未关闭 + 本轮未确认 + 纯文本 trim 后 ≥ 40 字符 + 不以 `/` 开头”,“不再提醒”偏好存本机 `localStorage`,不进 manifest 与后端。 +- **验证**:AGC 前端全量 1075 passed / 4 skipped / 0 failed;共享美术画布组件 1372 passed;`cargo check --all-targets` 通过;定向 Rust 用例 `asset_delete` 7、`manifest::` 30、`assets::tests` 22、`asset_rename` 9、`local_project_prompt_polish` 4、`bridge_registered_resource` / `bridge_art_resource` 3 全绿;`npm run check:encoding` 与 `git diff --check` 干净。 diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx index cd2c772ef..bdb924146 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx @@ -10,7 +10,7 @@ import { Sparkles, WandSparkles, } from 'lucide-react'; -import type { CSSProperties } from 'react'; +import type { CSSProperties, ReactNode } from 'react'; import { EditorIconButton } from './ImageCanvasEditorPrimitives'; import type { CanvasLayer } from './ImageCanvasEditorTypes'; @@ -19,7 +19,28 @@ import { isQuickEditSupportedLayer, } from './ImageCanvasGenerationModel'; +/** 选中工具条上的动作标识,供宿主声明自己实际可用的动作集合。 */ +export type ImageCanvasSelectedToolbarAction = + | 'quick-edit' + | 'redraw' + | 'crop-expand' + | 'remove-background' + | 'perfect-pixel' + | 'split-icon-spritesheet' + | 'extract-ui-design' + | 'character-animation' + | 'download'; + type ImageCanvasSelectedLayerToolbarViewProps = { + /** + * 宿主显式声明的可用动作集合。 + * + * 传 `null`(默认)时完全沿用美术画布内置判定;传入集合时以集合为准, + * 让没有对应后端链路的画布宿主不必把「点了没反应」的按钮渲染出来。 + */ + supportedActions?: ReadonlySet | null; + /** 宿主在工具条上追加的动作节点,渲染在下载按钮之前。 */ + extraActions?: ReactNode; selectedLayer: CanvasLayer | null; selectedToolbarStyle: CSSProperties | null; onOpenQuickEditPanel: (layer: CanvasLayer) => void; @@ -38,6 +59,8 @@ type ImageCanvasSelectedLayerToolbarViewProps = { }; export function ImageCanvasSelectedLayerToolbarView({ + supportedActions = null, + extraActions, selectedLayer, selectedToolbarStyle, onOpenQuickEditPanel, @@ -57,7 +80,20 @@ export function ImageCanvasSelectedLayerToolbarView({ if (!selectedLayer || !selectedToolbarStyle) { return null; } - const canRedraw = canOpenRedrawPanel(selectedLayer); + const isActionSupported = ( + action: ImageCanvasSelectedToolbarAction, + builtInAvailability: boolean, + ) => (supportedActions ? supportedActions.has(action) : builtInAvailability); + const canRedraw = isActionSupported( + 'redraw', + canOpenRedrawPanel(selectedLayer), + ); + const extraActionDivider = extraActions ? ( +
); } @@ -93,6 +133,14 @@ export function ImageCanvasSelectedLayerToolbarView({ selectedLayer.mediaType !== 'video' && selectedLayer.mediaType !== 'image-sequence' && selectedLayer.assetKind !== 'character-animation'; + const showQuickEdit = isActionSupported( + 'quick-edit', + isQuickEditSupportedLayer(selectedLayer), + ); + const showCharacterAnimation = isActionSupported( + 'character-animation', + selectedLayer.assetKind === 'character', + ); return (
event.stopPropagation()} > - {isQuickEditSupportedLayer(selectedLayer) ? ( + {showQuickEdit ? ( <> ) : null} - {canRasterEdit ? ( - <> - onOpenCropExpandPanel(selectedLayer)} - /> - onRemoveBackground(selectedLayer)} - /> - - ) : ( - - ) - } - // 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和 - // sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端 - // resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。 - // 与相邻的拆分图集按钮保持同一套门禁。 - disabled={ - isPersistingAssetKind || - isPerfectPixelProcessing || - isPerfectPixelPendingConfirmation - } - aria-busy={isPersistingAssetKind || isPerfectPixelProcessing} - onClick={() => onPerfectPixel(selectedLayer)} - > - - {isPersistingAssetKind - ? '保存中' - : isPerfectPixelProcessing - ? '处理中' - : isPerfectPixelPendingConfirmation - ? '待确认' - : '完美像素'} - - - + {canRasterEdit && isActionSupported('crop-expand', true) ? ( + onOpenCropExpandPanel(selectedLayer)} + /> ) : null} - {selectedLayer.assetKind === 'icon-spritesheet' ? ( + {canRasterEdit && isActionSupported('remove-background', true) ? ( + onRemoveBackground(selectedLayer)} + /> + ) : null} + {canRasterEdit && isActionSupported('perfect-pixel', true) ? ( + + ) : ( + + ) + } + // 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和 + // sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端 + // resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。 + // 与相邻的拆分图集按钮保持同一套门禁。 + disabled={ + isPersistingAssetKind || + isPerfectPixelProcessing || + isPerfectPixelPendingConfirmation + } + aria-busy={isPersistingAssetKind || isPerfectPixelProcessing} + onClick={() => onPerfectPixel(selectedLayer)} + > + + {isPersistingAssetKind + ? '保存中' + : isPerfectPixelProcessing + ? '处理中' + : isPerfectPixelPendingConfirmation + ? '待确认' + : '完美像素'} + + + ) : null} + {selectedLayer.assetKind === 'icon-spritesheet' && + isActionSupported('split-icon-spritesheet', true) ? ( ) : null} - {selectedLayer.assetKind === 'ui-design' ? ( + {selectedLayer.assetKind === 'ui-design' && + isActionSupported('extract-ui-design', true) ? ( 提取素材 ) : null} - {selectedLayer.assetKind === 'character' ? ( + {showCharacterAnimation ? ( ) : null} - onDownloadLayer(selectedLayer)} - /> + {extraActionDivider} + {extraActions} + {isActionSupported('download', true) ? ( + onDownloadLayer(selectedLayer)} + /> + ) : null}
); } diff --git a/src/services/host-bridge/hostBridge.ts b/src/services/host-bridge/hostBridge.ts index a7f772a73..765e23c97 100644 --- a/src/services/host-bridge/hostBridge.ts +++ b/src/services/host-bridge/hostBridge.ts @@ -493,7 +493,7 @@ export async function navigateWechatMiniProgramPage( success() { resolve(); }, - fail(_error) { + fail(_error: unknown) { console.error('[host-bridge] wechat mini program navigation failed'); reject(new Error(errorMessage)); },