Browse Source

fix: 参考@标签空null修复

爱丽丝直播课
WIN-UGQIHHLSKBB\EDY 4 weeks ago
parent
commit
1d88edb990
  1. 279
      docs/global-prompt-create-gen-task-payload.md
  2. 39
      src/layouts/globalPromptPanel/composerRangeParam.ts
  3. 5
      src/layouts/globalPromptPanel/composerSections/ReferenceUploadPromptSection.tsx
  4. 10
      src/layouts/globalPromptPanel/globalPromptPanel.tsx

279
docs/global-prompt-create-gen-task-payload.md

@ -0,0 +1,279 @@
# createGenTaskPayload 梳理
本文只说明 `src/layouts/globalPromptPanel/globalPromptPanel.tsx` 里的 `createGenTaskPayload`
它是创建任务接口 `createGenTask()` 的请求体。
它也会传给 `useGenerateCost()`,作为普通任务价格计算的同源参数。
## 1. 它解决什么问题
`createGenTaskPayload` 把创作面板当前状态整理成后端需要的任务参数。
它主要合并 4 类数据:
- 当前任务类型:`activeTaskSubType`
- 当前模型:`activeModelItem`
- 当前参数:`modelParamFields` 和 `selectedParamValues`
- 当前输入内容:`chatPrompt`、上传媒体、音色、动作模仿示例、参考主体
最终结果会用于两个地方:
- `useGenerateCost({ taskPayload: createGenTaskPayload })`
- `createGenTask(createGenTaskPayload)`
## 2. 整体流程
```txt
mediaItems
└─ splitUploadMediaItems()
├─ uploadImages
├─ uploadVideos
└─ uploadVoice
activeModelItem + activeTaskSubType + modelParamFields + selectedParamValues
└─ buildComposerTaskSubmitPayload()
├─ subType
├─ type
├─ 模型字段
├─ 普通参数
└─ extraTaskParams
createGenTaskPayload
├─ 基础任务参数
├─ chatPrompt
├─ images / videos / voices
├─ voiceId
├─ templateVideoMaterialId
└─ referenceSubjectList
```
## 3. 基础任务参数从哪里来
基础参数来自 `buildComposerTaskSubmitPayload()`
这个函数会返回:
- `subType`:当前任务子类型
- `type`:任务大类。当前代码里 `1` 是图片类,`2` 是视频类,`3` 是音频类
- 模型字段:来自 `buildGenTaskModelPayload(activeModelItem)`
- 普通参数:来自模型参数和固定参数
- `extraTaskParams`:滑块参数,比如 `speed / vol / pitch`
### 3.1 模型字段
`buildGenTaskModelPayload()` 会把同一个模型信息写成多个字段。
这是后端接口约定,不是前端重复写错。
当前会写入:
- `aiModelName`
- `aiPlatform`
- `aiModelId`
- `model`
- `aiModelCode`
- `aiModelCodeAlias`
其中 `aiPlatform`、`model`、`aiModelCode` 都来自 `activeModelItem.code`
### 3.2 参数字段
参数字段来自 `buildComposerTaskParamPayload()`
它会做这些事:
- 用 `buildPreferredParamValues()` 给缺失参数补默认值
- 找到比例字段,比如 `ratio`
- 找到分辨率字段,比如 `resolution`
- 根据比例和分辨率推导 `width / height`
- 保证请求体里有接口约定的 `ratio` 字段
### 3.3 滑块参数
`splitRangeParamsForTaskSubmit()` 会把滑块参数从顶层移到 `extraTaskParams`
例子:
```json
{
"resolution": "2K",
"ratio": "9:16",
"extraTaskParams": {
"speed": 1,
"vol": 0.8,
"pitch": 0
}
}
```
这样做是为了让普通参数留在顶层,同时让 `speed / vol / pitch` 这类范围值走 `extraTaskParams`
## 4. 媒体字段怎么来
上传媒体来自 `splitUploadMediaItems(mediaItems, composerUploadLimits)`
它会按类型拆成三列:
- 图片:`uploadImages`,最后写入 `images`
- 视频:`uploadVideos`,最后写入 `videos`
- 音频:`uploadVoice`,最后写入 `voices`
它也会按模型上传上限截断对应类型。
例如模型最多只收 1 个视频时,后面的上传视频不会进入请求体。
空数组不会提交。
所以代码最后会删除这些空字段:
- `images`
- `videos`
- `voices`
## 5. 不同任务的额外处理
`createGenTaskPayload` 在基础参数之外,会按任务类型补几个字段。
### 5.1 通用任务
所有任务都会写入:
- `chatPrompt: chatPrompt.trim()`
- `images`
- `videos`
- `voices`
如果 `chatPrompt` 为空,创建任务前会有校验。
动作模仿任务例外,因为它可以只依赖动作示例。
### 5.2 文本转语音任务
`isVoiceTask``true` 时,会写入:
- `voiceId`
取值优先级是:
```txt
selectedVoiceItem?.voiceId > voiceId
```
如果最终值为空,代码会删除 `voiceId`
创建任务前也会再校验一次,确保文本转语音必须有音色。
### 5.3 动作模仿任务
`isActionMimicTask``true` 时,`videos` 会特殊处理。
如果用户选了动作模仿示例,代码会把示例视频放到 `videos` 第一位:
```txt
[selectedActionMimicItem.url, ...uploadMediaPayload.uploadVideos]
```
如果选中的是官方示例,还会写入:
- `templateVideoMaterialId`
它来自 `selectedActionMimicItem.id`
如果不是官方示例,这个字段会被删除。
### 5.4 参考生视频任务
`isReferenceTask``true` 时,会写入:
- `referenceSubjectList`
这个字段来自 `referenceStructure.referenceSubjectList`
它保存参考主体列表,不只是普通上传图片。
## 6. 当前请求体大概长什么样
一个普通图像或视频任务的请求体大概是:
```json
{
"subType": 102,
"type": 1,
"aiModelName": "模型名",
"aiPlatform": "模型 code",
"aiModelId": 123,
"model": "模型 code",
"aiModelCode": "模型 code",
"aiModelCodeAlias": "别名",
"resolution": "2K",
"ratio": "9:16",
"width": 720,
"height": 1280,
"batchSize": 1,
"chatPrompt": "用户输入的提示词",
"images": ["https://example.com/a.png"],
"videos": ["https://example.com/a.mp4"],
"voices": ["https://example.com/a.mp3"],
"extraTaskParams": {
"speed": 1
}
}
```
实际字段会根据任务类型、模型参数、上传内容变化。
空的 `images / videos / voices` 不会出现在最终请求体里。
## 7. 创建任务前还有哪些校验
`createGenTaskPayload` 只负责组装请求体。
它不负责全部业务校验。
点击创建任务时,`handleCreateGenTaskClick()` 还会检查:
- 非动作模仿任务必须有提示词
- 当前任务是否满足最少上传数量
- 音频素材不能单独提交,必须搭配图片或视频
- 动作模仿任务必须选择示例
- 文本转语音任务必须选择音色
- 用户积分是否足够
这些校验通过后,才会调用:
```ts
await createGenTask(createGenTaskPayload);
```
## 8. 阅读这段代码时的顺序
建议按这个顺序看:
1. 先看 `createGenTaskPayload` 本体,知道最终字段有哪些。
2. 再看 `buildComposerTaskSubmitPayload()`,理解模型和参数怎么进请求体。
3. 再看 `buildComposerTaskParamPayload()`,理解默认参数、比例、分辨率、尺寸。
4. 再看 `splitRangeParamsForTaskSubmit()`,理解滑块参数为什么进 `extraTaskParams`
5. 最后看 `handleCreateGenTaskClick()`,理解请求体提交前的校验。
## 9. 为什么现在看起来杂乱
这段逻辑承担了两个职责:
- 生成创建任务的请求体
- 给价格计算提供同一份任务参数
同时它还要兼容多种任务类型。
所以它会同时碰到模型、参数、媒体、音色、动作示例、参考主体。
如果后续要继续整理,可以把它拆成一个纯函数,例如:
```ts
buildCreateGenTaskPayload({
activeTaskSubType,
activeModelItem,
modelParamFields,
selectedParamValues,
chatPrompt,
uploadMediaPayload,
selectedVoiceItem,
voiceId,
selectedActionMimicItem,
referenceSubjectList,
isVoiceTask,
isActionMimicTask,
isReferenceTask,
})
```
这样 `globalPromptPanel.tsx` 只负责传状态。
请求体规则可以放到 `utils.ts` 或单独文件里测试。

