生成素材弹窗接入提示词润色,并让请求身份绑住它铸造时的那句提示词

- 新增 ResourcePromptPolishSlot:素材侧两处入口共用的「AI 润色 / 恢复原文 / 润色中…」动作,取数走 AGC 侧 usePromptPolish,聊天框同款失败态(失败保留原文、可重试文案)
- 场景约束走 polish_local_project_prompt 的 context:「这是素材生成提示词(音效)」+「只润色这一句、不要扩写成需求/方案/验收标准、不要新增事实」(本批不动后端提示词文件),文案落在 resourceCanvasPromptPolishModel
- resourceEditModel 新增 truncateResourceEditPrompt:超上限先按类型截断(背景音乐 140 / 音效 1900 / 视频与角色动画 4000 / 其余 32000)再回填,并在状态行给出「已按长度上限截断」,不静默丢内容;后端上限未改
- resourceEditModel 新增 resolveResourceEditRequestIdentity:请求身份绑定到铸造时的 prompt(Rust request_fingerprint 含 prompt,改了提示词还复用同一 operationId 会被判「已绑定到不同资源编辑请求」);提示词没变时仍复用,失败重试照旧命中同一账本
- 生成素材弹窗用注入位渲染润色动作(共享 composer 只提供插槽,不上 Tauri 调用),提交时按同一口径解析身份;原先只在这一处使用的 createResourceCanvasGenerationRequest 随之下线
- 样式落在 resourceCanvasChrome.css(resource-prompt-polish 一族),弹窗与 composer 共用一行
- 测试:模型层补截断边界与身份复用/重铸;弹窗层补润色回填与上下文、超限截断提示、失败保留原文、失败后提示词与润色一起锁定且重试复用同一请求
- 变异验证:截断改成恒等 → 模型用例与「超限截断」用例双双红灯(实测字段长度 206 ≠ 140);身份去掉重铸 → 模型用例红灯
This commit is contained in:
2026-09-11 16:58:49 +08:00
parent 62029fdc23
commit d44e1ae8ea
8 changed files with 401 additions and 23 deletions
@@ -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<string | null>(null);
// 同一次生成会话只申请一份请求身份:失败重试必须命中同一 operation 账本
const requestRef = useRef<{
operationId: string;
idempotencyKey: string;
} | null>(null);
// 请求身份绑定到铸造时的那句提示词:失败重试命中同一 operation 账本,提示词变了就重铸
// Rust 的 request_fingerprint 含 prompt,复用旧身份会被拒)。面板在首次提交后锁定
// 输入,正常路径下提示词不会漂移;这里按同一口径收口,不依赖「锁」这层间接保证。
const requestRef = useRef<ResourceEditRequestIdentity | null>(null);
const inputLocked = attempted || submitting;
async function submit(event: FormEvent<HTMLFormElement>) {
@@ -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)}
/>
</label>
<ResourcePromptPolishSlot
subject={`素材生成提示词(${option.label}`}
editKind={option.editKind}
prompt={prompt}
disabled={inputLocked}
applyPrompt={setPrompt}
/>
{error ? (
<p className="game-resource-generation-error" role="alert">
{error}
@@ -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 (
<div className="resource-prompt-polish">
<button
type="button"
className="resource-prompt-polish-button"
aria-label="AI 润色"
title="AI 润色"
aria-busy={polish.polishing}
disabled={disabled || polish.polishing || prompt.trim() === ''}
onMouseDown={(event) => event.preventDefault()}
onClick={() => void polish.polish()}
>
{polish.polishing ? (
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
) : (
<Sparkles size={14} aria-hidden="true" />
)}
<span>AI </span>
</button>
{polish.originalText !== null ? (
<button
type="button"
className="resource-prompt-polish-button"
aria-label="恢复原文"
title="恢复原文"
disabled={disabled || polish.polishing}
onMouseDown={(event) => event.preventDefault()}
onClick={polish.restoreOriginal}
>
<RotateCcw size={14} aria-hidden="true" />
<span></span>
</button>
) : null}
{statusText ? (
<span
className="resource-prompt-polish-status"
role="status"
aria-live="polite"
>
{statusText}
</span>
) : null}
</div>
);
}
@@ -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;
@@ -110,16 +110,3 @@ export function isResourceCanvasGenerationAvailable({
projectId.trim().length > 0
);
}
/**
* 一次生成会话的请求身份。
*
* 面板打开时生成一次,失败重试继续复用同一对 operationId / idempotencyKey
* 命中同一 operation 账本,既不会重复扣费,也不会在服务端另起一次生成。
*/
export function createResourceCanvasGenerationRequest() {
return {
operationId: crypto.randomUUID(),
idempotencyKey: crypto.randomUUID(),
};
}
@@ -0,0 +1,9 @@
/**
* 素材提示词的润色场景约束,交给 `polish_local_project_prompt` 的 `context`(≤1000 字符)。
*
* 后端那条通道的提示词模板偏「需求」语域,而这里要润色的是一句话的素材描述。这一批
* 不动后端提示词文件,只把「这一句是什么、别扩写成需求」讲清楚。
*/
export function resourceAssetPromptPolishContext(subject: string) {
return `这是${subject}。只润色这一句本身:保持原意与已有事实,不要扩写成需求、方案或验收标准,不要新增事实、参数或细节。`;
}
@@ -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);
}
@@ -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<string, unknown>,
) => Promise<unknown>;
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(
<ResourceCanvasGenerationPanelView
initialKind="sound-effect"
onSubmit={async () => 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(
<ResourceCanvasGenerationPanelView
initialKind="background-music"
onSubmit={async () => 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(
<ResourceCanvasGenerationPanelView
initialKind="video"
onSubmit={async () => 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<void>>()
.mockRejectedValueOnce(new Error('result-unknown: 测试网络中断'))
.mockResolvedValueOnce(undefined);
render(
<ResourceCanvasGenerationPanelView
initialKind="video"
onSubmit={onSubmit}
onClose={() => 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]);
});
});
@@ -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({