合并主干 C3 交互改造并接入 C7 版本切换

- 合并 feat/agc-canvas-resource-workbench-v3:资源卡改为选中+浮出工具条、支持多选、删除资源详情面板与 resourceEditorRoute

- index.tsx 冲突以 C3 新结构为底:保留本包的素材重命名回调,丢弃被 C3 取消的 focusedResource 详情面板派生状态与浮层 JSX

- 重命名入口从已删除的详情面板搬到选中工具条 extraActions,与「分类与标签」并列

- check-config.mjs 的 allowlist 保持主干版本(normalize_local_project_raster_resource 一行已删)

- 还原 normalize_local_project_raster_resource Tauri 命令与注册:C3 已在前端接回调用方

- C7:新增 GameRunVersionPicker 运行模块右上角版本入口,无版本不渲染

- C7:版本状态收敛到 WorkspaceLauncherShell 一份,透传给资源画布与 @ 面板;换项目或版本消失时清回最新版本

- C7:currentVersionResourceBindingIds 接入 activeVersionId,复用 resolveActiveIterationVersion 口径,资源卡「当前使用」高亮随版本切换

- C7:切换版本时复用既有 onPlay 预览入口重载画面,不新增运行时资源重映射

- 新增 10 条测试覆盖版本判定口径、版本入口与切换、当前使用高亮、素材重命名链路
This commit is contained in:
agent
2026-09-10 15:46:08 +08:00
25 changed files with 2078 additions and 1339 deletions
@@ -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<NormalizeLocalProjectRasterResourceResult, String> {
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,
@@ -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,
+6 -3
View File
@@ -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)
@@ -84,6 +84,11 @@ export function WorkspaceLauncherShell({
const activeProjectContextRef = useRef(currentProjectContext);
const manifestMergeRef = useRef<ProjectManifestMergeState | null>(null);
activeProjectContextRef.current = currentProjectContext;
/**
* C7 当前游戏版本:唯一一份版本状态,同时喂给资源画布(「当前使用」高亮)与
* `@` 面板(「当前版本素材」页签)。`null` 表示回退到 manifest 中最新的版本。
*/
const [activeVersionId, setActiveVersionId] = useState<string | null>(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={
@@ -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;
@@ -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<HTMLDivElement>(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 (
<div ref={rootRef} className="game-run-version-picker">
<button
type="button"
className="game-run-version-trigger"
aria-haspopup="listbox"
aria-expanded={open}
aria-label={`当前版本:${formatIterationVersionLabel(currentVersion)}`}
onClick={() => setOpen((current) => !current)}
>
{formatIterationVersionLabel(currentVersion)}
</button>
{open
? createPortal(
<div
className="game-run-version-menu"
role="listbox"
aria-label="切换游戏版本"
style={{
position: 'fixed',
// 用视口坐标而不是相对定位:运行画面会缩放,挂在 body 上才不跟着画布跑偏。
top: rootRef.current
? `${rootRef.current.getBoundingClientRect().bottom + 6}px`
: '0',
right: rootRef.current
? `${Math.max(
8,
window.innerWidth -
rootRef.current.getBoundingClientRect().right,
)}px`
: '0',
}}
>
{versions.map((version) => {
const selected = version.versionId === currentVersion.versionId;
return (
<button
key={version.versionId}
type="button"
role="option"
aria-selected={selected}
aria-label={`切换到${formatIterationVersionLabel(version)}`}
className={selected ? 'is-selected' : undefined}
onClick={() => {
setOpen(false);
if (selected) return;
onSelectVersion(version.versionId);
}}
>
<span>{formatIterationVersionLabel(version)}</span>
<small>{version.versionId}</small>
</button>
);
})}
</div>,
document.body,
)
: null}
</div>
);
}
@@ -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;
}
}
@@ -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, {});
}
@@ -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<string>([
'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<ProjectResource, 'mediaType' | 'category' | 'path'>,
): 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<ProjectResource, 'subtype'>,
): CanvasAssetKind | null {
return CANVAS_ASSET_KINDS.has(resource.subtype)
? (resource.subtype as CanvasAssetKind)
: null;
}
export function isResourceRasterImage(
resource: Pick<ProjectResource, 'mediaType' | 'category' | 'path'>,
) {
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<ImageCanvasSelectedToolbarAction> {
const supported = new Set<ImageCanvasSelectedToolbarAction>();
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),
};
}
@@ -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<GameIterationVersion, 'createdReason' | 'createdAt'>,
) {
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<GameCreationAppManifest, 'versions'>,
activeVersionId: string | null = null,
): Set<string> {
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<ProjectResource, 'manifestAssetId'>,
currentVersionBindingIds: ReadonlySet<string>,
) {
return (
resource.manifestAssetId !== null &&
currentVersionBindingIds.has(resource.manifestAssetId)
);
}
+1
View File
@@ -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';
+88 -3
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+19
View File
@@ -15,4 +15,23 @@ interface Window {
) => 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;
}
@@ -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,
@@ -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 () => {
File diff suppressed because it is too large Load Diff
@@ -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(<DerivedWorkbench />);
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)',
@@ -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')}`,
);
});
});
@@ -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;
@@ -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<Element>();
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<Record<string, unknown>>) {
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);
});
});
@@ -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`
@@ -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/<name>` 的引用**,不做引用扫描与自动替换。
- **聊天引用与润色**`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` 干净。
@@ -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<ImageCanvasSelectedToolbarAction> | 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 ? (
<span
aria-hidden="true"
className="image-canvas-editor__floating-toolbar-divider"
/>
) : null;
if (selectedLayer.mediaType === 'audio') {
return (
@@ -79,12 +115,16 @@ export function ImageCanvasSelectedLayerToolbarView({
<span></span>
</CanvasChromeButton>
) : null}
<EditorIconButton
label="下载按钮"
title="下载按钮"
icon={Download}
onClick={() => onDownloadLayer(selectedLayer)}
/>
{extraActionDivider}
{extraActions}
{isActionSupported('download', true) ? (
<EditorIconButton
label="下载按钮"
title="下载按钮"
icon={Download}
onClick={() => onDownloadLayer(selectedLayer)}
/>
) : null}
</div>
);
}
@@ -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 (
<div
@@ -102,7 +150,7 @@ export function ImageCanvasSelectedLayerToolbarView({
aria-label="图片工具栏"
onPointerDown={(event) => event.stopPropagation()}
>
{isQuickEditSupportedLayer(selectedLayer) ? (
{showQuickEdit ? (
<>
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
@@ -119,74 +167,77 @@ export function ImageCanvasSelectedLayerToolbarView({
/>
</>
) : null}
{canRasterEdit ? (
<>
<EditorIconButton
label="裁扩按钮"
title="裁扩按钮"
icon={Crop}
onClick={() => onOpenCropExpandPanel(selectedLayer)}
/>
<EditorIconButton
label="去除背景按钮"
title="去除背景按钮"
icon={ImageOff}
onClick={() => onRemoveBackground(selectedLayer)}
/>
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
// 中文注释:保存态名称不能直接用「素材类型保存中」——icon-spritesheet 图层上
// 拆分图集按钮同时显示该文案,两个控件会撞同一个无障碍名称。
label={
isPersistingAssetKind
? '完美像素等待素材类型保存'
: isPerfectPixelProcessing
? '完美像素处理中'
: isPerfectPixelPendingConfirmation
? '完美像素结果待确认'
: '完美像素'
}
title={
isPersistingAssetKind
? '完美像素等待素材类型保存'
: isPerfectPixelProcessing
? '完美像素处理中'
: isPerfectPixelPendingConfirmation
? '结果待确认,双击原占位继续核对或重试'
: '自动识别并规整像素网格'
}
icon={
isPersistingAssetKind || isPerfectPixelProcessing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Grid2X2 className="h-4 w-4" />
)
}
// 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和
// sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端
// resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。
// 与相邻的拆分图集按钮保持同一套门禁。
disabled={
isPersistingAssetKind ||
isPerfectPixelProcessing ||
isPerfectPixelPendingConfirmation
}
aria-busy={isPersistingAssetKind || isPerfectPixelProcessing}
onClick={() => onPerfectPixel(selectedLayer)}
>
<span>
{isPersistingAssetKind
? '保存中'
: isPerfectPixelProcessing
? '处理中'
: isPerfectPixelPendingConfirmation
? '待确认'
: '完美像素'}
</span>
</CanvasChromeButton>
</>
{canRasterEdit && isActionSupported('crop-expand', true) ? (
<EditorIconButton
label="裁扩按钮"
title="裁扩按钮"
icon={Crop}
onClick={() => onOpenCropExpandPanel(selectedLayer)}
/>
) : null}
{selectedLayer.assetKind === 'icon-spritesheet' ? (
{canRasterEdit && isActionSupported('remove-background', true) ? (
<EditorIconButton
label="去除背景按钮"
title="去除背景按钮"
icon={ImageOff}
onClick={() => onRemoveBackground(selectedLayer)}
/>
) : null}
{canRasterEdit && isActionSupported('perfect-pixel', true) ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
// 中文注释:保存态名称不能直接用「素材类型保存中」——icon-spritesheet 图层上
// 拆分图集按钮同时显示该文案,两个控件会撞同一个无障碍名称。
label={
isPersistingAssetKind
? '完美像素等待素材类型保存'
: isPerfectPixelProcessing
? '完美像素处理中'
: isPerfectPixelPendingConfirmation
? '完美像素结果待确认'
: '完美像素'
}
title={
isPersistingAssetKind
? '完美像素等待素材类型保存'
: isPerfectPixelProcessing
? '完美像素处理中'
: isPerfectPixelPendingConfirmation
? '结果待确认,双击原占位继续核对或重试'
: '自动识别并规整像素网格'
}
icon={
isPersistingAssetKind || isPerfectPixelProcessing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Grid2X2 className="h-4 w-4" />
)
}
// 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和
// sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端
// resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。
// 与相邻的拆分图集按钮保持同一套门禁。
disabled={
isPersistingAssetKind ||
isPerfectPixelProcessing ||
isPerfectPixelPendingConfirmation
}
aria-busy={isPersistingAssetKind || isPerfectPixelProcessing}
onClick={() => onPerfectPixel(selectedLayer)}
>
<span>
{isPersistingAssetKind
? '保存中'
: isPerfectPixelProcessing
? '处理中'
: isPerfectPixelPendingConfirmation
? '待确认'
: '完美像素'}
</span>
</CanvasChromeButton>
) : null}
{selectedLayer.assetKind === 'icon-spritesheet' &&
isActionSupported('split-icon-spritesheet', true) ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label={
@@ -223,7 +274,8 @@ export function ImageCanvasSelectedLayerToolbarView({
</span>
</CanvasChromeButton>
) : null}
{selectedLayer.assetKind === 'ui-design' ? (
{selectedLayer.assetKind === 'ui-design' &&
isActionSupported('extract-ui-design', true) ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="提取素材"
@@ -234,7 +286,7 @@ export function ImageCanvasSelectedLayerToolbarView({
<span></span>
</CanvasChromeButton>
) : null}
{selectedLayer.assetKind === 'character' ? (
{showCharacterAnimation ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="生成动画"
@@ -262,12 +314,16 @@ export function ImageCanvasSelectedLayerToolbarView({
</CanvasChromeButton>
</>
) : null}
<EditorIconButton
label="下载按钮"
title="下载按钮"
icon={Download}
onClick={() => onDownloadLayer(selectedLayer)}
/>
{extraActionDivider}
{extraActions}
{isActionSupported('download', true) ? (
<EditorIconButton
label="下载按钮"
title="下载按钮"
icon={Download}
onClick={() => onDownloadLayer(selectedLayer)}
/>
) : null}
</div>
);
}
+1 -1
View File
@@ -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));
},