From 0035d16efa46a510a5ef968d456038e0685b75e5 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 10 Sep 2026 16:37:13 +0800 Subject: [PATCH] =?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(); + }); +});