WIP: AGC 资源画布与替换改造 V3.0 #316
+190
@@ -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<void>;
|
||||
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<ResourceCanvasGenerationKind>(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<string | null>(null);
|
||||
// 同一次生成会话只申请一份请求身份:失败重试必须命中同一 operation 账本。
|
||||
const requestRef = useRef<{
|
||||
operationId: string;
|
||||
idempotencyKey: string;
|
||||
} | null>(null);
|
||||
const inputLocked = attempted || submitting;
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
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 (
|
||||
<ThemedModal
|
||||
open
|
||||
ariaLabel="生成素材"
|
||||
closeOnBackdrop={!submitting}
|
||||
closeOnEscape={!submitting}
|
||||
onClose={() => {
|
||||
if (!submitting) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
panelClassName="game-approval-dialog game-resource-generation-dialog"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<h2>生成素材</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭生成素材"
|
||||
disabled={submitting}
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
<PlatformSegmentedTabs
|
||||
items={RESOURCE_GENERATION_KIND_ITEMS}
|
||||
activeId={kind}
|
||||
ariaLabel="生成素材类型"
|
||||
columns="three"
|
||||
gap="sm"
|
||||
size="compact"
|
||||
disabled={inputLocked}
|
||||
onChange={(nextKind) => {
|
||||
setKind(nextKind);
|
||||
setAssetName(resourceCanvasGenerationOption(nextKind).assetName);
|
||||
}}
|
||||
/>
|
||||
<form className="game-resource-generation-form" onSubmit={submit}>
|
||||
<label>
|
||||
<span>素材名称</span>
|
||||
<PlatformTextField
|
||||
aria-label="素材名称"
|
||||
maxLength={120}
|
||||
disabled={inputLocked}
|
||||
value={assetName}
|
||||
onChange={(event) => setAssetName(event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>生成提示词</span>
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
aria-label="生成提示词"
|
||||
rows={6}
|
||||
autoFocus
|
||||
disabled={inputLocked}
|
||||
maxLength={resourceEditPromptMaxLength(option.editKind)}
|
||||
placeholder={option.promptPlaceholder}
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
{error ? (
|
||||
<p className="game-resource-generation-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="game-resource-generation-actions">
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
disabled={submitting}
|
||||
onClick={onClose}
|
||||
>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
{error ? (
|
||||
<PlatformActionButton type="submit" disabled={submitting}>
|
||||
<Sparkles size={15} aria-hidden="true" />
|
||||
使用原请求重试
|
||||
</PlatformActionButton>
|
||||
) : (
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
disabled={
|
||||
submitting || !prompt.trim() || !assetName.trim() || inputLocked
|
||||
}
|
||||
>
|
||||
<Sparkles size={15} aria-hidden="true" />
|
||||
{submitting ? '生成中…' : option.generationLabel}
|
||||
</PlatformActionButton>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</ThemedModal>
|
||||
);
|
||||
}
|
||||
@@ -392,4 +392,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;
|
||||
}
|
||||
+125
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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<UiEditorRoute | null>(
|
||||
@@ -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<DeriveLocalProjectResourceResult>(
|
||||
'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 (
|
||||
<section
|
||||
@@ -5229,6 +5325,18 @@ export default function ProjectDevelopmentView({
|
||||
<FolderTree size={15} aria-hidden="true" />
|
||||
资源面板
|
||||
</button>
|
||||
{resourceGenerationAvailable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="game-workbench-resource-panel-button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={resourceGenerationOpen}
|
||||
onClick={() => setResourceGenerationOpen(true)}
|
||||
>
|
||||
<Sparkles size={15} aria-hidden="true" />
|
||||
生成素材
|
||||
</button>
|
||||
) : null}
|
||||
{pendingResourceEdits.length > 0 ||
|
||||
pendingResourceEditsLoadState === 'failed' ? (
|
||||
<button
|
||||
@@ -5965,6 +6073,13 @@ export default function ProjectDevelopmentView({
|
||||
</footer>
|
||||
) : null}
|
||||
|
||||
{resourceGenerationOpen ? (
|
||||
<ResourceCanvasGenerationPanelView
|
||||
onSubmit={submitResourceCanvasGeneration}
|
||||
onClose={() => setResourceGenerationOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{resourcePanelOpen ? (
|
||||
<ResourceCanvasPanelView
|
||||
entries={resourcePanelEntries}
|
||||
|
||||
@@ -518,6 +518,59 @@ describe('project resource live canvas integration', () => {
|
||||
expect(deriveCalls[1]).not.toHaveProperty('apiKey');
|
||||
});
|
||||
|
||||
it('creates a brand new media asset from the canvas generation entry with a create-mode derive request', async () => {
|
||||
const { deriveCalls } = installTauri({ failFirstDerive: true });
|
||||
render(<DerivedWorkbench />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '生成素材' }));
|
||||
const panel = await screen.findByRole('dialog', { name: '生成素材' });
|
||||
fireEvent.change(within(panel).getByLabelText('生成提示词'), {
|
||||
target: { value: '一段片头动画,镜头缓慢推进' },
|
||||
});
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '生成视频' }));
|
||||
expect(await within(panel).findByRole('alert')).not.toBeNull();
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: '使用原请求重试' }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(deriveCalls).toHaveLength(2));
|
||||
const operationId = String(deriveCalls[0]?.operationId);
|
||||
expect(deriveCalls[0]).toMatchObject({
|
||||
projectPath,
|
||||
expectedProjectId: 'live-canvas-project',
|
||||
editKind: 'video',
|
||||
generationMode: 'create',
|
||||
sourceResourceId: `create:${operationId}`,
|
||||
sourceAssetId: null,
|
||||
sourcePath: null,
|
||||
sourceMediaType: 'video/mp4',
|
||||
sourceSubtype: null,
|
||||
producerTaskId: null,
|
||||
sourceVersionId: null,
|
||||
prompt: '一段片头动画,镜头缓慢推进',
|
||||
assetName: '新视频',
|
||||
});
|
||||
expect(deriveCalls[0]).not.toHaveProperty('accessToken');
|
||||
expect(deriveCalls[1]?.operationId).toBe(operationId);
|
||||
expect(deriveCalls[1]?.idempotencyKey).toBe(deriveCalls[0]?.idempotencyKey);
|
||||
expect(screen.queryByRole('dialog', { name: '生成素材' })).toBeNull();
|
||||
// 产出物是新素材:画布定位并选中新卡片。
|
||||
expect(
|
||||
(await findResourceSelectButton(`${operationId}-rules.md`)).getAttribute(
|
||||
'aria-pressed',
|
||||
),
|
||||
).toBe('true');
|
||||
});
|
||||
|
||||
it('hides the canvas generation entry when the client bridge is unavailable', async () => {
|
||||
render(<DerivedWorkbench />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '资源面板' }),
|
||||
).not.toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '生成素材' })).toBeNull();
|
||||
});
|
||||
|
||||
it('lists an unfinished edit and resumes it using only the private-ledger operation id', async () => {
|
||||
const { resumeCalls } = installTauri({ pendingResourceEdit: true });
|
||||
render(<DerivedWorkbench />);
|
||||
|
||||
@@ -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(
|
||||
<ResourceCanvasGenerationPanelView
|
||||
onSubmit={async () => 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<void>>()
|
||||
.mockRejectedValueOnce(new Error('result-unknown: 测试网络中断'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
render(
|
||||
<ResourceCanvasGenerationPanelView
|
||||
onSubmit={onSubmit}
|
||||
onClose={() => 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(
|
||||
<ResourceCanvasGenerationPanelView
|
||||
onSubmit={async () => undefined}
|
||||
onClose={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '背景音乐' }));
|
||||
expect((screen.getByLabelText('素材名称') as HTMLInputElement).value).toBe(
|
||||
'新背景音乐',
|
||||
);
|
||||
expect(screen.getByRole('button', { name: '生成背景音乐' })).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1309,6 +1309,8 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过
|
||||
- **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 与后端。
|
||||
- **画布生成入口(C3 收口)**:资源画布新增「生成素材」入口,点击打开独立浮层面板(`ResourceCanvasGenerationPanelView` + `ThemedModal`,与资源面板同一套宿主 chrome,不在任何现有面板下面追加内容)。面板只提供本地契约真正支持无源生成的三类:视频 / 音效 / 背景音乐;提交走 `derive_local_project_resource`,`generationMode='create'`、`sourceResourceId='create:<operationId>'`,`sourceAssetId / sourcePath / sourceSubtype / producerTaskId / sourceVersionId` 全为 `null`,失败重试复用同一对 `operationId / idempotencyKey`;产出后经 `pendingResourceFocusRef` 定位并选中新素材卡。入口渲染门禁是 `isResourceCanvasGenerationAvailable`(客户端 invoke 桥存在 + `projectPath` 与 `manifest.projectId` 就绪),不满足时整块不渲染。**已知边界(本轮未完成项)**:`project/resource_editor.rs` 的 create 分支只放行 video / sound-effect / background-music,图片(`image-reference`)会被返回「当前资源类型不支持无源生成」;图片臂的远端请求固定走 `/api/editor/images/edits` 并要求正式源引用,入参结构体里也没有 model / aspectRatio / imageSize。因此本轮**不提供图片无源生成入口**,也**不把图片专属的模型 / 比例 / 尺寸 / 像素艺术 / 泥点价复用到这三类上**(那会渲染出不真实的图片价格)。后续要做图片生成入口,必须先扩展该命令的入参与该分支的端点路由。
|
||||
- **C3 生成入口验证**:AGC 前端全量 1100 passed / 4 skipped / 0 failed(该基线上 1092 + 本次新增 8 条);共享美术画布组件 1385 passed;`npm run ai-game-creator-shell:typecheck`、`npm run check:encoding`、`git diff --check` 全绿;本轮 Rust 零改动。
|
||||
- **验证**: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` 干净。
|
||||
|
||||
## 2026-09-11 AGC 资源工作台 V3:版本切换、参考图弹窗、标签库与素材重命名 UI
|
||||
|
||||
Reference in New Issue
Block a user