39
src/layouts/globalPromptPanel/composerRangeParam.ts

@ -6,16 +6,23 @@ export type ComposerRangeParamBounds = {
defaultValue: number; defaultValue: number;
}; };
/** 滑块型参数统一使用 0.1 步进。pitch 在 UI 层会单独改成整数步进。 */
export const RANGE_PARAM_STEP = 0.1; export const RANGE_PARAM_STEP = 0.1;
/** 避免浮点数出现 0.30000000000000004 这类展示和提交问题。 */
function roundRangeParamValue(value: number): number { function roundRangeParamValue(value: number): number {
return Math.round(value * 10) / 10; return Math.round(value * 10) / 10;
} }
/** UI 展示统一保留 1 位小数,比如 speed / vol 显示为 1.0。 */
export function formatRangeParamDisplay(value: number): string { export function formatRangeParamDisplay(value: number): string {
return roundRangeParamValue(value).toFixed(1); return roundRangeParamValue(value).toFixed(1);
} }
/**
* displayDimensions
* options[0] startValue / endValue
*/
function isRangeParamOption(option: ModelDimensionOption | undefined): boolean { function isRangeParamOption(option: ModelDimensionOption | undefined): boolean {
if (!option) return false; if (!option) return false;
const start = Number(option.startValue); const start = Number(option.startValue);
@ -23,11 +30,17 @@ function isRangeParamOption(option: ModelDimensionOption | undefined): boolean {
return Number.isFinite(start) && Number.isFinite(end) && start < end; return Number.isFinite(start) && Number.isFinite(end) && start < end;
} }
/** 把用户输入、历史任务值或草稿值限制在接口给定范围内。 */
function clampRangeParamValue(value: number, bounds: ComposerRangeParamBounds): number { function clampRangeParamValue(value: number, bounds: ComposerRangeParamBounds): number {
const clamped = Math.min(bounds.end, Math.max(bounds.start, value)); const clamped = Math.min(bounds.end, Math.max(bounds.start, value));
return roundRangeParamValue(clamped); return roundRangeParamValue(clamped);
} }
/**
*
* 0 speed / vol / pitch 0
* 0
*/
export function getRangeParamBounds(field: ComposerParamField): ComposerRangeParamBounds | null { export function getRangeParamBounds(field: ComposerParamField): ComposerRangeParamBounds | null {
const option = field.options[0]; const option = field.options[0];
if (!isRangeParamOption(option)) return null; if (!isRangeParamOption(option)) return null;
@ -41,7 +54,10 @@ export function normalizeRangeParamValue(value: number, bounds: ComposerRangePar
return clampRangeParamValue(value, bounds); return clampRangeParamValue(value, bounds);
} }
/** 仅用于滑块填充条视觉比例,展示值仍用 startValue~endValue 原值。 */ /**
*
* 使
*/
export function getRangeParamFillPercent(value: number, bounds: ComposerRangeParamBounds): number { export function getRangeParamFillPercent(value: number, bounds: ComposerRangeParamBounds): number {
const clamped = clampRangeParamValue(value, bounds); const clamped = clampRangeParamValue(value, bounds);
const span = bounds.end - bounds.start; const span = bounds.end - bounds.start;
@ -49,7 +65,10 @@ export function getRangeParamFillPercent(value: number, bounds: ComposerRangePar
return ((clamped - bounds.start) / span) * 100; return ((clamped - bounds.start) / span) * 100;
} }
/** 兼容 extraTaskParams 为对象或 JSON 字符串。 */ /**
* extraTaskParams JSON
* 便 /
*/
export function parseExtraTaskParams(raw: unknown): Record<string, unknown> | null { export function parseExtraTaskParams(raw: unknown): Record<string, unknown> | null {
if (raw == null || raw === "") return null; if (raw == null || raw === "") return null;
if (typeof raw === "string") { if (typeof raw === "string") {
@ -69,6 +88,10 @@ export function parseExtraTaskParams(raw: unknown): Record<string, unknown> | nu
return null; return null;
} }
/**
*
* extraTaskParams
*/
export function getExtraTaskParamValue(taskInfo: Record<string, unknown> | null | undefined, fieldKey: string): unknown { export function getExtraTaskParamValue(taskInfo: Record<string, unknown> | null | undefined, fieldKey: string): unknown {
const extra = parseExtraTaskParams(taskInfo?.extraTaskParams); const extra = parseExtraTaskParams(taskInfo?.extraTaskParams);
if (extra) { if (extra) {
@ -78,7 +101,7 @@ export function getExtraTaskParamValue(taskInfo: Record<string, unknown> | null
return taskInfo?.[fieldKey]; return taskInfo?.[fieldKey];
} }
/** 制作同款 / 再次生成:统一把 extraTaskParams 解析为对象,便于区间参数回填。 */ /** 制作同款 / 再次生成入口:先把 extraTaskParams 规整成对象,后续逻辑就不用重复判断类型。 */
export function normalizeTaskInfoForComposer(taskInfo: Record<string, unknown>): Record<string, unknown> { export function normalizeTaskInfoForComposer(taskInfo: Record<string, unknown>): Record<string, unknown> {
const extra = parseExtraTaskParams(taskInfo.extraTaskParams); const extra = parseExtraTaskParams(taskInfo.extraTaskParams);
if (!extra) return taskInfo; if (!extra) return taskInfo;
@ -89,7 +112,10 @@ function readRangeParamFromTaskInfo(taskInfo: Record<string, unknown>, fieldKey:
return getExtraTaskParamValue(taskInfo, fieldKey); return getExtraTaskParamValue(taskInfo, fieldKey);
} }
/** 为 speed / vol / pitch 等区间参数补齐或校正 selectedParamValues。 */ /**
* speed / vol / pitch selectedParamValues
* > >
*/
export function applyRangeParamValues( export function applyRangeParamValues(
fields: ComposerParamField[], fields: ComposerParamField[],
values: Record<string, string | number>, values: Record<string, string | number>,
@ -117,7 +143,10 @@ export function applyRangeParamValues(
return next; return next;
} }
/** 提交任务:区间参数写入 extraTaskParams,其余参数保留在顶层。 */ /**
*
* extraTaskParams
*/
export function splitRangeParamsForTaskSubmit( export function splitRangeParamsForTaskSubmit(
fields: ComposerParamField[], fields: ComposerParamField[],
paramValues: Record<string, string | number>, paramValues: Record<string, string | number>,

5
src/layouts/globalPromptPanel/composerSections/ReferenceUploadPromptSection.tsx

@ -444,6 +444,8 @@ export function ReferenceUploadPromptSection(props: CommonUploadPromptSectionPro
>(), >(),
); );
const [showPicker, setShowPicker] = useState(false); const [showPicker, setShowPicker] = useState(false);
const showPickerRef = useRef(showPicker);
showPickerRef.current = showPicker;
const [pickerPosition, setPickerPosition] = useState<{ const [pickerPosition, setPickerPosition] = useState<{
top: number; top: number;
left: number; left: number;
@ -916,7 +918,7 @@ export function ReferenceUploadPromptSection(props: CommonUploadPromptSectionPro
role: "textbox", role: "textbox",
}, },
handleKeyDown: (_view, event) => { handleKeyDown: (_view, event) => {
if (event.key === "Escape" && showPicker) { if (event.key === "Escape" && showPickerRef.current) {
setShowPicker(false); setShowPicker(false);
return true; return true;
} }
@ -947,7 +949,6 @@ export function ReferenceUploadPromptSection(props: CommonUploadPromptSectionPro
updatePickerPosition(currentEditor); updatePickerPosition(currentEditor);
}, },
}, },
[handleEditorAstChange, handleEditorCopy, handleEditorCut, handleEditorPaste, showPicker, t, updatePickerPosition],
); );
useEffect(() => { useEffect(() => {

10
src/layouts/globalPromptPanel/globalPromptPanel.tsx

@ -1718,7 +1718,9 @@ export function GlobalPromptPanel({ mode = "floating", embeddedVisible = false,
const rangeBounds = getRangeParamBounds(field); const rangeBounds = getRangeParamBounds(field);
if (rangeBounds) { if (rangeBounds) {
const selectedValue = selectedParamValues[field.key]; const selectedValue = selectedParamValues[field.key];
const rangeValue = normalizeRangeParamValue(Number(selectedValue ?? rangeBounds.defaultValue), rangeBounds); const isPitchRange = field.key.trim().toLowerCase() === "pitch";
const normalizedRangeValue = normalizeRangeParamValue(Number(selectedValue ?? rangeBounds.defaultValue), rangeBounds);
const rangeValue = isPitchRange ? Math.round(normalizedRangeValue) : normalizedRangeValue;
const fillPercent = getRangeParamFillPercent(rangeValue, rangeBounds); const fillPercent = getRangeParamFillPercent(rangeValue, rangeBounds);
const thumbAtStart = fillPercent <= 0; const thumbAtStart = fillPercent <= 0;
const thumbAtEnd = fillPercent >= 100; const thumbAtEnd = fillPercent >= 100;
@ -1727,7 +1729,7 @@ export function GlobalPromptPanel({ mode = "floating", embeddedVisible = false,
<div className="layoutShell__composerParamRangeHeader"> <div className="layoutShell__composerParamRangeHeader">
<p className="layoutShell__composerParamTitle">{field.name}</p> <p className="layoutShell__composerParamTitle">{field.name}</p>
<span className="layoutShell__composerParamRangeValue" aria-hidden="true"> <span className="layoutShell__composerParamRangeValue" aria-hidden="true">
{formatRangeParamDisplay(rangeValue)} {isPitchRange ? rangeValue : formatRangeParamDisplay(rangeValue)}
</span> </span>
</div> </div>
<div className="layoutShell__composerParamRangeSliderWrap"> <div className="layoutShell__composerParamRangeSliderWrap">
@ -1756,7 +1758,7 @@ export function GlobalPromptPanel({ mode = "floating", embeddedVisible = false,
className="layoutShell__composerParamRangeSliderInput" className="layoutShell__composerParamRangeSliderInput"
min={rangeBounds.start} min={rangeBounds.start}
max={rangeBounds.end} max={rangeBounds.end}
step={RANGE_PARAM_STEP} step={isPitchRange ? 1 : RANGE_PARAM_STEP}
value={rangeValue} value={rangeValue}
aria-label={field.name} aria-label={field.name}
aria-valuemin={rangeBounds.start} aria-valuemin={rangeBounds.start}
@ -1766,7 +1768,7 @@ export function GlobalPromptPanel({ mode = "floating", embeddedVisible = false,
const nextValue = Number(event.target.value); const nextValue = Number(event.target.value);
setSelectedParamValues((prev) => ({ setSelectedParamValues((prev) => ({
...prev, ...prev,
[field.key]: normalizeRangeParamValue(nextValue, rangeBounds), [field.key]: isPitchRange ? Math.round(normalizeRangeParamValue(nextValue, rangeBounds)) : normalizeRangeParamValue(nextValue, rangeBounds),
})); }));
}} }}
/> />

Loading…
Cancel
Save