移除可能将 image_to_video 误判为图像模型的子串回退,在没有匹配模型时显示空状态。 新增图像生成、图像编辑和视频模式回归测试。验证通过:前端 94 项测试、类型检查、lint 和生产构建。
1429 lines
57 KiB
TypeScript
1429 lines
57 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import type { GatewayApiKey, GatewayPricingEstimate, GatewayTask, PlatformModel } from '@easyai-ai-gateway/contracts';
|
|
import { ArrowUp, ChevronDown, MessageSquarePlus, Settings2, Sparkles } from 'lucide-react';
|
|
import { Badge, Button, FormDialog, Select, Textarea } from '../components/ui';
|
|
import { GatewayApiError, createImageEditTask, createImageGenerationTask, createVideoGenerationTask, estimatePricing, pollTaskUntilSettled, resolveApiAssetUrl, taskIsPending } from '../api';
|
|
import type { PlaygroundMode } from '../types';
|
|
import {
|
|
PlaygroundPromptMentionInput,
|
|
buildPlaygroundResourceToken,
|
|
removeInvalidPlaygroundResourceTokens,
|
|
replacePlaygroundResourceTokens,
|
|
} from './playground-prompt-mention';
|
|
import {
|
|
defaultMediaGenerationSettings,
|
|
deriveMediaModelCapabilities,
|
|
gatewayTaskErrorText,
|
|
mediaRequestPayload,
|
|
MediaSettingsPopover,
|
|
MediaTaskBoard,
|
|
normalizeMediaSettingsForCapabilities,
|
|
type MediaGenerationRun,
|
|
type MediaGenerationSettings,
|
|
type MediaModelCapabilities,
|
|
} from './playground-media';
|
|
import {
|
|
ComposerUploadButton as SharedComposerUploadButton,
|
|
mediaUploadAccept as sharedMediaUploadAccept,
|
|
mediaUploadAcceptForMode as sharedMediaUploadAcceptForMode,
|
|
mediaUploadRequestPayload as sharedMediaUploadRequestPayload,
|
|
mediaUploadSummaryMessage as sharedMediaUploadSummaryMessage,
|
|
mergeMediaUploadsForMode as sharedMergeMediaUploadsForMode,
|
|
normalizeFirstLastFrameUploads as sharedNormalizeFirstLastFrameUploads,
|
|
PlaygroundReferencePicker,
|
|
swapFirstLastFrameUploads as sharedSwapFirstLastFrameUploads,
|
|
uploadPlaygroundFiles as sharedUploadPlaygroundFiles,
|
|
UploadAttachmentList as SharedUploadAttachmentList,
|
|
videoGenerationContentFromPromptAndUploads as sharedVideoGenerationContentFromPromptAndUploads,
|
|
allowedMediaUploadKinds as sharedAllowedMediaUploadKinds,
|
|
type PlaygroundUpload,
|
|
type PlaygroundUploadRole,
|
|
} from './playground-upload';
|
|
import { AssistantChatPlayground, clearStoredChatMessages } from './playground-chat';
|
|
import {
|
|
ApiKeySelect,
|
|
ModeSwitch,
|
|
PlaygroundGreeting,
|
|
apiKeyNoticeText,
|
|
modeOptions,
|
|
modelOptionLabel,
|
|
placeholderByMode,
|
|
resolveSelectedApiKeyId,
|
|
videoModeOptions,
|
|
type ModelOption,
|
|
type VideoCreateMode,
|
|
} from './playground-shared';
|
|
|
|
const MEDIA_RUNS_STORAGE_KEY = 'easyai:playground:media-runs:v1';
|
|
const MEDIA_RUNS_STORAGE_LIMIT = 50;
|
|
export const MEDIA_ESTIMATE_DEBOUNCE_MS = 350;
|
|
|
|
type MediaEstimateState = {
|
|
amount?: number;
|
|
reservationAmount?: number;
|
|
candidateCount?: number;
|
|
currency?: string;
|
|
error?: string;
|
|
pricingVersion?: string;
|
|
resolver?: string;
|
|
signature?: string;
|
|
status: 'idle' | 'loading' | 'ready' | 'free' | 'unavailable' | 'error';
|
|
};
|
|
|
|
const publicWorks = [
|
|
{ title: '雨夜霓虹街区', type: '图像生成', image: 'https://picsum.photos/seed/easyai-neon-city/720/960' },
|
|
{ title: '玻璃温室晨光', type: '图像生成', image: 'https://picsum.photos/seed/easyai-glasshouse/720/540' },
|
|
{ title: '山谷航拍镜头', type: '视频生成', image: 'https://picsum.photos/seed/easyai-valley-flight/720/1040' },
|
|
{ title: '极简产品广告', type: '图像生成', image: 'https://picsum.photos/seed/easyai-product-ad/720/680' },
|
|
{ title: '机械结构设定', type: '图像编辑', image: 'https://picsum.photos/seed/easyai-mecha-sketch/720/920' },
|
|
{ title: '城市模型推演', type: '大模型', image: 'https://picsum.photos/seed/easyai-city-plan/720/620' },
|
|
{ title: '海边人物电影感', type: '图像生成', image: 'https://picsum.photos/seed/easyai-cinematic-sea/720/980' },
|
|
{ title: '空间站漫游', type: '视频生成', image: 'https://picsum.photos/seed/easyai-orbital-walk/720/860' },
|
|
{ title: '水彩建筑手稿', type: '图像生成', image: 'https://picsum.photos/seed/easyai-watercolor-arch/720/760' },
|
|
{ title: '品牌 KV 探索', type: '图像生成', image: 'https://picsum.photos/seed/easyai-brand-kv/720/560' },
|
|
{ title: '古城夜游镜头', type: '视频生成', image: 'https://picsum.photos/seed/easyai-night-town/720/1020' },
|
|
{ title: '界面概念板', type: '大模型', image: 'https://picsum.photos/seed/easyai-ui-board/720/650' },
|
|
];
|
|
|
|
export function PlaygroundPage(props: {
|
|
apiKeySecretsById: Record<string, string>;
|
|
apiKeys: GatewayApiKey[];
|
|
mode: PlaygroundMode;
|
|
models: PlatformModel[];
|
|
selectedApiKeyId: string;
|
|
token: string;
|
|
onApiKeyChange: (apiKeyId: string) => void;
|
|
onCreateApiKey: () => void;
|
|
onLogin: () => void;
|
|
onModeChange: (mode: PlaygroundMode) => void;
|
|
}) {
|
|
const [prompt, setPrompt] = useState('');
|
|
const [selectedModel, setSelectedModel] = useState('');
|
|
const [imageHasReference, setImageHasReference] = useState(false);
|
|
const [videoMode, setVideoMode] = useState<VideoCreateMode>('text_to_video');
|
|
const [threadKey, setThreadKey] = useState(0);
|
|
const [mediaSettings, setMediaSettings] = useState(defaultMediaGenerationSettings);
|
|
const [mediaRuns, setMediaRuns] = useState<MediaGenerationRun[]>(readStoredMediaRuns);
|
|
const [mediaMessage, setMediaMessage] = useState('');
|
|
const [mediaUploadMessage, setMediaUploadMessage] = useState('');
|
|
const [mediaUploads, setMediaUploads] = useState<PlaygroundUpload[]>([]);
|
|
const [mediaEstimate, setMediaEstimate] = useState<MediaEstimateState>({ status: 'idle' });
|
|
const [mediaUploading, setMediaUploading] = useState(false);
|
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
const isMountedRef = useRef(false);
|
|
const pendingMediaModelRef = useRef('');
|
|
const mediaEstimateSequenceRef = useRef(0);
|
|
const resumedTaskIdsRef = useRef(new Set<string>());
|
|
const activeMode = useMemo(() => modeOptions.find((item) => item.value === props.mode) ?? modeOptions[0], [props.mode]);
|
|
const mediaUploadAcceptValue = sharedMediaUploadAcceptForMode(props.mode, videoMode);
|
|
const effectiveImageHasReference = imageHasReference || (props.mode === 'image' && mediaUploads.some((item) => item.kind === 'image'));
|
|
const mediaUploadSignature = useMemo(() => mediaUploads.map((item) => `${item.id}:${item.kind}:${item.role ?? ''}`).join('|'), [mediaUploads]);
|
|
const modelOptions = useMemo(
|
|
() => buildModelOptions(filterModelsForMode(props.models, props.mode, effectiveImageHasReference, videoMode)),
|
|
[effectiveImageHasReference, props.mode, props.models, videoMode],
|
|
);
|
|
const activeApiKeyId = resolveSelectedApiKeyId(props.apiKeys, props.apiKeySecretsById, props.selectedApiKeyId);
|
|
const activeApiKeySecret = activeApiKeyId ? props.apiKeySecretsById[activeApiKeyId] ?? '' : '';
|
|
const activeModelOption = useMemo(() => modelOptions.find((item) => item.value === selectedModel), [modelOptions, selectedModel]);
|
|
const mediaCapabilities = useMemo(
|
|
() => props.mode === 'chat'
|
|
? undefined
|
|
: activeModelOption
|
|
? deriveMediaModelCapabilities(activeModelOption.models, props.mode, videoMode, mediaSettings.resolution)
|
|
: undefined,
|
|
[activeModelOption, mediaSettings.resolution, props.mode, videoMode],
|
|
);
|
|
const mediaEstimatePayload = useMemo(() => {
|
|
if (props.mode === 'chat' || !selectedModel) return null;
|
|
const normalizedSettings = mediaCapabilities
|
|
? normalizeMediaSettingsForCapabilities(mediaSettings, mediaCapabilities, props.mode)
|
|
: mediaSettings;
|
|
return buildMediaEstimatePayload(props.mode, selectedModel, prompt, normalizedSettings, mediaUploads, videoMode, {
|
|
supportsQualityControl: mediaCapabilities?.supportsQualityControl,
|
|
});
|
|
}, [mediaCapabilities, mediaSettings, mediaUploads, prompt, props.mode, selectedModel, videoMode]);
|
|
const mediaEstimateSignature = useMemo(
|
|
() => billingEstimateSignature(mediaEstimatePayload),
|
|
[mediaEstimatePayload],
|
|
);
|
|
|
|
useEffect(() => {
|
|
setSelectedModel((current) => {
|
|
const pendingModel = pendingMediaModelRef.current;
|
|
if (pendingModel) {
|
|
const resolvedPending = resolveModelOptionValue(pendingModel, modelOptions);
|
|
if (resolvedPending) {
|
|
pendingMediaModelRef.current = '';
|
|
return resolvedPending;
|
|
}
|
|
}
|
|
return modelOptions.some((item) => item.value === current) ? current : modelOptions[0]?.value ?? '';
|
|
});
|
|
}, [modelOptions]);
|
|
|
|
useEffect(() => {
|
|
isMountedRef.current = true;
|
|
return () => {
|
|
isMountedRef.current = false;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (props.mode === 'chat' || !mediaCapabilities) return;
|
|
const mediaMode = props.mode;
|
|
setMediaSettings((current) => normalizeMediaSettingsForCapabilities(current, mediaCapabilities, mediaMode));
|
|
}, [mediaCapabilities, props.mode]);
|
|
|
|
useEffect(() => {
|
|
writeStoredMediaRuns(mediaRuns);
|
|
}, [mediaRuns]);
|
|
|
|
useEffect(() => {
|
|
if (props.mode === 'chat') return;
|
|
setPrompt((current) => removeInvalidPlaygroundResourceTokens(current, mediaUploads));
|
|
}, [mediaUploadSignature, props.mode]);
|
|
|
|
useEffect(() => {
|
|
const credential = activeApiKeySecret || props.token;
|
|
if (props.mode === 'chat' || !credential || !mediaEstimatePayload) {
|
|
mediaEstimateSequenceRef.current += 1;
|
|
setMediaEstimate({ status: 'idle' });
|
|
return;
|
|
}
|
|
const sequence = mediaEstimateSequenceRef.current + 1;
|
|
mediaEstimateSequenceRef.current = sequence;
|
|
const controller = new AbortController();
|
|
setMediaEstimate((current) => ({ ...current, error: '', signature: mediaEstimateSignature, status: 'loading' }));
|
|
const timer = window.setTimeout(() => {
|
|
estimatePricing(credential, mediaEstimatePayload, controller.signal)
|
|
.then((estimate) => {
|
|
if (controller.signal.aborted || sequence !== mediaEstimateSequenceRef.current) return;
|
|
setMediaEstimate({ ...mediaEstimateFromResponse(estimate), signature: mediaEstimateSignature });
|
|
})
|
|
.catch((err) => {
|
|
if (controller.signal.aborted || sequence !== mediaEstimateSequenceRef.current) return;
|
|
const unavailable = err instanceof GatewayApiError && err.details.code === 'pricing_unavailable';
|
|
setMediaEstimate({
|
|
error: err instanceof Error ? err.message : unavailable ? '价格不可用' : '预计扣费计算失败',
|
|
signature: mediaEstimateSignature,
|
|
status: unavailable ? 'unavailable' : 'error',
|
|
});
|
|
});
|
|
}, MEDIA_ESTIMATE_DEBOUNCE_MS);
|
|
return () => {
|
|
window.clearTimeout(timer);
|
|
controller.abort();
|
|
};
|
|
}, [activeApiKeySecret, mediaEstimateSignature, props.mode, props.token]);
|
|
|
|
useEffect(() => {
|
|
if (props.mode === 'image') {
|
|
setMediaUploads((current) => current.some((item) => item.kind !== 'image') ? current.filter((item) => item.kind === 'image') : current);
|
|
return;
|
|
}
|
|
if (props.mode === 'video' && videoMode === 'first_last_frame') {
|
|
setMediaUploads((current) => sharedNormalizeFirstLastFrameUploads(current));
|
|
}
|
|
}, [props.mode, videoMode]);
|
|
|
|
useEffect(() => {
|
|
if (props.mode !== 'video') return;
|
|
setVideoMode((current) => {
|
|
if (mediaUploads.length > 0 && current === 'text_to_video') return 'omni_reference';
|
|
if (mediaUploads.length === 0 && current === 'omni_reference') return 'text_to_video';
|
|
return current;
|
|
});
|
|
}, [mediaUploads.length, props.mode]);
|
|
|
|
async function uploadMediaFiles(files: File[], targetRole?: PlaygroundUploadRole) {
|
|
if (!files.length) return;
|
|
const credential = activeApiKeySecret || props.token;
|
|
if (!props.token) {
|
|
props.onLogin();
|
|
return;
|
|
}
|
|
if (!credential) {
|
|
setMediaUploadMessage('请选择可用于测试的 API Key 后再上传。');
|
|
return;
|
|
}
|
|
setMediaUploading(true);
|
|
setMediaUploadMessage('');
|
|
try {
|
|
const { items, warnings } = await sharedUploadPlaygroundFiles(credential, files, {
|
|
allowFiles: false,
|
|
allowedKinds: sharedAllowedMediaUploadKinds(props.mode, videoMode),
|
|
source: `ai-gateway-playground-${props.mode}`,
|
|
});
|
|
if (items.length) {
|
|
setMediaUploads((current) => sharedMergeMediaUploadsForMode(current, items, props.mode, videoMode, targetRole));
|
|
if (props.mode === 'image' && items.some((item) => item.kind === 'image')) {
|
|
setImageHasReference(true);
|
|
}
|
|
if (props.mode === 'video' && videoMode === 'text_to_video') {
|
|
setVideoMode('omni_reference');
|
|
}
|
|
}
|
|
setMediaUploadMessage(warnings[0] ?? '');
|
|
} catch (err) {
|
|
setMediaUploadMessage(err instanceof Error ? err.message : '文件上传失败');
|
|
} finally {
|
|
setMediaUploading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
const credential = activeApiKeySecret || props.token;
|
|
if (!credential) return;
|
|
const resumableRuns = mediaRuns.filter((run) => (
|
|
run.task?.id
|
|
&& taskIsPending(run.status)
|
|
&& !resumedTaskIdsRef.current.has(run.task.id)
|
|
));
|
|
resumableRuns.forEach((run) => {
|
|
if (!run.task?.id) return;
|
|
resumedTaskIdsRef.current.add(run.task.id);
|
|
void pollTaskUntilSettled(credential, run.task, {
|
|
onUpdate: (detail) => updateMediaRunFromTask(run.localId, detail),
|
|
})
|
|
.then((detail) => {
|
|
if (!isMountedRef.current) return;
|
|
updateMediaRunFromTask(run.localId, detail);
|
|
})
|
|
.catch((err) => {
|
|
if (!isMountedRef.current) return;
|
|
const errorMessage = err instanceof Error ? err.message : '任务状态同步失败';
|
|
setMediaRuns((current) => updateMediaRun(current, run.localId, { error: errorMessage, status: 'failed' }));
|
|
});
|
|
});
|
|
}, [activeApiKeySecret, mediaRuns, props.token]);
|
|
|
|
async function submitMediaTask(overrides?: {
|
|
model?: string;
|
|
mode?: Exclude<PlaygroundMode, 'chat'>;
|
|
prompt?: string;
|
|
settings?: MediaGenerationSettings;
|
|
uploads?: PlaygroundUpload[];
|
|
videoMode?: VideoCreateMode;
|
|
}) {
|
|
const runMode = overrides?.mode ?? props.mode;
|
|
if (runMode === 'chat') return;
|
|
const sourceSettings = overrides?.settings ?? mediaSettings;
|
|
const runSettings = mediaCapabilities ? normalizeMediaSettingsForCapabilities(sourceSettings, mediaCapabilities, runMode) : sourceSettings;
|
|
const trimmedPrompt = (overrides?.prompt ?? prompt).trim();
|
|
const credential = activeApiKeySecret || props.token;
|
|
if (!props.token) {
|
|
props.onLogin();
|
|
return;
|
|
}
|
|
if (!credential) {
|
|
setMediaMessage('请选择可用于测试的 API Key;如果列表为空,请先创建一个 Key。');
|
|
return;
|
|
}
|
|
const runModel = overrides?.model ?? selectedModel;
|
|
if (!runModel) {
|
|
setMediaMessage('当前没有可用模型,请确认用户组权限或平台模型配置。');
|
|
return;
|
|
}
|
|
if (!trimmedPrompt) {
|
|
setMediaMessage(runMode === 'video' ? '请输入视频提示词。' : '请输入图片提示词。');
|
|
return;
|
|
}
|
|
const runUploads = sanitizeMediaRunUploads(overrides?.uploads ?? mediaUploads);
|
|
const runVideoMode = overrides?.videoMode ?? videoMode;
|
|
const runModelOption = modelOptions.find((item) => item.value === runModel);
|
|
const runEstimatePayload = buildMediaEstimatePayload(runMode, runModel, trimmedPrompt, runSettings, runUploads, runVideoMode, {
|
|
supportsQualityControl: runModelOption
|
|
? deriveMediaModelCapabilities(runModelOption.models, runMode, runVideoMode, runSettings.resolution).supportsQualityControl
|
|
: mediaCapabilities?.supportsQualityControl,
|
|
});
|
|
const runEstimateSignature = billingEstimateSignature(runEstimatePayload);
|
|
if (!overrides && !mediaEstimateAllowsProduction(mediaEstimate, runEstimateSignature)) {
|
|
setMediaMessage(mediaEstimate.status === 'unavailable'
|
|
? '当前参数没有可用价格,已阻止生产生成。'
|
|
: '请等待当前参数的预计费用计算完成后再生成。');
|
|
return;
|
|
}
|
|
if (overrides) {
|
|
try {
|
|
await estimatePricing(credential, runEstimatePayload);
|
|
} catch (error) {
|
|
const unavailable = error instanceof GatewayApiError && error.details.code === 'pricing_unavailable';
|
|
setMediaMessage(unavailable
|
|
? '当前参数没有可用价格,已阻止生产生成。'
|
|
: `预计扣费计算失败,未提交生产任务:${error instanceof Error ? error.message : '未知错误'}`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const localId = newLocalId();
|
|
const modelLabel = runModelOption?.label ?? runModel;
|
|
const run: MediaGenerationRun = {
|
|
createdAt: new Date().toISOString(),
|
|
localId,
|
|
mode: runMode,
|
|
modelLabel,
|
|
modelValue: runModel,
|
|
prompt: trimmedPrompt,
|
|
settings: runSettings,
|
|
status: 'submitting',
|
|
uploads: runUploads,
|
|
videoMode: runMode === 'video' ? runVideoMode : undefined,
|
|
};
|
|
|
|
setMediaRuns((current) => [...current, run]);
|
|
setMediaMessage('');
|
|
if (!overrides) {
|
|
setPrompt('');
|
|
setMediaUploads([]);
|
|
setMediaUploadMessage('');
|
|
setImageHasReference(false);
|
|
}
|
|
try {
|
|
const requestPrompt = replacePlaygroundResourceTokens(trimmedPrompt, runUploads, runMode);
|
|
let response: { task: GatewayTask; next: Record<string, string> };
|
|
if (runMode === 'video') {
|
|
response = await createVideoGenerationTask(credential, {
|
|
model: runModel,
|
|
content: sharedVideoGenerationContentFromPromptAndUploads(requestPrompt, runUploads, runVideoMode),
|
|
...mediaRequestPayload(runSettings, 'video'),
|
|
});
|
|
} else {
|
|
const runMediaCapabilities = runModelOption
|
|
? deriveMediaModelCapabilities(runModelOption.models, runMode, runVideoMode, runSettings.resolution)
|
|
: mediaCapabilities;
|
|
const uploadPayload = sharedMediaUploadRequestPayload(runUploads, 'image');
|
|
const requestPayload = {
|
|
model: runModel,
|
|
prompt: requestPrompt,
|
|
...mediaRequestPayload(runSettings, 'image', {
|
|
supportsQualityControl: runMediaCapabilities?.supportsQualityControl,
|
|
}),
|
|
...uploadPayload,
|
|
};
|
|
response = runUploads.some((item) => item.kind === 'image')
|
|
? await createImageEditTask(credential, requestPayload)
|
|
: await createImageGenerationTask(credential, requestPayload);
|
|
}
|
|
setMediaRuns((current) => updateMediaRun(current, localId, { status: response.task.status, task: response.task }));
|
|
void pollMediaRunUntilSettled(credential, localId, response.task);
|
|
} catch (err) {
|
|
const errorMessage = err instanceof Error ? err.message : '生成任务提交失败';
|
|
setMediaMessage(errorMessage);
|
|
setMediaRuns((current) => updateMediaRun(current, localId, { error: errorMessage, status: 'failed' }));
|
|
}
|
|
}
|
|
|
|
async function pollMediaRunUntilSettled(credential: string, localId: string, task: GatewayTask) {
|
|
try {
|
|
const detail = await pollTaskUntilSettled(credential, task, {
|
|
onUpdate: (nextTask) => updateMediaRunFromTask(localId, nextTask),
|
|
});
|
|
if (!isMountedRef.current) return;
|
|
updateMediaRunFromTask(localId, detail);
|
|
} catch (err) {
|
|
if (!isMountedRef.current) return;
|
|
const errorMessage = err instanceof Error ? err.message : '任务状态同步失败';
|
|
setMediaRuns((current) => updateMediaRun(current, localId, { error: errorMessage, status: 'failed' }));
|
|
}
|
|
}
|
|
|
|
function updateMediaRunFromTask(localId: string, task: GatewayTask) {
|
|
if (!isMountedRef.current) return;
|
|
setMediaRuns((current) => updateMediaRun(current, localId, {
|
|
error: taskIsPending(task.status) ? '' : gatewayTaskErrorText(task, '任务执行失败'),
|
|
status: task.status,
|
|
task,
|
|
}));
|
|
}
|
|
|
|
function selectMediaRunModel(run: MediaGenerationRun) {
|
|
const runModel = resolveMediaRunModelValue(run, modelOptions);
|
|
if (runModel) {
|
|
pendingMediaModelRef.current = '';
|
|
setSelectedModel(runModel);
|
|
return runModel;
|
|
}
|
|
const fallbackModel = firstString(run.modelValue, run.task?.requestedModel, taskRequestModel(run.task), run.task?.model, run.task?.resolvedModel);
|
|
pendingMediaModelRef.current = fallbackModel;
|
|
return fallbackModel;
|
|
}
|
|
|
|
function editMediaRun(run: MediaGenerationRun) {
|
|
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);
|
|
}
|
|
setMediaMessage('已带入这条任务的模型、提示词和参数,可调整后再次生成。');
|
|
}
|
|
|
|
function rerunMediaRun(run: MediaGenerationRun) {
|
|
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: editablePrompt, settings: run.settings, uploads: runUploads, videoMode: runVideoMode });
|
|
}
|
|
|
|
const mediaComposer = props.mode === 'chat' ? null : (
|
|
<Composer
|
|
mode={props.mode}
|
|
modelOptions={modelOptions}
|
|
prompt={prompt}
|
|
selectedModel={selectedModel}
|
|
imageHasReference={effectiveImageHasReference}
|
|
mediaSettings={mediaSettings}
|
|
mediaEstimate={mediaEstimate}
|
|
submitDisabled={!mediaEstimateAllowsProduction(mediaEstimate, mediaEstimateSignature)}
|
|
mediaCapabilities={mediaCapabilities}
|
|
uploadAccept={mediaUploadAcceptValue}
|
|
uploadMessage={mediaUploadMessage}
|
|
uploads={mediaUploads}
|
|
uploading={mediaUploading}
|
|
videoMode={videoMode}
|
|
onImageReferenceChange={setImageHasReference}
|
|
onMediaSettingsChange={setMediaSettings}
|
|
onModeChange={props.onModeChange}
|
|
onModelChange={setSelectedModel}
|
|
onPromptChange={setPrompt}
|
|
onRemoveUpload={(id) => setMediaUploads((current) => {
|
|
const next = current.filter((item) => item.id !== id);
|
|
if (!next.some((item) => item.kind === 'image')) {
|
|
setImageHasReference(false);
|
|
}
|
|
return next;
|
|
})}
|
|
onSwapFrameUploads={() => setMediaUploads((current) => sharedSwapFirstLastFrameUploads(current))}
|
|
onSubmit={() => void submitMediaTask()}
|
|
onUploadFiles={(files, targetRole) => void uploadMediaFiles(files, targetRole)}
|
|
onVideoModeChange={setVideoMode}
|
|
/>
|
|
);
|
|
|
|
function startNewThread() {
|
|
clearStoredChatMessages();
|
|
setThreadKey((value) => value + 1);
|
|
}
|
|
|
|
return (
|
|
<div className="playgroundPage">
|
|
<aside className="playgroundSidebar">
|
|
<div className="playgroundSidebarTitle">
|
|
<strong>开启创作</strong>
|
|
<Badge variant="secondary">Test</Badge>
|
|
</div>
|
|
<div className="playgroundSidebarNav">
|
|
<button type="button" className="playgroundSideItem active" onClick={startNewThread}>
|
|
<MessageSquarePlus size={15} />
|
|
新对话
|
|
</button>
|
|
<button type="button" className="playgroundSideItem">
|
|
<Sparkles size={15} />
|
|
默认创作
|
|
</button>
|
|
</div>
|
|
<button type="button" className="playgroundSideItem playgroundSettingsButton" onClick={() => setSettingsOpen(true)}>
|
|
<Settings2 size={15} />
|
|
设置
|
|
</button>
|
|
</aside>
|
|
|
|
<main className="playgroundStage">
|
|
<section className="playgroundHero" data-chat={props.mode === 'chat'} data-media-board={props.mode !== 'chat' && mediaRuns.length > 0}>
|
|
{props.mode === 'chat' ? (
|
|
<AssistantChatPlayground
|
|
key={threadKey}
|
|
apiKeySecretsById={props.apiKeySecretsById}
|
|
apiKeys={props.apiKeys}
|
|
modelOptions={modelOptions}
|
|
selectedApiKeyId={props.selectedApiKeyId}
|
|
selectedModel={selectedModel}
|
|
token={props.token}
|
|
onApiKeyChange={props.onApiKeyChange}
|
|
onCreateApiKey={props.onCreateApiKey}
|
|
onModeChange={props.onModeChange}
|
|
onModelChange={setSelectedModel}
|
|
onLogin={props.onLogin}
|
|
/>
|
|
) : mediaRuns.length > 0 && mediaComposer ? (
|
|
<MediaTaskBoard
|
|
composer={mediaComposer}
|
|
message={mediaMessage}
|
|
runs={mediaRuns}
|
|
onEditRun={editMediaRun}
|
|
onRerun={rerunMediaRun}
|
|
/>
|
|
) : (
|
|
<>
|
|
<ModeSwitch activeMode={props.mode} onModeChange={props.onModeChange} />
|
|
<PlaygroundGreeting activeMode={activeMode} />
|
|
{mediaMessage && <p className="playgroundError">{mediaMessage}</p>}
|
|
{mediaComposer}
|
|
</>
|
|
)}
|
|
</section>
|
|
</main>
|
|
<PlaygroundSettingsDialog
|
|
apiKeySecretsById={props.apiKeySecretsById}
|
|
apiKeys={props.apiKeys}
|
|
open={settingsOpen}
|
|
selectedApiKeyId={activeApiKeyId}
|
|
onApiKeyChange={props.onApiKeyChange}
|
|
onClose={() => setSettingsOpen(false)}
|
|
onCreateApiKey={() => {
|
|
setSettingsOpen(false);
|
|
props.onCreateApiKey();
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PlaygroundSettingsDialog(props: {
|
|
apiKeySecretsById: Record<string, string>;
|
|
apiKeys: GatewayApiKey[];
|
|
open: boolean;
|
|
selectedApiKeyId: string;
|
|
onApiKeyChange: (apiKeyId: string) => void;
|
|
onClose: () => void;
|
|
onCreateApiKey: () => void;
|
|
}) {
|
|
const notice = apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById);
|
|
return (
|
|
<FormDialog
|
|
bodyClassName="playgroundSettingsDialogBody"
|
|
className="playgroundSettingsDialog"
|
|
eyebrow="在线测试"
|
|
footer={<Button type="submit">完成</Button>}
|
|
open={props.open}
|
|
title="设置"
|
|
onClose={props.onClose}
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
props.onClose();
|
|
}}
|
|
>
|
|
<label className="playgroundSettingsField">
|
|
<span className="playgroundSettingsFieldCopy">
|
|
<strong>API Key</strong>
|
|
<small>在线测试调用凭证</small>
|
|
</span>
|
|
<ApiKeySelect
|
|
apiKeySecretsById={props.apiKeySecretsById}
|
|
apiKeys={props.apiKeys}
|
|
selectedApiKeyId={props.selectedApiKeyId}
|
|
onApiKeyChange={props.onApiKeyChange}
|
|
/>
|
|
</label>
|
|
{notice && (
|
|
<div className="playgroundSettingsNotice">
|
|
<span>{notice}</span>
|
|
<Button type="button" size="sm" variant="secondary" onClick={props.onCreateApiKey}>
|
|
创建 API Key
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</FormDialog>
|
|
);
|
|
}
|
|
|
|
export function PlaygroundEntry(props: {
|
|
onModeChange: (mode: PlaygroundMode) => void;
|
|
}) {
|
|
const [mode, setMode] = useState<PlaygroundMode>('chat');
|
|
const [prompt, setPrompt] = useState('');
|
|
const activeMode = modeOptions.find((item) => item.value === mode) ?? modeOptions[0];
|
|
|
|
function openPlayground(nextMode = mode) {
|
|
props.onModeChange(nextMode);
|
|
}
|
|
|
|
return (
|
|
<section className="homePlaygroundEntry">
|
|
<h2>
|
|
开启你的 <button type="button" onClick={() => openPlayground(mode)}>{activeMode.label}<ChevronDown size={20} /></button> 即刻测试
|
|
</h2>
|
|
<Composer
|
|
mode={mode}
|
|
modelOptions={[]}
|
|
prompt={prompt}
|
|
compact
|
|
onModeChange={(nextMode) => {
|
|
setMode(nextMode);
|
|
openPlayground(nextMode);
|
|
}}
|
|
onModelChange={() => undefined}
|
|
onPromptChange={setPrompt}
|
|
onSubmit={() => openPlayground(mode)}
|
|
/>
|
|
<div className="playgroundModeCards">
|
|
{modeOptions.map((item) => (
|
|
<button type="button" key={item.value} onClick={() => openPlayground(item.value)}>
|
|
<span>{item.icon}</span>
|
|
<strong>{item.label}</strong>
|
|
<small>{item.description}</small>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export function PublicWorksGallery() {
|
|
return (
|
|
<section className="publicWorksSection">
|
|
<div className="publicWorksHeader">
|
|
<div>
|
|
<p className="eyebrow">Community Gallery</p>
|
|
<h2>公开作品展示</h2>
|
|
</div>
|
|
<div className="publicWorksTabs" aria-label="作品类型">
|
|
<button type="button" data-active="true">发现</button>
|
|
<button type="button">图片</button>
|
|
<button type="button">视频</button>
|
|
</div>
|
|
</div>
|
|
<div className="publicWorksMasonry">
|
|
{publicWorks.map((item) => (
|
|
<article className="publicWorkCard" key={item.title}>
|
|
<img src={item.image} alt={item.title} loading="lazy" />
|
|
<div>
|
|
<Badge variant="secondary">{item.type}</Badge>
|
|
<strong>{item.title}</strong>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function Composer(props: {
|
|
compact?: boolean;
|
|
imageHasReference?: boolean;
|
|
mediaCapabilities?: MediaModelCapabilities;
|
|
mediaEstimate?: MediaEstimateState;
|
|
submitDisabled?: boolean;
|
|
mediaSettings?: MediaGenerationSettings;
|
|
mode: PlaygroundMode;
|
|
modelOptions: ModelOption[];
|
|
prompt: string;
|
|
selectedModel?: string;
|
|
uploadAccept?: string;
|
|
uploadMessage?: string;
|
|
uploads?: PlaygroundUpload[];
|
|
uploading?: boolean;
|
|
videoMode?: VideoCreateMode;
|
|
onImageReferenceChange?: (value: boolean) => void;
|
|
onMediaSettingsChange?: (settings: MediaGenerationSettings) => void;
|
|
onModeChange: (mode: PlaygroundMode) => void;
|
|
onModelChange: (value: string) => void;
|
|
onPromptChange: (value: string) => void;
|
|
onRemoveUpload?: (id: string) => void;
|
|
onSwapFrameUploads?: () => void;
|
|
onSubmit?: () => void;
|
|
onUploadFiles?: (files: File[], targetRole?: PlaygroundUploadRole) => void;
|
|
onVideoModeChange?: (value: VideoCreateMode) => void;
|
|
}) {
|
|
const hasMediaReferencePicker = props.mode !== 'chat' && Boolean(props.onUploadFiles);
|
|
const mediaReferenceMessage = hasMediaReferencePicker
|
|
? props.uploadMessage || sharedMediaUploadSummaryMessage(props.uploads ?? [], props.mode, props.videoMode ?? 'text_to_video')
|
|
: props.uploadMessage;
|
|
return (
|
|
<div className={props.compact ? 'playgroundComposer compact' : 'playgroundComposer'}>
|
|
<div className={hasMediaReferencePicker ? 'composerBody composerBodyWithReferences' : 'composerBody'} data-frame-mode={props.mode === 'video' && props.videoMode === 'first_last_frame'}>
|
|
{hasMediaReferencePicker ? (
|
|
<PlaygroundReferencePicker
|
|
accept={props.uploadAccept ?? sharedMediaUploadAccept}
|
|
mode={props.mode}
|
|
uploads={props.uploads ?? []}
|
|
uploading={props.uploading}
|
|
videoMode={props.videoMode ?? 'text_to_video'}
|
|
onFiles={props.onUploadFiles}
|
|
onRemove={props.onRemoveUpload}
|
|
onSwapFrames={props.onSwapFrameUploads}
|
|
/>
|
|
) : (
|
|
<SharedComposerUploadButton
|
|
accept={props.uploadAccept ?? sharedMediaUploadAccept}
|
|
active={Boolean(props.uploads?.length) || props.imageHasReference === true}
|
|
disabled={!props.onUploadFiles}
|
|
uploading={props.uploading}
|
|
onFiles={props.onUploadFiles}
|
|
/>
|
|
)}
|
|
<div className="composerInputStack">
|
|
{hasMediaReferencePicker ? (
|
|
<PlaygroundPromptMentionInput
|
|
value={props.prompt}
|
|
placeholder={mediaPromptPlaceholder(props.mode)}
|
|
uploads={props.uploads ?? []}
|
|
onChange={props.onPromptChange}
|
|
/>
|
|
) : (
|
|
<Textarea
|
|
size={props.compact ? 'sm' : 'md'}
|
|
value={props.prompt}
|
|
placeholder={placeholderByMode[props.mode]}
|
|
onChange={(event) => props.onPromptChange(event.target.value)}
|
|
/>
|
|
)}
|
|
{!hasMediaReferencePicker && (
|
|
<SharedUploadAttachmentList
|
|
message={props.uploadMessage}
|
|
uploads={props.uploads ?? []}
|
|
onRemove={props.onRemoveUpload}
|
|
/>
|
|
)}
|
|
{hasMediaReferencePicker && mediaReferenceMessage && <div className="composerUploadMessage">{mediaReferenceMessage}</div>}
|
|
</div>
|
|
</div>
|
|
<div className="composerFooter">
|
|
<Select value={props.mode} onChange={(event) => props.onModeChange(event.target.value as PlaygroundMode)}>
|
|
{modeOptions.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}
|
|
</Select>
|
|
{props.mode === 'video' && (
|
|
<Select value={props.videoMode ?? 'text_to_video'} onChange={(event) => props.onVideoModeChange?.(event.target.value as VideoCreateMode)}>
|
|
{videoModeOptions.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}
|
|
</Select>
|
|
)}
|
|
<Select className="playgroundModelSelect" value={props.selectedModel ?? ''} disabled={!props.modelOptions.length} onChange={(event) => props.onModelChange(event.target.value)}>
|
|
{props.modelOptions.length ? props.modelOptions.map((item) => (
|
|
<option value={item.value} key={item.value}>{modelOptionLabel(item)}</option>
|
|
)) : <option value="">{props.compact ? '模型选择' : '暂无可用模型'}</option>}
|
|
</Select>
|
|
{props.mode !== 'chat' && props.mediaSettings && props.onMediaSettingsChange && (
|
|
<MediaSettingsPopover
|
|
capabilities={props.mediaCapabilities}
|
|
mode={props.mode}
|
|
settings={props.mediaSettings}
|
|
onChange={props.onMediaSettingsChange}
|
|
/>
|
|
)}
|
|
{props.mode !== 'chat' && props.mediaEstimate && (
|
|
<span
|
|
className="composerEstimatedCharge"
|
|
data-state={props.mediaEstimate.status}
|
|
title={mediaEstimateHint(props.mediaEstimate)}
|
|
aria-label={mediaEstimateAriaLabel(props.mediaEstimate)}
|
|
>
|
|
<Sparkles size={14} />
|
|
<span>{mediaEstimateText(props.mediaEstimate)}</span>
|
|
</span>
|
|
)}
|
|
<Button type="button" size="icon" className="composerMediaSendButton" aria-label="发送测试" disabled={props.submitDisabled} onClick={props.onSubmit}>
|
|
<ArrowUp size={24} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function buildMediaEstimatePayload(
|
|
mode: Exclude<PlaygroundMode, 'chat'>,
|
|
model: string,
|
|
prompt: string,
|
|
settings: MediaGenerationSettings,
|
|
uploads: PlaygroundUpload[],
|
|
videoMode: VideoCreateMode,
|
|
options?: { supportsQualityControl?: boolean },
|
|
): Record<string, unknown> {
|
|
const requestPrompt = replacePlaygroundResourceTokens(prompt.trim(), uploads, mode);
|
|
if (mode === 'video') {
|
|
return {
|
|
kind: 'videos.generations',
|
|
model,
|
|
content: sharedVideoGenerationContentFromPromptAndUploads(requestPrompt, uploads, videoMode),
|
|
...mediaRequestPayload(settings, 'video'),
|
|
};
|
|
}
|
|
|
|
const uploadPayload = sharedMediaUploadRequestPayload(uploads, 'image');
|
|
return {
|
|
kind: uploads.some((item) => item.kind === 'image') ? 'images.edits' : 'images.generations',
|
|
model,
|
|
prompt: requestPrompt,
|
|
...mediaRequestPayload(settings, 'image', options),
|
|
...uploadPayload,
|
|
};
|
|
}
|
|
|
|
export function mediaEstimateFromResponse(response: GatewayPricingEstimate): MediaEstimateState {
|
|
const amount = numericFromUnknown(response.totalAmount) ?? estimateItemsTotal(response.items);
|
|
const explicitFree = amount === 0 && response.items.length > 0 && response.items.every((item) => item.isFree === true);
|
|
return {
|
|
amount,
|
|
reservationAmount: numericFromUnknown(response.reservationAmount),
|
|
candidateCount: response.candidateCount,
|
|
currency: response.currency || estimateItemsCurrency(response.items),
|
|
pricingVersion: response.pricingVersion,
|
|
resolver: response.resolver,
|
|
status: explicitFree ? 'free' : 'ready',
|
|
};
|
|
}
|
|
|
|
function estimateItemsTotal(items: GatewayPricingEstimate['items']) {
|
|
const total = items.reduce((sum, item) => sum + (numericFromUnknown(item.amount) ?? 0), 0);
|
|
return Math.round(total * 1_000_000) / 1_000_000;
|
|
}
|
|
|
|
function estimateItemsCurrency(items: GatewayPricingEstimate['items']) {
|
|
return items.find((item) => stringFromUnknown(item.currency))?.currency || 'resource';
|
|
}
|
|
|
|
function mediaEstimateText(estimate: MediaEstimateState) {
|
|
if (estimate.status === 'loading') return '计算中';
|
|
if (estimate.status === 'unavailable') return '价格不可用';
|
|
if (estimate.status === 'error') return '估价失败';
|
|
if (estimate.status === 'free') return '免费';
|
|
if (estimate.amount === undefined) return '--';
|
|
const amount = formatEstimateAmount(estimate.amount);
|
|
const reservation = estimate.reservationAmount;
|
|
if (reservation !== undefined && reservation > estimate.amount) {
|
|
return `预计 ${amount} · 冻结上限 ${formatEstimateAmount(reservation)}`;
|
|
}
|
|
return `预计 ${amount}`;
|
|
}
|
|
|
|
function mediaEstimateHint(estimate: MediaEstimateState) {
|
|
if (estimate.status === 'unavailable' && estimate.error) return `价格不可用:${estimate.error}`;
|
|
if (estimate.status === 'error' && estimate.error) return `预计扣费计算失败:${estimate.error}`;
|
|
if (estimate.status === 'free') return '当前计价规则已显式标记为免费';
|
|
return '扣费为预计,实际扣费以账单为准';
|
|
}
|
|
|
|
function mediaEstimateAriaLabel(estimate: MediaEstimateState) {
|
|
if (estimate.status === 'error') return estimate.error ? `预计扣费计算失败:${estimate.error}` : '预计扣费计算失败';
|
|
if (estimate.status === 'unavailable') return estimate.error ? `价格不可用:${estimate.error}` : '价格不可用';
|
|
if (estimate.status === 'free') return '当前请求免费';
|
|
if (estimate.amount === undefined) return '预计扣费估算中';
|
|
return `预计扣费 ${formatEstimateAmount(estimate.amount)} ${estimate.currency || 'resource'}`;
|
|
}
|
|
|
|
export function mediaEstimateAllowsProduction(estimate: MediaEstimateState, signature: string) {
|
|
return estimate.signature === signature && (estimate.status === 'ready' || estimate.status === 'free');
|
|
}
|
|
|
|
export function billingEstimateSignature(payload: Record<string, unknown> | null) {
|
|
if (!payload) return '';
|
|
const content = Array.isArray(payload.content)
|
|
? payload.content.map((item) => {
|
|
const record = item && typeof item === 'object' ? item as Record<string, unknown> : {};
|
|
return {
|
|
duration: record.duration,
|
|
role: record.role,
|
|
type: record.type,
|
|
hasAudio: Boolean(record.audio_url),
|
|
hasImage: Boolean(record.image_url),
|
|
hasVideo: Boolean(record.video_url),
|
|
};
|
|
})
|
|
: undefined;
|
|
const signature = {
|
|
kind: payload.kind,
|
|
model: payload.model,
|
|
n: payload.n ?? payload.count ?? payload.batch_size ?? payload.batchSize,
|
|
quality: payload.quality,
|
|
size: payload.size,
|
|
resolution: payload.resolution,
|
|
duration: payload.duration,
|
|
audio: payload.audio,
|
|
maxCompletionTokens: payload.max_completion_tokens,
|
|
maxOutputTokens: payload.max_output_tokens,
|
|
maxTokens: payload.max_tokens,
|
|
content,
|
|
imageCount: pricingReferenceCount(payload, ['image', 'images', 'image_url', 'image_urls', 'referenceImage', 'reference_image']),
|
|
};
|
|
return JSON.stringify(signature);
|
|
}
|
|
|
|
function pricingReferenceCount(payload: Record<string, unknown>, keys: string[]) {
|
|
return keys.reduce((count, key) => {
|
|
const value = payload[key];
|
|
if (Array.isArray(value)) return count + value.length;
|
|
return count + (value ? 1 : 0);
|
|
}, 0);
|
|
}
|
|
|
|
function formatEstimateAmount(value: number) {
|
|
if (!Number.isFinite(value)) return '--';
|
|
const digits = Math.abs(value) > 0 && Math.abs(value) < 0.01 ? 6 : 2;
|
|
return value.toFixed(digits).replace(/\.?0+$/, '');
|
|
}
|
|
|
|
function mediaPromptPlaceholder(mode: PlaygroundMode) {
|
|
if (mode === 'image') return '输入画面描述,可用 @ 或 @资产 快速引用图片资源,例如:让 @图像 1 保持人物一致...';
|
|
if (mode === 'video') return '输入镜头、运动和风格,可用 @ 或 @资产 引用图片、视频或音频资源...';
|
|
return placeholderByMode.chat;
|
|
}
|
|
|
|
export function filterModelsForMode(models: PlatformModel[], mode: PlaygroundMode, hasReference: boolean, videoMode: VideoCreateMode) {
|
|
if (mode === 'chat') {
|
|
return filterModelsByType(models, ['text_generate', 'chat', 'responses', 'text']);
|
|
}
|
|
if (mode === 'image') {
|
|
const preferredTypes = hasReference ? ['image_edit', 'images.edits'] : ['image_generate', 'images.generations'];
|
|
return filterModelsByType(models, [...preferredTypes, 'image']);
|
|
}
|
|
const videoTypesByMode: Record<VideoCreateMode, string[]> = {
|
|
first_last_frame: ['video_first_last_frame', 'image_to_video', 'video_generate'],
|
|
omni_reference: ['omni_video', 'video_reference', 'video_generate'],
|
|
text_to_video: ['text_to_video', 'video_generate'],
|
|
};
|
|
return filterModelsByType(models, [...videoTypesByMode[videoMode], 'video']);
|
|
}
|
|
|
|
function filterModelsByType(models: PlatformModel[], modelTypes: string[]) {
|
|
const acceptedTypes = new Set(modelTypes);
|
|
return models.filter((model) => model.modelType.some((type) => acceptedTypes.has(type)));
|
|
}
|
|
|
|
function buildModelOptions(models: PlatformModel[]): ModelOption[] {
|
|
const grouped = new Map<string, ModelOption>();
|
|
models.forEach((model) => {
|
|
const value = model.modelAlias || model.modelName;
|
|
if (!value) return;
|
|
const current = grouped.get(value);
|
|
if (current) {
|
|
current.count += 1;
|
|
current.models.push(model);
|
|
if (!current.provider.includes(model.provider || '')) {
|
|
current.provider = [current.provider, model.provider].filter(Boolean).join(' / ');
|
|
}
|
|
return;
|
|
}
|
|
grouped.set(value, {
|
|
count: 1,
|
|
label: model.modelAlias || model.displayName || model.modelName,
|
|
models: [model],
|
|
provider: model.provider || model.platformName || '',
|
|
value,
|
|
});
|
|
});
|
|
return Array.from(grouped.values()).sort((a, b) => a.label.localeCompare(b.label));
|
|
}
|
|
|
|
function resolveMediaRunModelValue(run: MediaGenerationRun, modelOptions: ModelOption[]) {
|
|
const candidates = [
|
|
run.modelValue,
|
|
run.task?.requestedModel,
|
|
taskRequestModel(run.task),
|
|
run.task?.model,
|
|
run.task?.resolvedModel,
|
|
];
|
|
for (const candidate of candidates) {
|
|
const value = resolveModelOptionValue(candidate, modelOptions);
|
|
if (value) return value;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function resolveModelOptionValue(value: unknown, modelOptions: ModelOption[]) {
|
|
const raw = stringFromUnknown(value);
|
|
if (!raw) return '';
|
|
const direct = modelOptions.find((item) => item.value === raw);
|
|
if (direct) return direct.value;
|
|
const matched = modelOptions.find((item) => item.models.some((model) => (
|
|
model.modelAlias === raw
|
|
|| model.modelName === raw
|
|
|| model.displayName === raw
|
|
)));
|
|
return matched?.value ?? '';
|
|
}
|
|
|
|
function taskRequestModel(task: GatewayTask | undefined) {
|
|
return stringFromUnknown(task?.request?.model);
|
|
}
|
|
|
|
function firstString(...values: unknown[]) {
|
|
for (const value of values) {
|
|
const text = stringFromUnknown(value);
|
|
if (text) return text;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function updateMediaRun(runs: MediaGenerationRun[], localId: string, patch: Partial<MediaGenerationRun>) {
|
|
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 {
|
|
const raw = window.localStorage.getItem(MEDIA_RUNS_STORAGE_KEY);
|
|
if (!raw) return [];
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
const record = recordFromUnknown(parsed);
|
|
const source = Array.isArray(parsed) ? parsed : record?.runs;
|
|
if (!Array.isArray(source)) return [];
|
|
const runs = source
|
|
.map((item, index) => mediaRunFromStorage(item, index))
|
|
.filter((item): item is MediaGenerationRun => Boolean(item));
|
|
return sortMediaRunsByCreatedAt(runs).slice(-MEDIA_RUNS_STORAGE_LIMIT);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function writeStoredMediaRuns(runs: MediaGenerationRun[]) {
|
|
if (typeof window === 'undefined') return;
|
|
try {
|
|
if (!runs.length) {
|
|
window.localStorage.removeItem(MEDIA_RUNS_STORAGE_KEY);
|
|
return;
|
|
}
|
|
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,
|
|
}));
|
|
} catch {
|
|
// Best effort only: private mode or full storage should not break generation.
|
|
}
|
|
}
|
|
|
|
function mediaRunFromStorage(value: unknown, index: number): MediaGenerationRun | undefined {
|
|
const record = recordFromUnknown(value);
|
|
if (!record) return undefined;
|
|
const mode = record.mode === 'image' || record.mode === 'video' ? record.mode : undefined;
|
|
const prompt = stringFromUnknown(record.prompt);
|
|
if (!mode || !prompt) return undefined;
|
|
const task = taskFromStorage(record.task);
|
|
const createdAt = dateStringFromUnknown(record.createdAt) ?? new Date().toISOString();
|
|
const localId = stringFromUnknown(record.localId) || task?.id || `stored-${index}-${createdAt}`;
|
|
const modelValue = stringFromUnknown(record.modelValue) || task?.requestedModel || taskRequestModel(task) || task?.model || '';
|
|
const modelLabel = stringFromUnknown(record.modelLabel) || modelValue || '未知模型';
|
|
let status: MediaGenerationRun['status'] = stringFromUnknown(record.status) || task?.status || 'failed';
|
|
let error = stringFromUnknown(record.error);
|
|
if (status === 'submitting' && !task?.id) {
|
|
status = 'failed';
|
|
error = error || '任务提交已中断,请重新生成。';
|
|
}
|
|
const uploads = sanitizeMediaRunUploads(Array.isArray(record.uploads) ? record.uploads : []);
|
|
return {
|
|
createdAt,
|
|
error,
|
|
localId,
|
|
mode,
|
|
modelLabel,
|
|
modelValue,
|
|
prompt,
|
|
settings: mediaSettingsFromStorage(record.settings),
|
|
status,
|
|
task,
|
|
uploads,
|
|
videoMode: mode === 'video' ? videoModeFromStorage(record.videoMode, uploads) : undefined,
|
|
};
|
|
}
|
|
|
|
function sortMediaRunsByCreatedAt(runs: MediaGenerationRun[]) {
|
|
return [...runs].sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt));
|
|
}
|
|
|
|
function mediaSettingsFromStorage(value: unknown): MediaGenerationSettings {
|
|
const fallback = defaultMediaGenerationSettings();
|
|
const record = recordFromUnknown(value);
|
|
if (!record) return fallback;
|
|
return {
|
|
aspectRatio: stringFromUnknown(record.aspectRatio) || fallback.aspectRatio,
|
|
countPreset: countPresetFromStorage(record.countPreset, fallback.countPreset),
|
|
customCount: numberFromUnknown(record.customCount, fallback.customCount, 1, 20),
|
|
durationSeconds: numberFromUnknown(record.durationSeconds ?? record.duration_seconds ?? record.duration, fallback.durationSeconds, 1, 3600),
|
|
height: numberFromUnknown(record.height, fallback.height, 128, 8192),
|
|
outputMode: record.outputMode === 'group' ? 'group' : 'single',
|
|
outputAudio: booleanFromUnknown(record.outputAudio ?? record.output_audio ?? record.audio, fallback.outputAudio),
|
|
quality: imageQualityFromStorage(record.quality, fallback.quality),
|
|
resolution: stringFromUnknown(record.resolution) || fallback.resolution,
|
|
width: numberFromUnknown(record.width, fallback.width, 128, 8192),
|
|
};
|
|
}
|
|
|
|
function imageQualityFromStorage(value: unknown, fallback: MediaGenerationSettings['quality']) {
|
|
if (value === 'low' || value === 'medium' || value === 'high' || value === 'auto') return value;
|
|
return fallback;
|
|
}
|
|
|
|
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);
|
|
if (numeric === 1 || numeric === 2 || numeric === 3 || numeric === 4) return numeric;
|
|
return fallback;
|
|
}
|
|
|
|
function taskFromStorage(value: unknown): GatewayTask | undefined {
|
|
const record = recordFromUnknown(value);
|
|
if (!record || !stringFromUnknown(record.id) || !stringFromUnknown(record.status)) return undefined;
|
|
return record as unknown as GatewayTask;
|
|
}
|
|
|
|
function recordFromUnknown(value: unknown): Record<string, unknown> | undefined {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
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;
|
|
const time = Date.parse(raw);
|
|
return Number.isFinite(time) ? raw : undefined;
|
|
}
|
|
|
|
function numberFromUnknown(value: unknown, fallback: number, min: number, max: number) {
|
|
const number = typeof value === 'number' ? value : Number(value);
|
|
if (!Number.isFinite(number)) return fallback;
|
|
return Math.min(max, Math.max(min, Math.round(number)));
|
|
}
|
|
|
|
function booleanFromUnknown(value: unknown, fallback: boolean) {
|
|
if (value === true || value === 'true') return true;
|
|
if (value === false || value === 'false') return false;
|
|
return fallback;
|
|
}
|
|
|
|
function newLocalId() {
|
|
return typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
|
? crypto.randomUUID()
|
|
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
}
|