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; 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('text_to_video'); const [threadKey, setThreadKey] = useState(0); const [mediaSettings, setMediaSettings] = useState(defaultMediaGenerationSettings); const [mediaRuns, setMediaRuns] = useState(readStoredMediaRuns); const [mediaMessage, setMediaMessage] = useState(''); const [mediaUploadMessage, setMediaUploadMessage] = useState(''); const [mediaUploads, setMediaUploads] = useState([]); const [mediaEstimate, setMediaEstimate] = useState({ 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()); 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; 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 }; 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 : ( 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 (
0}> {props.mode === 'chat' ? ( ) : mediaRuns.length > 0 && mediaComposer ? ( ) : ( <> {mediaMessage &&

{mediaMessage}

} {mediaComposer} )}
setSettingsOpen(false)} onCreateApiKey={() => { setSettingsOpen(false); props.onCreateApiKey(); }} />
); } function PlaygroundSettingsDialog(props: { apiKeySecretsById: Record; apiKeys: GatewayApiKey[]; open: boolean; selectedApiKeyId: string; onApiKeyChange: (apiKeyId: string) => void; onClose: () => void; onCreateApiKey: () => void; }) { const notice = apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById); return ( 完成} open={props.open} title="设置" onClose={props.onClose} onSubmit={(event) => { event.preventDefault(); props.onClose(); }} > {notice && (
{notice}
)}
); } export function PlaygroundEntry(props: { onModeChange: (mode: PlaygroundMode) => void; }) { const [mode, setMode] = useState('chat'); const [prompt, setPrompt] = useState(''); const activeMode = modeOptions.find((item) => item.value === mode) ?? modeOptions[0]; function openPlayground(nextMode = mode) { props.onModeChange(nextMode); } return (

开启你的 即刻测试

{ setMode(nextMode); openPlayground(nextMode); }} onModelChange={() => undefined} onPromptChange={setPrompt} onSubmit={() => openPlayground(mode)} />
{modeOptions.map((item) => ( ))}
); } export function PublicWorksGallery() { return (

Community Gallery

公开作品展示

{publicWorks.map((item) => (
{item.title}
{item.type} {item.title}
))}
); } 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 (
{hasMediaReferencePicker ? ( ) : ( )}
{hasMediaReferencePicker ? ( ) : (