生成素材弹窗接入提示词润色,并让请求身份绑住它铸造时的那句提示词
- 新增 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:
+21
-8
@@ -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;
|
||||
|
||||
-13
@@ -110,16 +110,3 @@ export function isResourceCanvasGenerationAvailable({
|
||||
projectId.trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次生成会话的请求身份。
|
||||
*
|
||||
* 面板打开时生成一次,失败重试继续复用同一对 operationId / idempotencyKey:
|
||||
* 命中同一 operation 账本,既不会重复扣费,也不会在服务端另起一次生成。
|
||||
*/
|
||||
export function createResourceCanvasGenerationRequest() {
|
||||
return {
|
||||
operationId: crypto.randomUUID(),
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
+9
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user