Files
easyai-ai-gateway/apps/web/src/pages/PlaygroundPage.tsx
T
chengcheng 69b0c107d3 fix(web): 修正在线测试模型类型筛选
移除可能将 image_to_video 误判为图像模型的子串回退,在没有匹配模型时显示空状态。

新增图像生成、图像编辑和视频模式回归测试。验证通过:前端 94 项测试、类型检查、lint 和生产构建。
2026-07-21 13:12:40 +08:00

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)}`;
}