feat(web): improve playground media resource prompts

This commit is contained in:
2026-05-14 01:17:59 +08:00
parent cdf469eccf
commit 170fd8655c
4 changed files with 763 additions and 41 deletions
+250 -9
View File
@@ -2,10 +2,11 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import type { GatewayApiKey, GatewayTask, PlatformModel } from '@easyai-ai-gateway/contracts';
import { ChevronDown, MessageSquarePlus, Send, Sparkles } from 'lucide-react';
import { Badge, Button, Select, Textarea } from '../components/ui';
import { createImageEditTask, createImageGenerationTask, createVideoGenerationTask, pollTaskUntilSettled, taskIsPending } from '../api';
import { createImageEditTask, createImageGenerationTask, createVideoGenerationTask, pollTaskUntilSettled, resolveApiAssetUrl, taskIsPending } from '../api';
import type { PlaygroundMode } from '../types';
import {
PlaygroundPromptMentionInput,
buildPlaygroundResourceToken,
removeInvalidPlaygroundResourceTokens,
replacePlaygroundResourceTokens,
} from './playground-prompt-mention';
@@ -240,6 +241,8 @@ export function PlaygroundPage(props: {
mode?: Exclude<PlaygroundMode, 'chat'>;
prompt?: string;
settings?: MediaGenerationSettings;
uploads?: PlaygroundUpload[];
videoMode?: VideoCreateMode;
}) {
const runMode = overrides?.mode ?? props.mode;
if (runMode === 'chat') return;
@@ -266,7 +269,8 @@ export function PlaygroundPage(props: {
}
const localId = newLocalId();
const runUploads = overrides ? [] : mediaUploads;
const runUploads = sanitizeMediaRunUploads(overrides?.uploads ?? mediaUploads);
const runVideoMode = overrides?.videoMode ?? videoMode;
const runModelOption = modelOptions.find((item) => item.value === runModel);
const modelLabel = runModelOption?.label ?? runModel;
const run: MediaGenerationRun = {
@@ -278,6 +282,8 @@ export function PlaygroundPage(props: {
prompt: trimmedPrompt,
settings: runSettings,
status: 'submitting',
uploads: runUploads,
videoMode: runMode === 'video' ? runVideoMode : undefined,
};
setMediaRuns((current) => [...current, run]);
@@ -288,7 +294,7 @@ export function PlaygroundPage(props: {
if (runMode === 'video') {
response = await createVideoGenerationTask(credential, {
model: runModel,
content: sharedVideoGenerationContentFromPromptAndUploads(requestPrompt, runUploads, videoMode),
content: sharedVideoGenerationContentFromPromptAndUploads(requestPrompt, runUploads, runVideoMode),
...mediaRequestPayload(runSettings, 'video'),
});
} else {
@@ -353,8 +359,15 @@ export function PlaygroundPage(props: {
}
function editMediaRun(run: MediaGenerationRun) {
setPrompt(run.prompt);
const runUploads = editableMediaRunUploads(run);
const editablePrompt = editableMediaRunPrompt(run, runUploads);
setPrompt(editablePrompt);
setMediaSettings(run.settings);
setMediaUploads(runUploads);
setImageHasReference(run.mode === 'image' && runUploads.some((item) => item.kind === 'image'));
if (run.mode === 'video') {
setVideoMode(run.videoMode ?? inferVideoModeFromUploads(runUploads));
}
selectMediaRunModel(run);
if (props.mode !== run.mode) {
props.onModeChange(run.mode);
@@ -363,15 +376,23 @@ export function PlaygroundPage(props: {
}
function rerunMediaRun(run: MediaGenerationRun) {
setPrompt(run.prompt);
const runUploads = editableMediaRunUploads(run);
const editablePrompt = editableMediaRunPrompt(run, runUploads);
const runVideoMode = run.videoMode ?? inferVideoModeFromUploads(runUploads);
setPrompt(editablePrompt);
setMediaSettings(run.settings);
setMediaUploads(runUploads);
setImageHasReference(run.mode === 'image' && runUploads.some((item) => item.kind === 'image'));
if (run.mode === 'video') {
setVideoMode(runVideoMode);
}
const runModel = selectMediaRunModel(run);
if (props.mode !== run.mode) {
props.onModeChange(run.mode);
setMediaMessage('已切换到对应模式并带入模型和参数,请确认后再次生成。');
return;
}
void submitMediaTask({ mode: run.mode, model: runModel, prompt: run.prompt, settings: run.settings });
void submitMediaTask({ mode: run.mode, model: runModel, prompt: editablePrompt, settings: run.settings, uploads: runUploads, videoMode: runVideoMode });
}
const mediaComposer = props.mode === 'chat' ? null : (
@@ -676,8 +697,8 @@ function Composer(props: {
}
function mediaPromptPlaceholder(mode: PlaygroundMode) {
if (mode === 'image') return '输入画面描述,可用 @ 快速引用图片资源,例如:让 @图像 1 保持人物一致...';
if (mode === 'video') return '输入镜头、运动和风格,可用 @ 引用图片、视频或音频资源...';
if (mode === 'image') return '输入画面描述,可用 @ 或 @资产 快速引用图片资源,例如:让 @图像 1 保持人物一致...';
if (mode === 'video') return '输入镜头、运动和风格,可用 @ 或 @资产 引用图片、视频或音频资源...';
return placeholderByMode.chat;
}
@@ -771,6 +792,211 @@ function updateMediaRun(runs: MediaGenerationRun[], localId: string, patch: Part
return runs.map((run) => run.localId === localId ? { ...run, ...patch } : run);
}
const playgroundResourceTokenPattern = /<<<playground-resource:([^>]+)>>>/g;
function editableMediaRunUploads(run: MediaGenerationRun): PlaygroundUpload[] {
const storedUploads = sanitizeMediaRunUploads(run.uploads ?? []);
if (storedUploads.length) return storedUploads;
return mediaRunUploadsFromTaskRequest(run.task?.request, run.mode);
}
function editableMediaRunPrompt(run: MediaGenerationRun, uploads: PlaygroundUpload[]) {
return restorePromptResourceTokens(run.prompt, uploads, run.mode);
}
function restorePromptResourceTokens(raw: string, uploads: PlaygroundUpload[], mode: Exclude<PlaygroundMode, 'chat'>) {
if (!uploads.length) return raw;
if (raw.includes('<<<playground-resource:')) {
return restoreSerializedPromptResourceTokens(raw, uploads);
}
return restorePromptLabelsToResourceTokens(raw, uploads, mode);
}
function restoreSerializedPromptResourceTokens(raw: string, uploads: PlaygroundUpload[]) {
const byId = new Map(uploads.map((item) => [item.id, item]));
const usedIds = new Set<string>();
let fallbackIndex = 0;
return raw.replace(playgroundResourceTokenPattern, (full, id: string) => {
let item = byId.get(id);
if (item) {
usedIds.add(item.id);
} else {
while (fallbackIndex < uploads.length && usedIds.has(uploads[fallbackIndex]!.id)) {
fallbackIndex += 1;
}
item = uploads[fallbackIndex];
fallbackIndex += 1;
if (item) usedIds.add(item.id);
}
return item ? buildPlaygroundResourceToken(item.id) : full;
});
}
function restorePromptLabelsToResourceTokens(raw: string, uploads: PlaygroundUpload[], mode: Exclude<PlaygroundMode, 'chat'>) {
return uploads.reduce((text, item) => {
const index = uploadKindIndex(item, uploads);
return promptResourceRestorePatterns(item.kind, index, mode).reduce(
(current, pattern) => current.replace(pattern, buildPlaygroundResourceToken(item.id)),
text,
);
}, raw);
}
function promptResourceRestorePatterns(kind: PlaygroundUpload['kind'], index: number, mode: Exclude<PlaygroundMode, 'chat'>) {
const englishKind = mode === 'image' ? 'image' : uploadKindEnglish(kind);
const patterns = [
new RegExp(`\\b${englishKind}\\s+${index}\\b`, 'g'),
];
if (kind === 'image') {
patterns.push(new RegExp(`@(?:图像|图片)\\s*${index}(?!\\d)`, 'g'));
} else {
patterns.push(new RegExp(`@${uploadKindTitle(kind)}\\s*${index}(?!\\d)`, 'g'));
}
patterns.push(new RegExp(`@(?:素材|资产)\\s*${index}(?!\\d)`, 'g'));
return patterns;
}
function mediaRunUploadsFromTaskRequest(request: unknown, mode: Exclude<PlaygroundMode, 'chat'>): PlaygroundUpload[] {
const record = recordFromUnknown(request);
if (!record) return [];
const uploads: PlaygroundUpload[] = [];
if (Array.isArray(record.content)) {
record.content.forEach((item) => {
const part = recordFromUnknown(item);
if (!part) return;
appendUploadFromContentPart(uploads, part);
});
}
if (mode === 'image') {
appendImageUploadsFromRequestValue(uploads, record.image);
appendImageUploadsFromRequestValue(uploads, record.images);
appendImageUploadsFromRequestValue(uploads, record.input_image);
appendImageUploadsFromRequestValue(uploads, record.input_images);
}
return dedupeMediaRunUploads(uploads);
}
function appendUploadFromContentPart(uploads: PlaygroundUpload[], part: Record<string, unknown>) {
const role = part.role === 'first_frame' || part.role === 'last_frame' ? part.role : undefined;
const type = stringFromUnknown(part.type);
if (type === 'image_url') {
appendMediaRunUploadUrl(uploads, 'image', firstString(nestedString(part.image_url, 'url'), part.url), role);
return;
}
if (type === 'video_url') {
appendMediaRunUploadUrl(uploads, 'video', firstString(nestedString(part.video_url, 'url'), part.url), role);
return;
}
if (type === 'audio_url') {
appendMediaRunUploadUrl(uploads, 'audio', firstString(nestedString(part.audio_url, 'url'), part.url), role);
}
}
function appendImageUploadsFromRequestValue(uploads: PlaygroundUpload[], value: unknown) {
if (!value) return;
if (typeof value === 'string') {
appendMediaRunUploadUrl(uploads, 'image', value);
return;
}
if (Array.isArray(value)) {
value.forEach((item) => appendImageUploadsFromRequestValue(uploads, item));
return;
}
const record = recordFromUnknown(value);
if (!record) return;
appendMediaRunUploadUrl(uploads, 'image', firstString(record.url, nestedString(record.image_url, 'url'), record.image_url, record.imageUrl, record.path));
}
function appendMediaRunUploadUrl(uploads: PlaygroundUpload[], kind: PlaygroundUpload['kind'], value: unknown, role?: PlaygroundUpload['role']) {
const rawUrl = stringFromUnknown(value);
if (!rawUrl) return;
const url = resolveApiAssetUrl(rawUrl);
uploads.push({
contentType: '',
id: `${kind}-${uploads.length}-${url}`,
kind,
name: `${uploadKindTitle(kind)} ${uploads.filter((item) => item.kind === kind).length + 1}`,
raw: {},
role,
size: 0,
url,
});
}
function dedupeMediaRunUploads(uploads: PlaygroundUpload[]) {
const seen = new Set<string>();
return uploads.filter((item) => {
const key = `${item.kind}:${item.url}:${item.role ?? ''}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function sanitizeMediaRunUploads(uploads: unknown[]): PlaygroundUpload[] {
return uploads
.map((item, index) => sanitizeMediaRunUpload(item, index))
.filter((item): item is PlaygroundUpload => Boolean(item));
}
function sanitizeMediaRunUpload(value: unknown, index: number): PlaygroundUpload | undefined {
const record = recordFromUnknown(value);
if (!record) return undefined;
const url = stringFromUnknown(record.url);
const kind = uploadKindFromUnknown(record.kind);
if (!url || !kind) return undefined;
const size = numericFromUnknown(record.size);
return {
contentType: stringFromUnknown(record.contentType),
id: stringFromUnknown(record.id) || `${kind}-${index}-${url}`,
kind,
name: stringFromUnknown(record.name) || `${uploadKindTitle(kind)} ${index + 1}`,
raw: recordFromUnknown(record.raw) ?? {},
role: record.role === 'first_frame' || record.role === 'last_frame' ? record.role : undefined,
size: size && size > 0 ? Math.round(size) : 0,
url,
};
}
function numericFromUnknown(value: unknown) {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
function uploadKindFromUnknown(value: unknown): PlaygroundUpload['kind'] | undefined {
if (value === 'audio' || value === 'file' || value === 'image' || value === 'video') return value;
return undefined;
}
function uploadKindTitle(kind: PlaygroundUpload['kind']) {
if (kind === 'image') return '图像';
if (kind === 'video') return '视频';
if (kind === 'audio') return '音频';
return '文件';
}
function uploadKindEnglish(kind: PlaygroundUpload['kind']) {
if (kind === 'image') return 'image';
if (kind === 'video') return 'video';
if (kind === 'audio') return 'audio';
return 'file';
}
function uploadKindIndex(item: PlaygroundUpload, uploads: PlaygroundUpload[]) {
const sameKind = uploads.filter((upload) => upload.kind === item.kind);
return Math.max(1, sameKind.findIndex((upload) => upload.id === item.id) + 1);
}
function inferVideoModeFromUploads(uploads: PlaygroundUpload[]): VideoCreateMode {
if (uploads.some((item) => item.role === 'first_frame' || item.role === 'last_frame')) return 'first_last_frame';
if (uploads.length > 0) return 'omni_reference';
return 'text_to_video';
}
function readStoredMediaRuns(): MediaGenerationRun[] {
if (typeof window === 'undefined') return [];
try {
@@ -796,7 +1022,9 @@ function writeStoredMediaRuns(runs: MediaGenerationRun[]) {
window.localStorage.removeItem(MEDIA_RUNS_STORAGE_KEY);
return;
}
const storedRuns = sortMediaRunsByCreatedAt(runs).slice(-MEDIA_RUNS_STORAGE_LIMIT);
const storedRuns = sortMediaRunsByCreatedAt(runs)
.slice(-MEDIA_RUNS_STORAGE_LIMIT)
.map((run) => ({ ...run, uploads: sanitizeMediaRunUploads(run.uploads ?? []) }));
window.localStorage.setItem(MEDIA_RUNS_STORAGE_KEY, JSON.stringify({
runs: storedRuns,
version: 1,
@@ -823,6 +1051,7 @@ function mediaRunFromStorage(value: unknown, index: number): MediaGenerationRun
status = 'failed';
error = error || '任务提交已中断,请重新生成。';
}
const uploads = sanitizeMediaRunUploads(Array.isArray(record.uploads) ? record.uploads : []);
return {
createdAt,
error,
@@ -834,6 +1063,8 @@ function mediaRunFromStorage(value: unknown, index: number): MediaGenerationRun
settings: mediaSettingsFromStorage(record.settings),
status,
task,
uploads,
videoMode: mode === 'video' ? videoModeFromStorage(record.videoMode, uploads) : undefined,
};
}
@@ -858,6 +1089,11 @@ function mediaSettingsFromStorage(value: unknown): MediaGenerationSettings {
};
}
function videoModeFromStorage(value: unknown, uploads: PlaygroundUpload[]): VideoCreateMode {
if (value === 'text_to_video' || value === 'first_last_frame' || value === 'omni_reference') return value;
return inferVideoModeFromUploads(uploads);
}
function countPresetFromStorage(value: unknown, fallback: MediaGenerationSettings['countPreset']) {
if (value === 'custom') return value;
const numeric = Number(value);
@@ -880,6 +1116,11 @@ function stringFromUnknown(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function nestedString(value: unknown, key: string) {
const record = recordFromUnknown(value);
return record ? stringFromUnknown(record[key]) : '';
}
function dateStringFromUnknown(value: unknown) {
const raw = stringFromUnknown(value);
if (!raw) return undefined;