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 index 5932779f1..5fc3b897e 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx @@ -5,14 +5,18 @@ import { PlatformActionButton } from '../../../../../packages/shared/src/compone 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, + resolveResourceEditRequestIdentity, + resourceEditPromptMaxLength, + type ResourceEditRequestIdentity, +} from '../../view/project-development/resourceEditModel'; +import { RESOURCE_CANVAS_GENERATION_DEFAULT_KIND, RESOURCE_CANVAS_GENERATION_OPTIONS, type ResourceCanvasGenerationKind, resourceCanvasGenerationOption, } from './resourceCanvasGenerationModel'; +import { ResourcePromptPolishSlot } from './ResourcePromptPolishSlot'; export type ResourceCanvasGenerationSubmitInput = { kind: ResourceCanvasGenerationKind; @@ -57,11 +61,10 @@ export function ResourceCanvasGenerationPanelView({ 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); + // 请求身份绑定到铸造时的那句提示词:失败重试命中同一 operation 账本,提示词变了就重铸 + // (Rust 的 request_fingerprint 含 prompt,复用旧身份会被拒)。面板在首次提交后锁定 + // 输入,正常路径下提示词不会漂移;这里按同一口径收口,不依赖「锁」这层间接保证。 + const requestRef = useRef(null); const inputLocked = attempted || submitting; async function submit(event: FormEvent) { @@ -74,7 +77,10 @@ export function ResourceCanvasGenerationPanelView({ setAttempted(true); setSubmitting(true); setError(null); - requestRef.current ??= createResourceCanvasGenerationRequest(); + requestRef.current = resolveResourceEditRequestIdentity( + requestRef.current, + normalizedPrompt, + ); try { await onSubmit({ kind, @@ -153,6 +159,13 @@ export function ResourceCanvasGenerationPanelView({ onChange={(event) => setPrompt(event.currentTarget.value)} /> + {error ? (

