From 0035d16efa46a510a5ef968d456038e0685b75e5 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 10 Sep 2026 16:37:13 +0800 Subject: [PATCH 1/2] =?UTF-8?q?AGC=20=E8=B5=84=E6=BA=90=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=94=9F=E6=88=90=E7=B4=A0=E6=9D=90=E6=B5=AE?= =?UTF-8?q?=E5=B1=82=E4=B8=8E=E7=94=9F=E6=88=90=E5=85=A5=E5=8F=A3=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 resourceCanvasGenerationModel:按本地契约锁定无源生成放行的视频 / 音效 / 背景音乐三类,提供 create: 源标识、入口渲染门禁与请求身份工厂 - 新增 ResourceCanvasGenerationPanelView:独立浮层面板承载类型选择、草稿与失败重试,失败后锁定原请求并复用同一 operationId / idempotencyKey - 浮层只呈现三类真实可用类型,不出现图片专属的模型 / 比例 / 尺寸 / 像素艺术与泥点价 - resourceCanvasChrome.css 补浮层宿主样式 - 新增 resourceCanvasGenerationEntry 定向测试 6 条 --- .../ResourceCanvasGenerationPanelView.tsx | 190 ++++++++++++++++++ .../resource-canvas/resourceCanvasChrome.css | 33 +++ .../resourceCanvasGenerationModel.ts | 125 ++++++++++++ .../resourceCanvasGenerationEntry.test.tsx | 137 +++++++++++++ 4 files changed, 485 insertions(+) create mode 100644 apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx create mode 100644 apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationModel.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasGenerationEntry.test.tsx diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx new file mode 100644 index 000000000..5932779f1 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx @@ -0,0 +1,190 @@ +import { Sparkles, X } from 'lucide-react'; +import { type FormEvent, useRef, useState } from 'react'; + +import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton'; +import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs'; +import { PlatformTextField } from '../../../../../packages/shared/src/components/PlatformTextField'; +import { ThemedModal } from '../../components/modal/ThemedModal'; +import { resourceEditPromptMaxLength } from '../../view/project-development/resourceEditModel'; +import { + createResourceCanvasGenerationRequest, + RESOURCE_CANVAS_GENERATION_DEFAULT_KIND, + RESOURCE_CANVAS_GENERATION_OPTIONS, + type ResourceCanvasGenerationKind, + resourceCanvasGenerationOption, +} from './resourceCanvasGenerationModel'; + +export type ResourceCanvasGenerationSubmitInput = { + kind: ResourceCanvasGenerationKind; + operationId: string; + idempotencyKey: string; + prompt: string; + assetName: string; +}; + +export type ResourceCanvasGenerationPanelViewProps = { + initialKind?: ResourceCanvasGenerationKind; + onSubmit: (input: ResourceCanvasGenerationSubmitInput) => Promise; + onClose: () => void; +}; + +const RESOURCE_GENERATION_KIND_ITEMS = RESOURCE_CANVAS_GENERATION_OPTIONS.map( + (option) => ({ id: option.kind, label: option.label }), +); + +function resourceGenerationErrorMessage(error: unknown) { + if (typeof error === 'string' && error.trim()) return error; + if (error instanceof Error && error.message) return error.message; + return '生成素材失败'; +} + +/** + * 资源画布「生成入口」的浮层面板。 + * + * 形态是独立弹层(`ThemedModal`,与资源面板 / 分类面板同一套宿主 chrome), + * 不在任何现有面板下面追加内容;提交链路与结果定位由宿主 `index.tsx` 负责, + * 面板只持有草稿、类型选择与失败重试状态。 + */ +export function ResourceCanvasGenerationPanelView({ + initialKind = RESOURCE_CANVAS_GENERATION_DEFAULT_KIND, + onSubmit, + onClose, +}: ResourceCanvasGenerationPanelViewProps) { + const [kind, setKind] = useState(initialKind); + const option = resourceCanvasGenerationOption(kind); + const [prompt, setPrompt] = useState(''); + const [assetName, setAssetName] = useState(option.assetName); + const [attempted, setAttempted] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + // 同一次生成会话只申请一份请求身份:失败重试必须命中同一 operation 账本。 + const requestRef = useRef<{ + operationId: string; + idempotencyKey: string; + } | null>(null); + const inputLocked = attempted || submitting; + + async function submit(event: FormEvent) { + event.preventDefault(); + const normalizedPrompt = prompt.trim(); + const normalizedAssetName = assetName.trim(); + if (!normalizedPrompt || !normalizedAssetName || submitting) { + return; + } + setAttempted(true); + setSubmitting(true); + setError(null); + requestRef.current ??= createResourceCanvasGenerationRequest(); + try { + await onSubmit({ + kind, + operationId: requestRef.current.operationId, + idempotencyKey: requestRef.current.idempotencyKey, + prompt: normalizedPrompt, + assetName: normalizedAssetName, + }); + } catch (submitError) { + setError(resourceGenerationErrorMessage(submitError)); + setSubmitting(false); + } + } + + return ( + { + if (!submitting) { + onClose(); + } + }} + panelClassName="game-approval-dialog game-resource-generation-dialog" + > +
+
+

生成素材

+
+ +
+ { + setKind(nextKind); + setAssetName(resourceCanvasGenerationOption(nextKind).assetName); + }} + /> +
+ + + {error ? ( +

+ {error} +

+ ) : null} +
+ + 取消 + + {error ? ( + + + ) : ( + + + )} +
+
+
+ ); +} 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 index de56e491d..963fb086d 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css @@ -373,4 +373,37 @@ .game-resource-panel-meta small { color: #8c6252; font-size: 0.68rem; +} + +/* 生成素材浮层:独立弹层承载类型选择、草稿与失败重试。 */ +.game-resource-generation-dialog { + width: min(560px, 100%); + max-height: min(720px, calc(100dvh - 40px)); + overflow: auto; +} + +.game-resource-generation-form { + display: grid; + gap: 12px; +} + +.game-resource-generation-form label { + display: grid; + gap: 6px; + color: #76594e; + font-size: 11px; + font-weight: 700; +} + +.game-resource-generation-error { + margin: 0; + color: #b3261e; + font-size: 11px; +} + +.game-resource-generation-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; } \ No newline at end of file diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationModel.ts new file mode 100644 index 000000000..94463f4d7 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationModel.ts @@ -0,0 +1,125 @@ +import type { LocalProjectResourceEditKind } from '../../view/project-development/resourceEditModel'; + +/** + * 资源画布「生成入口」当前能真正落地的三类新建素材。 + * + * 这里的白名单与 Rust `resolve_resource_edit_source` 的 create 分支一一对应: + * `apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs` 只放行 + * video / sound-effect / background-music,其余类型(含图片)会直接返回 + * “当前资源类型不支持无源生成”。因此入口只呈现这三类——宁可少一个入口, + * 也不给一个提交必然失败、或者拿图片参数糊弄用户的按钮。 + */ +export type ResourceCanvasGenerationKind = + | 'video' + | 'sound-effect' + | 'background-music'; + +export type ResourceCanvasGenerationOption = { + kind: ResourceCanvasGenerationKind; + editKind: LocalProjectResourceEditKind; + /** 浮层内类型选择器的短标签。 */ + label: string; + /** 入口按钮与提交按钮的完整文案。 */ + generationLabel: string; + sourceMediaType: string; + /** 新建素材的默认名称,与旧 `resourceGenerationOptions` 口径一致。 */ + assetName: string; + promptPlaceholder: string; +}; + +const VIDEO_GENERATION_OPTION: ResourceCanvasGenerationOption = { + kind: 'video', + editKind: 'video', + label: '视频', + generationLabel: '生成视频', + sourceMediaType: 'video/mp4', + assetName: '新视频', + promptPlaceholder: '描述想生成的视频画面、镜头与节奏', +}; + +const SOUND_EFFECT_GENERATION_OPTION: ResourceCanvasGenerationOption = { + kind: 'sound-effect', + editKind: 'sound-effect', + label: '音效', + generationLabel: '生成音效', + sourceMediaType: 'audio/mpeg', + assetName: '新音效', + promptPlaceholder: '描述想生成的音效,例如:木门缓慢推开时的吱呀声', +}; + +const BACKGROUND_MUSIC_GENERATION_OPTION: ResourceCanvasGenerationOption = { + kind: 'background-music', + editKind: 'background-music', + label: '背景音乐', + generationLabel: '生成背景音乐', + sourceMediaType: 'audio/mpeg', + assetName: '新背景音乐', + promptPlaceholder: '描述想生成的背景音乐风格、情绪与乐器', +}; + +export const RESOURCE_CANVAS_GENERATION_OPTIONS: readonly ResourceCanvasGenerationOption[] = + [ + VIDEO_GENERATION_OPTION, + SOUND_EFFECT_GENERATION_OPTION, + BACKGROUND_MUSIC_GENERATION_OPTION, + ]; + +export const RESOURCE_CANVAS_GENERATION_DEFAULT_KIND: ResourceCanvasGenerationKind = + 'video'; + +export function resourceCanvasGenerationOption( + kind: ResourceCanvasGenerationKind, +): ResourceCanvasGenerationOption { + if (kind === 'sound-effect') { + return SOUND_EFFECT_GENERATION_OPTION; + } + if (kind === 'background-music') { + return BACKGROUND_MUSIC_GENERATION_OPTION; + } + return VIDEO_GENERATION_OPTION; +} + +/** + * create 模式的稳定源标识。 + * + * 无源生成没有真实源资源,Rust 只把这个字符串当作 canonical resource id 记账, + * 不再回读源文件;用 operationId 拼出来保证同一 operation 的流水可对账。 + */ +export function resourceCanvasGenerationSourceId(operationId: string) { + return `create:${operationId.trim()}`; +} + +/** + * 入口按钮的渲染门禁。 + * + * 没有客户端 invoke 桥(浏览器里打开的构建)或项目还没就绪时,生成能力不成立, + * 入口整体不渲染,避免留下点了没反应的按钮。 + */ +export function isResourceCanvasGenerationAvailable({ + hasRuntimeInvoke, + projectPath, + projectId, +}: { + hasRuntimeInvoke: boolean; + projectPath: string; + projectId: string; +}) { + return ( + hasRuntimeInvoke && + projectPath.trim().length > 0 && + projectId.trim().length > 0 + ); +} + +/** + * 一次生成会话的请求身份。 + * + * 面板打开时生成一次,失败重试继续复用同一对 operationId / idempotencyKey: + * 命中同一 operation 账本,既不会重复扣费,也不会在服务端另起一次生成。 + */ +export function createResourceCanvasGenerationRequest() { + return { + operationId: crypto.randomUUID(), + idempotencyKey: crypto.randomUUID(), + }; +} diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationEntry.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationEntry.test.tsx new file mode 100644 index 000000000..43cdcf012 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationEntry.test.tsx @@ -0,0 +1,137 @@ +// @vitest-environment jsdom +import { + cleanup, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { + isResourceCanvasGenerationAvailable, + RESOURCE_CANVAS_GENERATION_OPTIONS, + resourceCanvasGenerationSourceId, +} from '../src/features/resource-canvas/resourceCanvasGenerationModel'; +import { ResourceCanvasGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasGenerationPanelView'; + +afterEach(() => { + cleanup(); +}); + +describe('resourceCanvasGenerationModel', () => { + test('只放行本地契约支持无源生成的三类素材', () => { + expect(RESOURCE_CANVAS_GENERATION_OPTIONS.map((item) => item.kind)).toEqual( + ['video', 'sound-effect', 'background-music'], + ); + expect( + RESOURCE_CANVAS_GENERATION_OPTIONS.map((item) => item.editKind), + ).toEqual(['video', 'sound-effect', 'background-music']); + }); + + test('没有客户端桥或项目未就绪时生成能力不成立', () => { + expect( + isResourceCanvasGenerationAvailable({ + hasRuntimeInvoke: false, + projectPath: '/tmp/project', + projectId: 'project-1', + }), + ).toBe(false); + expect( + isResourceCanvasGenerationAvailable({ + hasRuntimeInvoke: true, + projectPath: ' ', + projectId: 'project-1', + }), + ).toBe(false); + expect( + isResourceCanvasGenerationAvailable({ + hasRuntimeInvoke: true, + projectPath: '/tmp/project', + projectId: '', + }), + ).toBe(false); + expect( + isResourceCanvasGenerationAvailable({ + hasRuntimeInvoke: true, + projectPath: '/tmp/project', + projectId: 'project-1', + }), + ).toBe(true); + }); + + test('无源生成的稳定源标识挂在 operationId 上', () => { + expect(resourceCanvasGenerationSourceId(' op-1 ')).toBe('create:op-1'); + }); +}); + +describe('ResourceCanvasGenerationPanelView', () => { + test('只呈现三类可无源生成的素材,也不展示图片专属选项或泥点价', () => { + render( + undefined} + onClose={() => undefined} + />, + ); + + const panel = screen.getByRole('dialog', { name: '生成素材' }); + for (const label of ['视频', '音效', '背景音乐']) { + expect(within(panel).getByRole('button', { name: label })).not.toBeNull(); + } + expect(panel.textContent).not.toContain('泥点'); + expect(panel.textContent).not.toContain('像素艺术'); + expect(panel.textContent).not.toContain('图片'); + }); + + test('失败后锁定原请求并用同一 operationId 与幂等键重试', async () => { + const user = userEvent.setup(); + const onSubmit = vi + .fn<(input: unknown) => Promise>() + .mockRejectedValueOnce(new Error('result-unknown: 测试网络中断')) + .mockResolvedValueOnce(undefined); + render( + undefined} + />, + ); + + const prompt = screen.getByLabelText('生成提示词'); + const assetName = screen.getByLabelText('素材名称'); + expect((assetName as HTMLInputElement).value).toBe('新视频'); + await user.type(prompt, '一段片头动画,镜头缓慢推进'); + await user.click(screen.getByRole('button', { name: '生成视频' })); + + expect((await screen.findByRole('alert')).textContent).toContain( + 'result-unknown: 测试网络中断', + ); + expect((prompt as HTMLTextAreaElement).disabled).toBe(true); + expect((assetName as HTMLInputElement).disabled).toBe(true); + + await user.click(screen.getByRole('button', { name: '使用原请求重试' })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2)); + expect(onSubmit.mock.calls[0]?.[0]).toEqual(onSubmit.mock.calls[1]?.[0]); + expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({ + kind: 'video', + prompt: '一段片头动画,镜头缓慢推进', + assetName: '新视频', + }); + }); + + test('切换类型后默认名称与提交文案跟随类型', async () => { + const user = userEvent.setup(); + render( + undefined} + onClose={() => undefined} + />, + ); + + await user.click(screen.getByRole('button', { name: '背景音乐' })); + expect((screen.getByLabelText('素材名称') as HTMLInputElement).value).toBe( + '新背景音乐', + ); + expect(screen.getByRole('button', { name: '生成背景音乐' })).not.toBeNull(); + }); +}); From 6496601d120c0ff87ca00c32dc78b65222980283 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 10 Sep 2026 16:37:52 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E8=B5=84=E6=BA=90=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E7=94=9F=E6=88=90=E7=B4=A0=E6=9D=90=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E5=B9=B6=E6=89=93=E9=80=9A=E6=97=A0=E6=BA=90=E6=96=B0?= =?UTF-8?q?=E5=BB=BA=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - index.tsx 只做导入、一个生成入口按钮、一处浮层渲染与提交回调;无 invoke 桥或项目未就绪时入口不渲染 - 提交走 derive_local_project_resource 的 create 模式:sourceResourceId 为 create:,sourceAssetId / sourcePath / sourceSubtype / producerTaskId / sourceVersionId 全为 null - 产出后经 pendingResourceFocusRef 定位并选中新素材卡,并写入生成资源已保存提示 - projectResourceLiveIntegration 补入口可见性、create 入参、重试复用 operationId 与新卡定位 2 条用例 - 实施计划文档补 C3 生成入口口径、图片无源生成未完成项与本轮验证数字 --- .../src/view/project-development/index.tsx | 115 ++++++++++++++++++ .../projectResourceLiveIntegration.test.tsx | 53 ++++++++ ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 + 3 files changed, 170 insertions(+) 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 92b472c9a..6ccbb4fa4 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 @@ -95,6 +95,15 @@ import { resolveResourceCanvasPanelEntries, resolveSelectedPanelEntries, } from '../../features/resource-canvas/resourceCanvasAssetTransferModel'; +import { + isResourceCanvasGenerationAvailable, + resourceCanvasGenerationOption, + resourceCanvasGenerationSourceId, +} from '../../features/resource-canvas/resourceCanvasGenerationModel'; +import { + ResourceCanvasGenerationPanelView, + type ResourceCanvasGenerationSubmitInput, +} from '../../features/resource-canvas/ResourceCanvasGenerationPanelView'; import { canRedoResourceCanvasHistory, canUndoResourceCanvasHistory, @@ -1247,6 +1256,7 @@ export default function ProjectDevelopmentView({ createResourceCanvasHistory, ); const [resourcePanelOpen, setResourcePanelOpen] = useState(false); + const [resourceGenerationOpen, setResourceGenerationOpen] = useState(false); const [resourcePanelNotice, setResourcePanelNotice] = useState(''); const [resourcePanelUploading, setResourcePanelUploading] = useState(false); const [uiEditorRoute, setUiEditorRoute] = useState( @@ -5065,6 +5075,86 @@ export default function ProjectDevelopmentView({ quickEditSourceLayer, ]); + /** + * 生成入口:从资源画布无源新建视频 / 音效 / 背景音乐。 + * + * 产出物是一张全新的资源卡(`generationMode: 'create'`),源素材、画布布局与 + * 已有 manifest 条目都不参与派生;成功后用 `pendingResourceFocusRef` 定位新卡。 + */ + const submitResourceCanvasGeneration = useCallback( + async (input: ResourceCanvasGenerationSubmitInput) => { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + throw new Error('生成资源需要在客户端内执行'); + } + const option = resourceCanvasGenerationOption(input.kind); + const actionProject = { projectPath, projectId: manifest.projectId }; + const flowId = crypto.randomUUID(); + const status = await invoke<{ revision: number }>( + 'get_local_game_project_revision', + { projectPath }, + ); + if (!Number.isSafeInteger(status.revision) || status.revision < 0) { + throw new Error('项目 revision 无效'); + } + const result = await withPlatformSessionRefresh(() => + invoke( + 'derive_local_project_resource', + { + input: { + projectPath, + expectedProjectId: actionProject.projectId, + expectedProjectRevision: status.revision, + operationId: input.operationId, + idempotencyKey: input.idempotencyKey, + editKind: option.editKind, + generationMode: 'create', + sourceResourceId: resourceCanvasGenerationSourceId( + input.operationId, + ), + sourceAssetId: null, + sourcePath: null, + sourceMediaType: option.sourceMediaType, + sourceSubtype: null, + producerTaskId: null, + sourceVersionId: null, + prompt: input.prompt, + assetName: input.assetName, + }, + }, + ), + ); + if ( + !result.asset || + result.manifest.projectId !== actionProject.projectId + ) { + throw new Error('生成资源结果与当前项目不一致'); + } + onManifestChange?.(projectPath, result.manifest, { + projectId: result.manifest.projectId, + revision: result.committedProjectRevision, + source: 'asset-command', + commitId: result.operationId, + }); + activeFocusFlowIdRef.current = flowId; + pendingResourceFocusRef.current = { + flowId, + saveAttemptId: result.operationId, + sessionId: result.operationId, + draftId: result.operationId, + commitId: result.operationId, + projectPath, + projectId: result.manifest.projectId, + focusGeneration: focusGenerationRef.current, + resourceId: `asset:${result.asset.id}`, + completed: false, + }; + setResourceWorkbenchNotice('生成资源已保存,正在同步资源与布局…'); + setResourceGenerationOpen(false); + }, + [manifest.projectId, onManifestChange, projectPath], + ); + const selectedResourceCardPreview = selectedResourcePreviewIdentity ? (resourceCardPreviews.previews.get(selectedResourcePreviewIdentity) ?? null) @@ -5127,6 +5217,12 @@ export default function ProjectDevelopmentView({ canvasSize: resourceBookSceneSize, }) : null; + // 没有客户端 invoke 桥或项目还没就绪时不渲染生成入口,避免留下点了没反应的按钮。 + const resourceGenerationAvailable = isResourceCanvasGenerationAvailable({ + hasRuntimeInvoke: Boolean(window.__TAURI__?.core?.invoke), + projectPath, + projectId: manifest.projectId, + }); return (