{error} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourcePromptPolishSlot.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourcePromptPolishSlot.tsx new file mode 100644 index 000000000..b7a6ed284 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourcePromptPolishSlot.tsx @@ -0,0 +1,89 @@ +import { Loader2, RotateCcw, Sparkles } from 'lucide-react'; + +import { + type LocalProjectResourceEditKind, + truncateResourceEditPrompt, +} from '../../view/project-development/resourceEditModel'; +import { usePromptPolish } from '../project-workspace/usePromptPolish'; +import { resourceAssetPromptPolishContext } from './resourceCanvasPromptPolishModel'; + +export type ResourcePromptPolishSlotProps = { + /** 场景名,用于上下文文案,例如「素材生成提示词(音效)」。 */ + subject: string; + /** 决定提示词上限:润色结果超限先截断再回填。 */ + editKind: LocalProjectResourceEditKind; + prompt: string; + disabled?: boolean; + applyPrompt: (text: string) => void; +}; + +/** + * 素材提示词的「AI 润色」动作:生成素材弹窗与快速编辑浮层共用同一份。 + * + * 文案与聊天输入区一致(`AI 润色` / `恢复原文` / `润色中…` / 失败保留原文), + * 取数走 AGC 侧的 `usePromptPolish`;共享 composer 只提供注入位,不碰这里。 + */ +export function ResourcePromptPolishSlot({ + subject, + editKind, + prompt, + disabled = false, + applyPrompt, +}: ResourcePromptPolishSlotProps) { + const polish = usePromptPolish({ + readPrompt: () => prompt, + applyPrompt, + canPolish: () => !disabled, + resolveContext: () => resourceAssetPromptPolishContext(subject), + normalizeResult: (polished) => + truncateResourceEditPrompt(polished, editKind), + }); + const statusText = polish.polishing + ? '润色中…' + : (polish.error ?? polish.notice); + + return ( +

+ + {polish.originalText !== null ? ( + + ) : null} + {statusText ? ( + + {statusText} + + ) : null} +
+ ); +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css index aa07a6335..a246fb96a 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 @@ -627,6 +627,50 @@ margin: 0; } +/* 提示词润色动作(ResourcePromptPolishSlot):生成素材弹窗与快速编辑 composer 共用一行。 + * + * 注入位由共享 composer 渲染(`promptActionSlot`),节点自己铺满 composer 的一整行; + * 这一族类名只属于 AGC 侧,网页端不传注入位,不会渲染到。 */ +.resource-prompt-polish { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; + grid-column: 1 / -1; + margin-top: 0.15rem; +} + +.resource-prompt-polish-button { + display: inline-flex; + align-items: center; + gap: 0.28rem; + height: 1.85rem; + padding: 0 0.62rem; + border: 1px solid var(--image-canvas-brand-border); + border-radius: 999px; + background: #fff; + color: var(--image-canvas-brand-accent-strong); + font-size: 0.72rem; + font-weight: 700; + cursor: pointer; +} + +.resource-prompt-polish-button:hover:not(:disabled), +.resource-prompt-polish-button:focus-visible { + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft); +} + +.resource-prompt-polish-button:disabled { + color: #94a3b8; + cursor: not-allowed; +} + +.resource-prompt-polish-status { + color: var(--image-canvas-brand-accent-strong); + font-size: 0.72rem; +} + .image-canvas-editor__portal-menu { z-index: 70; } @@ -760,8 +804,7 @@ } /* 信息浮层:与快速编辑浮层同一族(同一个锚点、同一档圆角与阴影), - * 但只读、更窄,所以不复用生成 composer 的栅格。 */ -.game-resource-canvas-toolbar-host .game-resource-info-panel { + * 但只读、更窄,所以不复用生成 composer 的栅格。 */.game-resource-canvas-toolbar-host .game-resource-info-panel { position: absolute; z-index: 70; display: grid; 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 index 94463f4d7..c12ac7c02 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationModel.ts @@ -110,16 +110,3 @@ export function isResourceCanvasGenerationAvailable({ 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/src/features/resource-canvas/resourceCanvasPromptPolishModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasPromptPolishModel.ts new file mode 100644 index 000000000..c21e53b16 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasPromptPolishModel.ts @@ -0,0 +1,9 @@ +/** + * 素材提示词的润色场景约束,交给 `polish_local_project_prompt` 的 `context`(≤1000 字符)。 + * + * 后端那条通道的提示词模板偏「需求」语域,而这里要润色的是一句话的素材描述。这一批 + * 不动后端提示词文件,只把「这一句是什么、别扩写成需求」讲清楚。 + */ +export function resourceAssetPromptPolishContext(subject: string) { + return `这是${subject}。只润色这一句本身:保持原意与已有事实,不要扩写成需求、方案或验收标准,不要新增事实、参数或细节。`; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts index 0fb50dbab..70d7ba916 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts @@ -210,3 +210,62 @@ export function resourceEditPromptMaxLength( return 32_000; } } + +/** 提示词被长度上限截断时给用户看的替代文案;动过内容就必须说,不许静默丢内容。 */ +export const RESOURCE_EDIT_PROMPT_TRUNCATED_NOTICE = '已按长度上限截断'; + +/** + * 按资源类型的提示词上限截断(与 Rust `resource_editor.rs` 的上限同口径,只是本地先截)。 + * + * 上限本身不动后端;这里只处理润色回填这类程序化写入——输入框的 `maxLength` 只管用户键入。 + */ +export function truncateResourceEditPrompt( + prompt: string, + editKind: LocalProjectResourceEditKind, +): { text: string; notice: string | null } { + const limit = resourceEditPromptMaxLength(editKind); + if (prompt.length <= limit) { + return { text: prompt, notice: null }; + } + return { + text: prompt.slice(0, limit), + notice: RESOURCE_EDIT_PROMPT_TRUNCATED_NOTICE, + }; +} + +/** 一次资源编辑请求的身份:绑定到它铸造时的那句提示词。 */ +export type ResourceEditRequestIdentity = { + operationId: string; + idempotencyKey: string; + prompt: string; +}; + +export function createResourceEditRequestIdentity( + prompt: string, +): ResourceEditRequestIdentity { + return { + operationId: crypto.randomUUID(), + idempotencyKey: crypto.randomUUID(), + prompt, + }; +} + +/** + * 复用还是重铸请求身份。 + * + * Rust 的 `resource_edit_request_fingerprint` 把 `prompt` 也算进摘要 + * (`src-tauri/src/project/resource_editor.rs:807-827`),因此同一个 operationId / + * 幂等键带着改过的提示词重试,会被判成「operationId 或幂等键已绑定到不同资源编辑请求」 + * (同文件 :5216-5225)。所以身份只对它铸造时的那句提示词有效:提示词变了就重铸, + * 而不是靠禁用润色入口来回避。 + * + * 提示词没变时照旧复用:失败重试仍命中同一 operation 账本,不会重复扣费。 + */ +export function resolveResourceEditRequestIdentity( + current: ResourceEditRequestIdentity | null, + prompt: string, +): ResourceEditRequestIdentity { + return current && current.prompt === prompt + ? current + : createResourceEditRequestIdentity(prompt); +} diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationEntry.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationEntry.test.tsx index 43cdcf012..db2546989 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationEntry.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationEntry.test.tsx @@ -16,8 +16,26 @@ import { } from '../src/features/resource-canvas/resourceCanvasGenerationModel'; import { ResourceCanvasGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasGenerationPanelView'; +type TauriInvoke = ( + command: string, + args?: Record, +) => Promise; + +function installTauriInvoke(invoke: TauriInvoke) { + const mock = vi.fn(invoke); + ( + window as unknown as { + __TAURI__?: { core?: { invoke?: typeof mock } }; + } + ).__TAURI__ = { core: { invoke: mock } }; + return mock; +} + afterEach(() => { cleanup(); + delete ( + window as unknown as { __TAURI__?: { core?: { invoke?: TauriInvoke } } } + ).__TAURI__; }); describe('resourceCanvasGenerationModel', () => { @@ -135,3 +153,115 @@ describe('ResourceCanvasGenerationPanelView', () => { expect(screen.getByRole('button', { name: '生成背景音乐' })).not.toBeNull(); }); }); + +describe('生成素材弹窗的提示词润色', () => { + test('润色走短文本通道,带上「这是素材生成提示词」的场景约束并回填', async () => { + const user = userEvent.setup(); + const invoke = installTauriInvoke(async (command) => { + if (command !== 'polish_local_project_prompt') return undefined; + return ' 润色后的音效描述 '; + }); + render( + undefined} + onClose={() => undefined} + />, + ); + + const prompt = screen.getByLabelText('生成提示词') as HTMLTextAreaElement; + await user.type(prompt, '木门推开的声音'); + const polishButton = screen.getByRole('button', { name: 'AI 润色' }); + expect(polishButton.getAttribute('aria-busy')).toBe('false'); + await user.click(polishButton); + + await waitFor(() => expect(prompt.value).toBe('润色后的音效描述')); + expect(invoke).toHaveBeenCalledWith('polish_local_project_prompt', { + prompt: '木门推开的声音', + context: expect.stringContaining('这是素材生成提示词(音效)'), + }); + // 场景约束只讲这一句,不把提示词扩写成需求文档。 + const [, args] = invoke.mock.calls[0] as [string, { context: string }]; + expect(args.context).toContain('不要扩写成需求'); + // 成功后有原文快照,可以一键回到原文。 + await user.click(screen.getByRole('button', { name: '恢复原文' })); + expect(prompt.value).toBe('木门推开的声音'); + }); + + test('润色结果超上限先截断再回填,并在状态行说明', async () => { + const user = userEvent.setup(); + installTauriInvoke(async (command) => { + if (command !== 'polish_local_project_prompt') return undefined; + // 背景音乐上限 140,这里给 200 字。 + return `润色后的背景音乐描述${'啊'.repeat(196)}`; + }); + render( + undefined} + onClose={() => undefined} + />, + ); + + const prompt = screen.getByLabelText('生成提示词') as HTMLTextAreaElement; + await user.type(prompt, '轻快的八音盒'); + await user.click(screen.getByRole('button', { name: 'AI 润色' })); + + await waitFor(() => expect(prompt.value.length).toBe(140)); + expect(prompt.value.startsWith('润色后的背景音乐描述')).toBe(true); + expect(screen.getByRole('status').textContent).toBe('已按长度上限截断'); + }); + + test('润色失败保留原文并给出可重试提示', async () => { + const user = userEvent.setup(); + installTauriInvoke(async (command) => { + if (command !== 'polish_local_project_prompt') return undefined; + throw new Error('platform llm timeout'); + }); + render( + undefined} + onClose={() => undefined} + />, + ); + + const prompt = screen.getByLabelText('生成提示词') as HTMLTextAreaElement; + await user.type(prompt, '一段片头动画,镜头缓慢推进'); + await user.click(screen.getByRole('button', { name: 'AI 润色' })); + + expect(await screen.findByText('AI 润色失败,可重试')).not.toBeNull(); + expect(prompt.value).toBe('一段片头动画,镜头缓慢推进'); + expect(screen.queryByRole('button', { name: '恢复原文' })).toBeNull(); + }); + + test('提交失败后提示词与润色入口一起锁定,重试仍是同一份请求身份', async () => { + const user = userEvent.setup(); + installTauriInvoke(async () => undefined); + const onSubmit = vi + .fn<(input: unknown) => Promise>() + .mockRejectedValueOnce(new Error('result-unknown: 测试网络中断')) + .mockResolvedValueOnce(undefined); + render( + undefined} + />, + ); + + const prompt = screen.getByLabelText('生成提示词') as HTMLTextAreaElement; + await user.type(prompt, '一段片头动画'); + await user.click(screen.getByRole('button', { name: '生成视频' })); + expect(await screen.findByRole('alert')).not.toBeNull(); + + // 锁定输入 = 提交的提示词不可能漂移,所以复用同一 operationId 是安全的。 + expect(prompt.disabled).toBe(true); + const polishButton = screen.getByRole('button', { name: 'AI 润色' }); + expect((polishButton as HTMLButtonElement).disabled).toBe(true); + + await user.click(screen.getByRole('button', { name: '使用原请求重试' })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2)); + expect(onSubmit.mock.calls[1]?.[0]).toEqual(onSubmit.mock.calls[0]?.[0]); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceEditModel.test.ts b/apps/ai-game-creator-shell/tests/resourceEditModel.test.ts index 8eddc344c..6dc8bfb85 100644 --- a/apps/ai-game-creator-shell/tests/resourceEditModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceEditModel.test.ts @@ -4,7 +4,10 @@ import { canonicalProjectedResourceMediaType, resolveProjectCharacterAnimationCapability, resolveProjectResourceEditCapability, + resolveResourceEditRequestIdentity, + RESOURCE_EDIT_PROMPT_TRUNCATED_NOTICE, resourceEditPromptMaxLength, + truncateResourceEditPrompt, } from '../src/view/project-development/resourceEditModel'; import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel'; @@ -207,6 +210,51 @@ describe('全类型资源编辑能力模型', () => { expect(resourceEditPromptMaxLength('text')).toBe(32_000); }); + it('超上限的润色结果按类型截断并给出替代文案,边界上不动内容', () => { + const exactlyLimit = '音'.repeat(140); + expect( + truncateResourceEditPrompt(exactlyLimit, 'background-music'), + ).toEqual({ + text: exactlyLimit, + notice: null, + }); + const overLimit = `${exactlyLimit}多出来的一个字`; + expect(truncateResourceEditPrompt(overLimit, 'background-music')).toEqual({ + text: exactlyLimit, + notice: RESOURCE_EDIT_PROMPT_TRUNCATED_NOTICE, + }); + expect(RESOURCE_EDIT_PROMPT_TRUNCATED_NOTICE).toBe('已按长度上限截断'); + // 上限按类型走,不是一刀切。 + expect( + truncateResourceEditPrompt('字'.repeat(200), 'sound-effect').notice, + ).toBeNull(); + expect( + truncateResourceEditPrompt('字'.repeat(1_901), 'sound-effect').text + .length, + ).toBe(1_900); + expect( + truncateResourceEditPrompt('字'.repeat(32_001), 'text').text.length, + ).toBe(32_000); + }); + + it('请求身份只在提示词没变时复用', () => { + const first = resolveResourceEditRequestIdentity(null, '原始提示词'); + expect(first.prompt).toBe('原始提示词'); + expect(first.operationId).toBeTruthy(); + expect(first.idempotencyKey).toBeTruthy(); + + // 同句重试:同一份身份,命中同一 operation 账本。 + expect(resolveResourceEditRequestIdentity(first, '原始提示词')).toBe(first); + + // 提示词变了(改了、或润色回填过):必须重铸,否则 Rust 的 + // request_fingerprint(含 prompt)会把重试判成「已绑定到不同资源编辑请求」。 + const second = resolveResourceEditRequestIdentity(first, '润色后的提示词'); + expect(second).not.toBe(first); + expect(second.operationId).not.toBe(first.operationId); + expect(second.idempotencyKey).not.toBe(first.idempotencyKey); + expect(second.prompt).toBe('润色后的提示词'); + }); + it('图片资源可进入角色动画派生入口', () => { const capability = resolveProjectCharacterAnimationCapability( resource({