feat(web): improve media playground controls
This commit is contained in:
parent
ada765d90e
commit
9f7c9f6581
@ -511,7 +511,20 @@ export async function* streamChatCompletionText(
|
|||||||
|
|
||||||
export async function createImageGenerationTask(
|
export async function createImageGenerationTask(
|
||||||
token: string,
|
token: string,
|
||||||
input: { model: string; prompt: string; size?: string; quality?: string; runMode?: string; simulation?: boolean },
|
input: {
|
||||||
|
model: string;
|
||||||
|
prompt: string;
|
||||||
|
aspect_ratio?: string;
|
||||||
|
count?: number;
|
||||||
|
height?: number;
|
||||||
|
n?: number;
|
||||||
|
quality?: string;
|
||||||
|
resolution?: string;
|
||||||
|
runMode?: string;
|
||||||
|
simulation?: boolean;
|
||||||
|
size?: string;
|
||||||
|
width?: number;
|
||||||
|
},
|
||||||
): Promise<{ task: GatewayTask; next: Record<string, string> }> {
|
): Promise<{ task: GatewayTask; next: Record<string, string> }> {
|
||||||
return request<{ task: GatewayTask; next: Record<string, string> }>('/api/v1/images/generations', {
|
return request<{ task: GatewayTask; next: Record<string, string> }>('/api/v1/images/generations', {
|
||||||
body: input,
|
body: input,
|
||||||
@ -531,6 +544,29 @@ export async function createImageEditTask(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createVideoGenerationTask(
|
||||||
|
token: string,
|
||||||
|
input: {
|
||||||
|
model: string;
|
||||||
|
prompt: string;
|
||||||
|
aspect_ratio?: string;
|
||||||
|
count?: number;
|
||||||
|
height?: number;
|
||||||
|
n?: number;
|
||||||
|
resolution?: string;
|
||||||
|
runMode?: string;
|
||||||
|
simulation?: boolean;
|
||||||
|
size?: string;
|
||||||
|
width?: number;
|
||||||
|
},
|
||||||
|
): Promise<{ task: GatewayTask; next: Record<string, string> }> {
|
||||||
|
return request<{ task: GatewayTask; next: Record<string, string> }>('/api/v1/videos/generations', {
|
||||||
|
body: input,
|
||||||
|
method: 'POST',
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function estimatePricing(
|
export async function estimatePricing(
|
||||||
token: string,
|
token: string,
|
||||||
input: Record<string, unknown>,
|
input: Record<string, unknown>,
|
||||||
@ -546,6 +582,11 @@ export async function getTask(token: string, taskId: string): Promise<GatewayTas
|
|||||||
return request<GatewayTask>(`/api/v1/tasks/${taskId}`, { token });
|
return request<GatewayTask>(`/api/v1/tasks/${taskId}`, { token });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveApiAssetUrl(src: string) {
|
||||||
|
if (/^(https?:|data:|blob:)/i.test(src)) return src;
|
||||||
|
return `${API_BASE}${src.startsWith('/') ? src : `/${src}`}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function listRateLimitWindows(token: string): Promise<ListResponse<RateLimitWindow>> {
|
export async function listRateLimitWindows(token: string): Promise<ListResponse<RateLimitWindow>> {
|
||||||
return request<ListResponse<RateLimitWindow>>('/api/admin/runtime/rate-limit-windows', { token });
|
return request<ListResponse<RateLimitWindow>>('/api/admin/runtime/rate-limit-windows', { token });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||||
import {
|
import {
|
||||||
AssistantRuntimeProvider,
|
AssistantRuntimeProvider,
|
||||||
ComposerPrimitive,
|
ComposerPrimitive,
|
||||||
@ -6,25 +6,51 @@ import {
|
|||||||
ThreadPrimitive,
|
ThreadPrimitive,
|
||||||
useMessagePartText,
|
useMessagePartText,
|
||||||
useLocalRuntime,
|
useLocalRuntime,
|
||||||
|
useThread,
|
||||||
type ChatModelAdapter,
|
type ChatModelAdapter,
|
||||||
type ThreadMessage,
|
type ThreadMessage,
|
||||||
|
type ThreadMessageLike,
|
||||||
} from '@assistant-ui/react';
|
} from '@assistant-ui/react';
|
||||||
import { StreamdownTextPrimitive } from '@assistant-ui/react-streamdown';
|
import { StreamdownTextPrimitive } from '@assistant-ui/react-streamdown';
|
||||||
import { cjk } from '@streamdown/cjk';
|
import { cjk } from '@streamdown/cjk';
|
||||||
import { code } from '@streamdown/code';
|
import { code } from '@streamdown/code';
|
||||||
import { math } from '@streamdown/math';
|
import { math } from '@streamdown/math';
|
||||||
import { mermaid } from '@streamdown/mermaid';
|
import { mermaid } from '@streamdown/mermaid';
|
||||||
import type { GatewayApiKey, PlatformModel } from '@easyai-ai-gateway/contracts';
|
import type { GatewayApiKey, GatewayTask, PlatformModel } from '@easyai-ai-gateway/contracts';
|
||||||
import { Bot, ChevronDown, Image as ImageIcon, MessageSquarePlus, Paperclip, Send, Sparkles, Video } from 'lucide-react';
|
import { Bot, ChevronDown, Image as ImageIcon, MessageSquarePlus, Paperclip, Send, Sparkles, Video } from 'lucide-react';
|
||||||
import { Badge, Button, Select, Textarea } from '../components/ui';
|
import { Badge, Button, Select, Textarea } from '../components/ui';
|
||||||
import { streamChatCompletionText } from '../api';
|
import { createImageGenerationTask, createVideoGenerationTask, getTask, streamChatCompletionText } from '../api';
|
||||||
import type { PlaygroundMode } from '../types';
|
import type { PlaygroundMode } from '../types';
|
||||||
|
import {
|
||||||
|
defaultMediaGenerationSettings,
|
||||||
|
deriveMediaModelCapabilities,
|
||||||
|
mediaRequestPayload,
|
||||||
|
MediaSettingsPopover,
|
||||||
|
MediaTaskBoard,
|
||||||
|
normalizeMediaSettingsForCapabilities,
|
||||||
|
type MediaGenerationRun,
|
||||||
|
type MediaGenerationSettings,
|
||||||
|
type MediaModelCapabilities,
|
||||||
|
} from './playground-media';
|
||||||
|
|
||||||
type VideoCreateMode = 'text_to_video' | 'first_last_frame' | 'omni_reference';
|
type VideoCreateMode = 'text_to_video' | 'first_last_frame' | 'omni_reference';
|
||||||
|
|
||||||
|
const MEDIA_RUNS_STORAGE_KEY = 'easyai:playground:media-runs:v1';
|
||||||
|
const MEDIA_RUNS_STORAGE_LIMIT = 50;
|
||||||
|
const CHAT_MESSAGES_STORAGE_KEY = 'easyai:playground:chat-messages:v1';
|
||||||
|
const CHAT_MESSAGES_STORAGE_LIMIT = 100;
|
||||||
|
|
||||||
|
interface StoredChatMessage {
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
id: string;
|
||||||
|
role: 'assistant' | 'user';
|
||||||
|
}
|
||||||
|
|
||||||
interface ModelOption {
|
interface ModelOption {
|
||||||
count: number;
|
count: number;
|
||||||
label: string;
|
label: string;
|
||||||
|
models: PlatformModel[];
|
||||||
provider: string;
|
provider: string;
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
@ -87,16 +113,195 @@ export function PlaygroundPage(props: {
|
|||||||
const [imageHasReference, setImageHasReference] = useState(false);
|
const [imageHasReference, setImageHasReference] = useState(false);
|
||||||
const [videoMode, setVideoMode] = useState<VideoCreateMode>('text_to_video');
|
const [videoMode, setVideoMode] = useState<VideoCreateMode>('text_to_video');
|
||||||
const [threadKey, setThreadKey] = useState(0);
|
const [threadKey, setThreadKey] = useState(0);
|
||||||
|
const [mediaSettings, setMediaSettings] = useState(defaultMediaGenerationSettings);
|
||||||
|
const [mediaRuns, setMediaRuns] = useState<MediaGenerationRun[]>(readStoredMediaRuns);
|
||||||
|
const [mediaSubmitting, setMediaSubmitting] = useState(false);
|
||||||
|
const [mediaMessage, setMediaMessage] = useState('');
|
||||||
|
const isMountedRef = useRef(false);
|
||||||
|
const resumedTaskIdsRef = useRef(new Set<string>());
|
||||||
const activeMode = useMemo(() => modeOptions.find((item) => item.value === props.mode) ?? modeOptions[0], [props.mode]);
|
const activeMode = useMemo(() => modeOptions.find((item) => item.value === props.mode) ?? modeOptions[0], [props.mode]);
|
||||||
const modelOptions = useMemo(
|
const modelOptions = useMemo(
|
||||||
() => buildModelOptions(filterModelsForMode(props.models, props.mode, imageHasReference, videoMode)),
|
() => buildModelOptions(filterModelsForMode(props.models, props.mode, imageHasReference, videoMode)),
|
||||||
[imageHasReference, props.mode, props.models, videoMode],
|
[imageHasReference, 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
|
||||||
|
: deriveMediaModelCapabilities(activeModelOption?.models, props.mode, videoMode),
|
||||||
|
[activeModelOption, props.mode, videoMode],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedModel((current) => modelOptions.some((item) => item.value === current) ? current : modelOptions[0]?.value ?? '');
|
setSelectedModel((current) => modelOptions.some((item) => item.value === current) ? current : modelOptions[0]?.value ?? '');
|
||||||
}, [modelOptions]);
|
}, [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(() => {
|
||||||
|
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)
|
||||||
|
.then((detail) => {
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
setMediaRuns((current) => updateMediaRun(current, run.localId, {
|
||||||
|
error: detail.error,
|
||||||
|
status: detail.status,
|
||||||
|
task: 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?: {
|
||||||
|
mode?: Exclude<PlaygroundMode, 'chat'>;
|
||||||
|
prompt?: string;
|
||||||
|
settings?: MediaGenerationSettings;
|
||||||
|
}) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if (!selectedModel) {
|
||||||
|
setMediaMessage('当前没有可用模型,请确认用户组权限或平台模型配置。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!trimmedPrompt) {
|
||||||
|
setMediaMessage(runMode === 'video' ? '请输入视频提示词。' : '请输入图片提示词。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localId = newLocalId();
|
||||||
|
const modelLabel = modelOptions.find((item) => item.value === selectedModel)?.label ?? selectedModel;
|
||||||
|
const run: MediaGenerationRun = {
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
localId,
|
||||||
|
mode: runMode,
|
||||||
|
modelLabel,
|
||||||
|
prompt: trimmedPrompt,
|
||||||
|
settings: runSettings,
|
||||||
|
status: 'submitting',
|
||||||
|
};
|
||||||
|
|
||||||
|
setMediaRuns((current) => [...current, run]);
|
||||||
|
setMediaMessage('');
|
||||||
|
setMediaSubmitting(true);
|
||||||
|
try {
|
||||||
|
const requestPayload = {
|
||||||
|
model: selectedModel,
|
||||||
|
prompt: trimmedPrompt,
|
||||||
|
...mediaRequestPayload(runSettings),
|
||||||
|
};
|
||||||
|
const response = runMode === 'video'
|
||||||
|
? await createVideoGenerationTask(credential, requestPayload)
|
||||||
|
: await createImageGenerationTask(credential, requestPayload);
|
||||||
|
setMediaRuns((current) => updateMediaRun(current, localId, { status: response.task.status, task: response.task }));
|
||||||
|
const detail = await pollTaskUntilSettled(credential, response.task);
|
||||||
|
setMediaRuns((current) => updateMediaRun(current, localId, {
|
||||||
|
error: detail.error,
|
||||||
|
status: detail.status,
|
||||||
|
task: detail,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '生成任务提交失败';
|
||||||
|
setMediaMessage(errorMessage);
|
||||||
|
setMediaRuns((current) => updateMediaRun(current, localId, { error: errorMessage, status: 'failed' }));
|
||||||
|
} finally {
|
||||||
|
setMediaSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editMediaRun(run: MediaGenerationRun) {
|
||||||
|
setPrompt(run.prompt);
|
||||||
|
setMediaSettings(run.settings);
|
||||||
|
if (props.mode !== run.mode) {
|
||||||
|
props.onModeChange(run.mode);
|
||||||
|
}
|
||||||
|
setMediaMessage('已带入这条任务的提示词和参数,可调整后再次生成。');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rerunMediaRun(run: MediaGenerationRun) {
|
||||||
|
setPrompt(run.prompt);
|
||||||
|
setMediaSettings(run.settings);
|
||||||
|
if (props.mode !== run.mode) {
|
||||||
|
props.onModeChange(run.mode);
|
||||||
|
setMediaMessage('已切换到对应模式并带入参数,请确认模型后再次生成。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void submitMediaTask({ mode: run.mode, prompt: run.prompt, settings: run.settings });
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaComposer = props.mode === 'chat' ? null : (
|
||||||
|
<Composer
|
||||||
|
apiKeySecretsById={props.apiKeySecretsById}
|
||||||
|
apiKeys={props.apiKeys}
|
||||||
|
isSubmitting={mediaSubmitting}
|
||||||
|
mode={props.mode}
|
||||||
|
modelOptions={modelOptions}
|
||||||
|
prompt={prompt}
|
||||||
|
selectedApiKeyId={activeApiKeyId}
|
||||||
|
selectedModel={selectedModel}
|
||||||
|
imageHasReference={imageHasReference}
|
||||||
|
mediaSettings={mediaSettings}
|
||||||
|
mediaCapabilities={mediaCapabilities}
|
||||||
|
videoMode={videoMode}
|
||||||
|
onApiKeyChange={props.onApiKeyChange}
|
||||||
|
onCreateApiKey={props.onCreateApiKey}
|
||||||
|
onImageReferenceChange={setImageHasReference}
|
||||||
|
onMediaSettingsChange={setMediaSettings}
|
||||||
|
onModeChange={props.onModeChange}
|
||||||
|
onModelChange={setSelectedModel}
|
||||||
|
onPromptChange={setPrompt}
|
||||||
|
onSubmit={() => void submitMediaTask()}
|
||||||
|
onVideoModeChange={setVideoMode}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
function startNewThread() {
|
||||||
|
clearStoredChatMessages();
|
||||||
|
setThreadKey((value) => value + 1);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="playgroundPage">
|
<div className="playgroundPage">
|
||||||
<aside className="playgroundSidebar">
|
<aside className="playgroundSidebar">
|
||||||
@ -104,7 +309,7 @@ export function PlaygroundPage(props: {
|
|||||||
<strong>开启创作</strong>
|
<strong>开启创作</strong>
|
||||||
<Badge variant="secondary">Test</Badge>
|
<Badge variant="secondary">Test</Badge>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="playgroundSideItem active" onClick={() => setThreadKey((value) => value + 1)}>
|
<button type="button" className="playgroundSideItem active" onClick={startNewThread}>
|
||||||
<MessageSquarePlus size={15} />
|
<MessageSquarePlus size={15} />
|
||||||
新对话
|
新对话
|
||||||
</button>
|
</button>
|
||||||
@ -115,7 +320,7 @@ export function PlaygroundPage(props: {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="playgroundStage">
|
<main className="playgroundStage">
|
||||||
<section className="playgroundHero" data-chat={props.mode === 'chat'}>
|
<section className="playgroundHero" data-chat={props.mode === 'chat'} data-media-board={props.mode !== 'chat' && mediaRuns.length > 0}>
|
||||||
{props.mode === 'chat' ? (
|
{props.mode === 'chat' ? (
|
||||||
<AssistantChatPlayground
|
<AssistantChatPlayground
|
||||||
key={threadKey}
|
key={threadKey}
|
||||||
@ -131,29 +336,20 @@ export function PlaygroundPage(props: {
|
|||||||
onModelChange={setSelectedModel}
|
onModelChange={setSelectedModel}
|
||||||
onLogin={props.onLogin}
|
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} />
|
<ModeSwitch activeMode={props.mode} onModeChange={props.onModeChange} />
|
||||||
<PlaygroundGreeting activeMode={activeMode} />
|
<PlaygroundGreeting activeMode={activeMode} />
|
||||||
<Composer
|
{mediaMessage && <p className="playgroundError">{mediaMessage}</p>}
|
||||||
apiKeySecretsById={props.apiKeySecretsById}
|
{mediaComposer}
|
||||||
apiKeys={props.apiKeys}
|
|
||||||
mode={props.mode}
|
|
||||||
modelOptions={modelOptions}
|
|
||||||
prompt={prompt}
|
|
||||||
selectedApiKeyId={props.selectedApiKeyId}
|
|
||||||
selectedModel={selectedModel}
|
|
||||||
imageHasReference={imageHasReference}
|
|
||||||
videoMode={videoMode}
|
|
||||||
onApiKeyChange={props.onApiKeyChange}
|
|
||||||
onCreateApiKey={props.onCreateApiKey}
|
|
||||||
onImageReferenceChange={setImageHasReference}
|
|
||||||
onModeChange={props.onModeChange}
|
|
||||||
onModelChange={setSelectedModel}
|
|
||||||
onPromptChange={setPrompt}
|
|
||||||
onSubmit={() => undefined}
|
|
||||||
onVideoModeChange={setVideoMode}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@ -250,6 +446,7 @@ function AssistantChatPlayground(props: {
|
|||||||
const activeApiKeySecret = activeApiKeyId ? props.apiKeySecretsById[activeApiKeyId] ?? '' : '';
|
const activeApiKeySecret = activeApiKeyId ? props.apiKeySecretsById[activeApiKeyId] ?? '' : '';
|
||||||
const canRun = Boolean(props.token && props.selectedModel && activeApiKeySecret);
|
const canRun = Boolean(props.token && props.selectedModel && activeApiKeySecret);
|
||||||
const apiKeyNotice = apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById);
|
const apiKeyNotice = apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById);
|
||||||
|
const initialMessages = useMemo(() => readStoredChatMessages(), []);
|
||||||
const adapter = useMemo<ChatModelAdapter>(() => ({
|
const adapter = useMemo<ChatModelAdapter>(() => ({
|
||||||
async *run({ abortSignal, messages }) {
|
async *run({ abortSignal, messages }) {
|
||||||
if (!props.token) {
|
if (!props.token) {
|
||||||
@ -282,10 +479,11 @@ function AssistantChatPlayground(props: {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
}), [activeApiKeySecret, props]);
|
}), [activeApiKeySecret, props]);
|
||||||
const runtime = useLocalRuntime(adapter);
|
const runtime = useLocalRuntime(adapter, { initialMessages });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AssistantRuntimeProvider runtime={runtime}>
|
<AssistantRuntimeProvider runtime={runtime}>
|
||||||
|
<AssistantChatPersistenceBridge />
|
||||||
<ThreadPrimitive.Root className="assistantThreadRoot">
|
<ThreadPrimitive.Root className="assistantThreadRoot">
|
||||||
<ThreadPrimitive.Empty>
|
<ThreadPrimitive.Empty>
|
||||||
<div className="assistantEmptyStage">
|
<div className="assistantEmptyStage">
|
||||||
@ -345,6 +543,21 @@ function AssistantChatPlayground(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AssistantChatPersistenceBridge() {
|
||||||
|
const messages = useThread((state) => state.messages);
|
||||||
|
const skipInitialEmptyWriteRef = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (skipInitialEmptyWriteRef.current) {
|
||||||
|
skipInitialEmptyWriteRef.current = false;
|
||||||
|
if (!messages.length && hasStoredChatMessages()) return;
|
||||||
|
}
|
||||||
|
writeStoredChatMessages(messages);
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function ModeSwitch(props: {
|
function ModeSwitch(props: {
|
||||||
activeMode: PlaygroundMode;
|
activeMode: PlaygroundMode;
|
||||||
onModeChange: (mode: PlaygroundMode) => void;
|
onModeChange: (mode: PlaygroundMode) => void;
|
||||||
@ -535,6 +748,90 @@ function threadMessageText(message: ThreadMessage) {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readStoredChatMessages(): ThreadMessageLike[] {
|
||||||
|
if (typeof window === 'undefined') return [];
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(CHAT_MESSAGES_STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
const record = recordFromUnknown(parsed);
|
||||||
|
const source = Array.isArray(parsed) ? parsed : record?.messages;
|
||||||
|
if (!Array.isArray(source)) return [];
|
||||||
|
return source
|
||||||
|
.map(chatMessageLikeFromStorage)
|
||||||
|
.filter((item): item is ThreadMessageLike => Boolean(item))
|
||||||
|
.slice(-CHAT_MESSAGES_STORAGE_LIMIT);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasStoredChatMessages() {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
try {
|
||||||
|
return Boolean(window.localStorage.getItem(CHAT_MESSAGES_STORAGE_KEY));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStoredChatMessages(messages: readonly ThreadMessage[]) {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
const storedMessages = messages
|
||||||
|
.map(storedChatMessageFromThread)
|
||||||
|
.filter((item): item is StoredChatMessage => Boolean(item))
|
||||||
|
.slice(-CHAT_MESSAGES_STORAGE_LIMIT);
|
||||||
|
if (!storedMessages.length) {
|
||||||
|
window.localStorage.removeItem(CHAT_MESSAGES_STORAGE_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.localStorage.setItem(CHAT_MESSAGES_STORAGE_KEY, JSON.stringify({
|
||||||
|
messages: storedMessages,
|
||||||
|
version: 1,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// Best effort only: local chat history should not block sending messages.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStoredChatMessages() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(CHAT_MESSAGES_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatMessageLikeFromStorage(value: unknown): ThreadMessageLike | undefined {
|
||||||
|
const record = recordFromUnknown(value);
|
||||||
|
if (!record) return undefined;
|
||||||
|
const role = record.role === 'assistant' || record.role === 'user' ? record.role : undefined;
|
||||||
|
const content = stringFromUnknown(record.content);
|
||||||
|
if (!role || !content) return undefined;
|
||||||
|
const createdAt = dateStringFromUnknown(record.createdAt);
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
createdAt: createdAt ? new Date(createdAt) : undefined,
|
||||||
|
id: stringFromUnknown(record.id) || undefined,
|
||||||
|
role,
|
||||||
|
status: role === 'assistant' ? { type: 'complete', reason: 'stop' } : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function storedChatMessageFromThread(message: ThreadMessage): StoredChatMessage | undefined {
|
||||||
|
if (message.role !== 'assistant' && message.role !== 'user') return undefined;
|
||||||
|
const content = threadMessageText(message);
|
||||||
|
if (!content) return undefined;
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
createdAt: message.createdAt.toISOString(),
|
||||||
|
id: message.id,
|
||||||
|
role: message.role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function resolveSelectedApiKeyId(apiKeys: GatewayApiKey[], secretsById: Record<string, string>, selectedApiKeyId: string) {
|
function resolveSelectedApiKeyId(apiKeys: GatewayApiKey[], secretsById: Record<string, string>, selectedApiKeyId: string) {
|
||||||
if (selectedApiKeyId && secretsById[selectedApiKeyId]) return selectedApiKeyId;
|
if (selectedApiKeyId && secretsById[selectedApiKeyId]) return selectedApiKeyId;
|
||||||
const firstUsable = apiKeys.find((item) => Boolean(secretsById[item.id]));
|
const firstUsable = apiKeys.find((item) => Boolean(secretsById[item.id]));
|
||||||
@ -561,6 +858,9 @@ function Composer(props: {
|
|||||||
apiKeys?: GatewayApiKey[];
|
apiKeys?: GatewayApiKey[];
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
imageHasReference?: boolean;
|
imageHasReference?: boolean;
|
||||||
|
isSubmitting?: boolean;
|
||||||
|
mediaCapabilities?: MediaModelCapabilities;
|
||||||
|
mediaSettings?: MediaGenerationSettings;
|
||||||
mode: PlaygroundMode;
|
mode: PlaygroundMode;
|
||||||
modelOptions: ModelOption[];
|
modelOptions: ModelOption[];
|
||||||
prompt: string;
|
prompt: string;
|
||||||
@ -570,6 +870,7 @@ function Composer(props: {
|
|||||||
onApiKeyChange?: (apiKeyId: string) => void;
|
onApiKeyChange?: (apiKeyId: string) => void;
|
||||||
onCreateApiKey?: () => void;
|
onCreateApiKey?: () => void;
|
||||||
onImageReferenceChange?: (value: boolean) => void;
|
onImageReferenceChange?: (value: boolean) => void;
|
||||||
|
onMediaSettingsChange?: (settings: MediaGenerationSettings) => void;
|
||||||
onModeChange: (mode: PlaygroundMode) => void;
|
onModeChange: (mode: PlaygroundMode) => void;
|
||||||
onModelChange: (value: string) => void;
|
onModelChange: (value: string) => void;
|
||||||
onPromptChange: (value: string) => void;
|
onPromptChange: (value: string) => void;
|
||||||
@ -611,6 +912,14 @@ function Composer(props: {
|
|||||||
<option value={item.value} key={item.value}>{modelOptionLabel(item)}</option>
|
<option value={item.value} key={item.value}>{modelOptionLabel(item)}</option>
|
||||||
)) : <option value="">模型选择</option>}
|
)) : <option value="">模型选择</option>}
|
||||||
</Select>
|
</Select>
|
||||||
|
{props.mode !== 'chat' && props.mediaSettings && props.onMediaSettingsChange && (
|
||||||
|
<MediaSettingsPopover
|
||||||
|
capabilities={props.mediaCapabilities}
|
||||||
|
mode={props.mode}
|
||||||
|
settings={props.mediaSettings}
|
||||||
|
onChange={props.onMediaSettingsChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{props.apiKeys && props.apiKeySecretsById && props.onApiKeyChange && (
|
{props.apiKeys && props.apiKeySecretsById && props.onApiKeyChange && (
|
||||||
<ApiKeySelect
|
<ApiKeySelect
|
||||||
apiKeySecretsById={props.apiKeySecretsById}
|
apiKeySecretsById={props.apiKeySecretsById}
|
||||||
@ -627,7 +936,7 @@ function Composer(props: {
|
|||||||
<div className="composerQuickPrompts">
|
<div className="composerQuickPrompts">
|
||||||
{quickItems.map((item) => <button type="button" key={item} onClick={() => props.onPromptChange(item)}>{item}</button>)}
|
{quickItems.map((item) => <button type="button" key={item} onClick={() => props.onPromptChange(item)}>{item}</button>)}
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" size="icon" aria-label="发送测试" onClick={props.onSubmit}>
|
<Button type="button" size="icon" aria-label="发送测试" disabled={props.isSubmitting} onClick={props.onSubmit}>
|
||||||
<Send size={15} />
|
<Send size={15} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -691,6 +1000,7 @@ function buildModelOptions(models: PlatformModel[]): ModelOption[] {
|
|||||||
const current = grouped.get(value);
|
const current = grouped.get(value);
|
||||||
if (current) {
|
if (current) {
|
||||||
current.count += 1;
|
current.count += 1;
|
||||||
|
current.models.push(model);
|
||||||
if (!current.provider.includes(model.provider || '')) {
|
if (!current.provider.includes(model.provider || '')) {
|
||||||
current.provider = [current.provider, model.provider].filter(Boolean).join(' / ');
|
current.provider = [current.provider, model.provider].filter(Boolean).join(' / ');
|
||||||
}
|
}
|
||||||
@ -699,6 +1009,7 @@ function buildModelOptions(models: PlatformModel[]): ModelOption[] {
|
|||||||
grouped.set(value, {
|
grouped.set(value, {
|
||||||
count: 1,
|
count: 1,
|
||||||
label: model.modelAlias || model.displayName || model.modelName,
|
label: model.modelAlias || model.displayName || model.modelName,
|
||||||
|
models: [model],
|
||||||
provider: model.provider || model.platformName || '',
|
provider: model.provider || model.platformName || '',
|
||||||
value,
|
value,
|
||||||
});
|
});
|
||||||
@ -711,3 +1022,149 @@ function modelOptionLabel(option: ModelOption) {
|
|||||||
const provider = option.provider ? ` · ${option.provider}` : '';
|
const provider = option.provider ? ` · ${option.provider}` : '';
|
||||||
return `${option.label}${provider}${count}`;
|
return `${option.label}${provider}${count}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateMediaRun(runs: MediaGenerationRun[], localId: string, patch: Partial<MediaGenerationRun>) {
|
||||||
|
return runs.map((run) => run.localId === localId ? { ...run, ...patch } : run);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollTaskUntilSettled(token: string, task: GatewayTask) {
|
||||||
|
let detail = task;
|
||||||
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||||
|
detail = await getTask(token, detail.id);
|
||||||
|
if (!taskIsPending(detail.status)) return detail;
|
||||||
|
await delay(1200);
|
||||||
|
}
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskIsPending(status: string) {
|
||||||
|
return status === 'queued' || status === 'running' || status === 'submitting';
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
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 modelLabel = stringFromUnknown(record.modelLabel) || task?.model || '未知模型';
|
||||||
|
let status: MediaGenerationRun['status'] = stringFromUnknown(record.status) || task?.status || 'failed';
|
||||||
|
let error = stringFromUnknown(record.error);
|
||||||
|
if (status === 'submitting' && !task?.id) {
|
||||||
|
status = 'failed';
|
||||||
|
error = error || '任务提交已中断,请重新生成。';
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
createdAt,
|
||||||
|
error,
|
||||||
|
localId,
|
||||||
|
mode,
|
||||||
|
modelLabel,
|
||||||
|
prompt,
|
||||||
|
settings: mediaSettingsFromStorage(record.settings),
|
||||||
|
status,
|
||||||
|
task,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
height: numberFromUnknown(record.height, fallback.height, 128, 8192),
|
||||||
|
outputMode: record.outputMode === 'group' ? 'group' : 'single',
|
||||||
|
resolution: stringFromUnknown(record.resolution) || fallback.resolution,
|
||||||
|
width: numberFromUnknown(record.width, fallback.width, 128, 8192),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 delay(ms: number) {
|
||||||
|
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function newLocalId() {
|
||||||
|
return typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
}
|
||||||
|
|||||||
818
apps/web/src/pages/playground-media.tsx
Normal file
818
apps/web/src/pages/playground-media.tsx
Normal file
@ -0,0 +1,818 @@
|
|||||||
|
import { useEffect, useRef, type CSSProperties, type ReactNode } from 'react';
|
||||||
|
import type { GatewayTask, PlatformModel } from '@easyai-ai-gateway/contracts';
|
||||||
|
import {
|
||||||
|
Download,
|
||||||
|
Edit3,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Images,
|
||||||
|
Link2,
|
||||||
|
LoaderCircle,
|
||||||
|
Sparkles,
|
||||||
|
Square,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { resolveApiAssetUrl } from '../api';
|
||||||
|
import { Button, Input, Popover, PopoverContent, PopoverTrigger } from '../components/ui';
|
||||||
|
import type { PlaygroundMode } from '../types';
|
||||||
|
|
||||||
|
export type MediaOutputMode = 'single' | 'group';
|
||||||
|
export type MediaCountPreset = 1 | 2 | 3 | 4 | 'custom';
|
||||||
|
export type MediaResolution = string;
|
||||||
|
|
||||||
|
const mediaGridGap = 2;
|
||||||
|
const mediaPreviewMaxHeight = 600;
|
||||||
|
|
||||||
|
export interface MediaGenerationSettings {
|
||||||
|
aspectRatio: string;
|
||||||
|
countPreset: MediaCountPreset;
|
||||||
|
customCount: number;
|
||||||
|
height: number;
|
||||||
|
outputMode: MediaOutputMode;
|
||||||
|
resolution: MediaResolution;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaGenerationRun {
|
||||||
|
createdAt: string;
|
||||||
|
error?: string;
|
||||||
|
localId: string;
|
||||||
|
mode: Exclude<PlaygroundMode, 'chat'>;
|
||||||
|
modelLabel: string;
|
||||||
|
prompt: string;
|
||||||
|
settings: MediaGenerationSettings;
|
||||||
|
status: GatewayTask['status'] | 'submitting';
|
||||||
|
task?: GatewayTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AspectRatioOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
visual: 'smart' | 'wide' | 'landscape' | 'square' | 'portrait' | 'tall';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolutionOption {
|
||||||
|
label: string;
|
||||||
|
modes: Array<Exclude<PlaygroundMode, 'chat'>>;
|
||||||
|
value: MediaResolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaModelCapabilities {
|
||||||
|
aspectRatios: string[];
|
||||||
|
maxCount: number;
|
||||||
|
resolutions: MediaResolution[];
|
||||||
|
supportsGroup: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aspectRatioOptions: AspectRatioOption[] = [
|
||||||
|
{ value: 'auto', label: '智能', visual: 'smart' },
|
||||||
|
{ value: '8:1', label: '8:1', visual: 'wide' },
|
||||||
|
{ value: '4:1', label: '4:1', visual: 'wide' },
|
||||||
|
{ value: '21:9', label: '21:9', visual: 'wide' },
|
||||||
|
{ value: '16:9', label: '16:9', visual: 'wide' },
|
||||||
|
{ value: '3:2', label: '3:2', visual: 'landscape' },
|
||||||
|
{ value: '5:4', label: '5:4', visual: 'landscape' },
|
||||||
|
{ value: '4:3', label: '4:3', visual: 'landscape' },
|
||||||
|
{ value: '1:1', label: '1:1', visual: 'square' },
|
||||||
|
{ value: '4:5', label: '4:5', visual: 'portrait' },
|
||||||
|
{ value: '3:4', label: '3:4', visual: 'portrait' },
|
||||||
|
{ value: '2:3', label: '2:3', visual: 'portrait' },
|
||||||
|
{ value: '9:16', label: '9:16', visual: 'tall' },
|
||||||
|
{ value: '1:4', label: '1:4', visual: 'tall' },
|
||||||
|
{ value: '1:8', label: '1:8', visual: 'tall' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const resolutionOptions: ResolutionOption[] = [
|
||||||
|
{ value: '1K', label: '标准 1K', modes: ['image'] },
|
||||||
|
{ value: '2K', label: '高清 2K', modes: ['image'] },
|
||||||
|
{ value: '4K', label: '超清 4K', modes: ['image', 'video'] },
|
||||||
|
{ value: '480p', label: '标清 480p', modes: ['video'] },
|
||||||
|
{ value: '720p', label: '高清 720p', modes: ['video'] },
|
||||||
|
{ value: '1080p', label: '全高清 1080p', modes: ['video'] },
|
||||||
|
{ value: '2160p', label: '超清 2160p', modes: ['video'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const countPresetOptions: Array<{ label: string; value: MediaCountPreset }> = [
|
||||||
|
{ value: 1, label: '1' },
|
||||||
|
{ value: 2, label: '2' },
|
||||||
|
{ value: 3, label: '3' },
|
||||||
|
{ value: 4, label: '4' },
|
||||||
|
{ value: 'custom', label: '自定义' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function defaultMediaGenerationSettings(): MediaGenerationSettings {
|
||||||
|
return {
|
||||||
|
aspectRatio: '1:1',
|
||||||
|
countPreset: 1,
|
||||||
|
customCount: 6,
|
||||||
|
height: 2048,
|
||||||
|
outputMode: 'single',
|
||||||
|
resolution: '2K',
|
||||||
|
width: 2048,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaOutputCount(settings: MediaGenerationSettings) {
|
||||||
|
if (settings.outputMode === 'single') return 1;
|
||||||
|
const raw = settings.countPreset === 'custom' ? settings.customCount : settings.countPreset;
|
||||||
|
return clampNumber(raw, 1, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaRequestPayload(settings: MediaGenerationSettings) {
|
||||||
|
const count = mediaOutputCount(settings);
|
||||||
|
const size = `${settings.width}x${settings.height}`;
|
||||||
|
const highQuality = settings.resolution === '4K' || settings.resolution === '2160p';
|
||||||
|
return {
|
||||||
|
aspect_ratio: settings.aspectRatio === 'auto' ? undefined : settings.aspectRatio,
|
||||||
|
count,
|
||||||
|
height: settings.height,
|
||||||
|
n: count,
|
||||||
|
quality: highQuality ? 'high' : 'medium',
|
||||||
|
resolution: settings.resolution,
|
||||||
|
size,
|
||||||
|
width: settings.width,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveMediaModelCapabilities(
|
||||||
|
models: PlatformModel[] | PlatformModel | undefined,
|
||||||
|
mode: Exclude<PlaygroundMode, 'chat'>,
|
||||||
|
contextKey?: string,
|
||||||
|
): MediaModelCapabilities {
|
||||||
|
const modelList = (Array.isArray(models) ? models : models ? [models] : []).filter(Boolean);
|
||||||
|
if (!modelList.length) return defaultMediaModelCapabilities(mode);
|
||||||
|
const derived = modelList.map((model) => deriveSingleMediaModelCapabilities(model, mode, contextKey));
|
||||||
|
return {
|
||||||
|
aspectRatios: intersectOptionValues(derived.map((item) => item.aspectRatios), aspectRatioOptions.map((item) => item.value)),
|
||||||
|
maxCount: Math.max(1, Math.min(...derived.map((item) => item.maxCount))),
|
||||||
|
resolutions: intersectOptionValues(derived.map((item) => item.resolutions), resolutionOptionsForMode(mode).map((item) => item.value)),
|
||||||
|
supportsGroup: derived.every((item) => item.supportsGroup),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMediaSettingsForCapabilities(
|
||||||
|
settings: MediaGenerationSettings,
|
||||||
|
capabilities: MediaModelCapabilities,
|
||||||
|
mode: Exclude<PlaygroundMode, 'chat'>,
|
||||||
|
) {
|
||||||
|
const aspectOptions = filterAspectRatioOptions(capabilities);
|
||||||
|
const resolutionItems = filterResolutionOptions(capabilities, mode);
|
||||||
|
const maxCount = Math.max(1, Math.min(capabilities.maxCount, 20));
|
||||||
|
const supportsGroup = capabilities.supportsGroup && maxCount > 1;
|
||||||
|
const next: MediaGenerationSettings = {
|
||||||
|
...settings,
|
||||||
|
aspectRatio: aspectOptions.some((item) => item.value === settings.aspectRatio) ? settings.aspectRatio : aspectOptions[0]?.value ?? 'auto',
|
||||||
|
resolution: resolutionItems.some((item) => item.value === settings.resolution) ? settings.resolution : resolutionItems[0]?.value ?? settings.resolution,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!supportsGroup) {
|
||||||
|
next.countPreset = 1;
|
||||||
|
next.customCount = 1;
|
||||||
|
next.outputMode = 'single';
|
||||||
|
} else if (next.outputMode === 'group') {
|
||||||
|
if (next.countPreset === 'custom') {
|
||||||
|
next.customCount = clampNumber(next.customCount, 2, maxCount);
|
||||||
|
} else if (next.countPreset > maxCount) {
|
||||||
|
next.countPreset = maxCount <= 4 ? (maxCount as MediaCountPreset) : 'custom';
|
||||||
|
next.customCount = maxCount;
|
||||||
|
} else if (next.countPreset === 1) {
|
||||||
|
next.outputMode = 'single';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
next.countPreset = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mediaSettingsEqual(settings, next) ? settings : next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MediaSettingsPopover(props: {
|
||||||
|
capabilities?: MediaModelCapabilities;
|
||||||
|
mode: Exclude<PlaygroundMode, 'chat'>;
|
||||||
|
settings: MediaGenerationSettings;
|
||||||
|
onChange: (settings: MediaGenerationSettings) => void;
|
||||||
|
}) {
|
||||||
|
const capabilities = props.capabilities ?? defaultMediaModelCapabilities(props.mode);
|
||||||
|
const aspectOptions = filterAspectRatioOptions(capabilities);
|
||||||
|
const resolutionItems = resolutionOptionsForMode(props.mode);
|
||||||
|
const enabledResolutions = new Set(filterResolutionOptions(capabilities, props.mode).map((item) => item.value));
|
||||||
|
const maxCount = Math.max(1, Math.min(capabilities.maxCount, 20));
|
||||||
|
const supportsGroup = capabilities.supportsGroup && maxCount > 1;
|
||||||
|
const countOptions = countPresetOptions.filter((item) => item.value === 'custom' ? maxCount > 4 : item.value <= Math.min(4, maxCount));
|
||||||
|
const count = mediaOutputCount(props.settings);
|
||||||
|
const unit = props.mode === 'video' ? '条' : '张';
|
||||||
|
|
||||||
|
function patch(next: Partial<MediaGenerationSettings>) {
|
||||||
|
props.onChange(normalizeMediaSettingsForCapabilities({ ...props.settings, ...next }, capabilities, props.mode));
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCountPreset(value: MediaCountPreset) {
|
||||||
|
if (value !== 1 && !supportsGroup) return;
|
||||||
|
patch({
|
||||||
|
countPreset: value,
|
||||||
|
outputMode: value === 1 ? 'single' : 'group',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button type="button" className="mediaSettingsTrigger">
|
||||||
|
<Square size={15} />
|
||||||
|
<span>{mediaSettingsSummary(props.settings, props.mode)}</span>
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="start" className="mediaSettingsPanel" side="top" sideOffset={10}>
|
||||||
|
<section className="mediaSettingsSection">
|
||||||
|
<span className="mediaSettingsLabel">选择比例</span>
|
||||||
|
<div className="mediaAspectGrid">
|
||||||
|
{aspectOptions.map((item) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={item.value}
|
||||||
|
data-active={props.settings.aspectRatio === item.value}
|
||||||
|
onClick={() => patch({ aspectRatio: item.value })}
|
||||||
|
>
|
||||||
|
<span className="mediaAspectIcon" data-visual={item.visual}>
|
||||||
|
{item.visual === 'smart' && <Sparkles size={16} />}
|
||||||
|
</span>
|
||||||
|
<strong>{item.label}</strong>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mediaSettingsSection">
|
||||||
|
<span className="mediaSettingsLabel">选择分辨率</span>
|
||||||
|
<div className="mediaResolutionSegment">
|
||||||
|
{resolutionItems.map((item) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={item.value}
|
||||||
|
data-active={props.settings.resolution === item.value}
|
||||||
|
disabled={!enabledResolutions.has(item.value)}
|
||||||
|
onClick={() => enabledResolutions.has(item.value) && patch({ resolution: item.value })}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
{item.value === '4K' && <Sparkles size={15} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mediaSettingsSection">
|
||||||
|
<span className="mediaSettingsLabel">尺寸</span>
|
||||||
|
<div className="mediaSizeRow">
|
||||||
|
<label>
|
||||||
|
<span>W</span>
|
||||||
|
<Input
|
||||||
|
inputMode="numeric"
|
||||||
|
min={128}
|
||||||
|
size="sm"
|
||||||
|
type="number"
|
||||||
|
value={props.settings.width}
|
||||||
|
onChange={(event) => patch({ width: clampNumber(Number(event.target.value), 128, 8192) })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Link2 size={18} />
|
||||||
|
<label>
|
||||||
|
<span>H</span>
|
||||||
|
<Input
|
||||||
|
inputMode="numeric"
|
||||||
|
min={128}
|
||||||
|
size="sm"
|
||||||
|
type="number"
|
||||||
|
value={props.settings.height}
|
||||||
|
onChange={(event) => patch({ height: clampNumber(Number(event.target.value), 128, 8192) })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<strong>PX</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mediaSettingsSection">
|
||||||
|
<span className="mediaSettingsLabel">生成数量</span>
|
||||||
|
<div className="mediaOutputModeSegment">
|
||||||
|
<button type="button" data-active={props.settings.outputMode === 'single'} onClick={() => patch({ countPreset: 1, outputMode: 'single' })}>
|
||||||
|
<ImageIcon size={15} />
|
||||||
|
单图
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={!supportsGroup} data-active={props.settings.outputMode === 'group'} onClick={() => patch({ countPreset: 2, outputMode: 'group' })}>
|
||||||
|
<Images size={15} />
|
||||||
|
{supportsGroup ? '组图' : '不支持组图'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{supportsGroup ? (
|
||||||
|
<>
|
||||||
|
<div className="mediaCountGrid">
|
||||||
|
{countOptions.map((item) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={String(item.value)}
|
||||||
|
data-active={props.settings.countPreset === item.value}
|
||||||
|
onClick={() => selectCountPreset(item.value)}
|
||||||
|
>
|
||||||
|
{item.value === 'custom' ? item.label : `${item.label}${unit}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{props.settings.countPreset === 'custom' && props.settings.outputMode === 'group' && (
|
||||||
|
<label className="mediaCustomCount">
|
||||||
|
<span>自定义数量</span>
|
||||||
|
<Input
|
||||||
|
inputMode="numeric"
|
||||||
|
min={2}
|
||||||
|
max={maxCount}
|
||||||
|
size="sm"
|
||||||
|
type="number"
|
||||||
|
value={props.settings.customCount}
|
||||||
|
onChange={(event) => patch({ customCount: clampNumber(Number(event.target.value), 2, maxCount) })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="mediaUnsupportedNote">当前模型不支持组图输出。</p>
|
||||||
|
)}
|
||||||
|
<p className="mediaSettingsHint">
|
||||||
|
<Sparkles size={14} />
|
||||||
|
{count} / {unit}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MediaTaskBoard(props: {
|
||||||
|
composer: ReactNode;
|
||||||
|
message?: string;
|
||||||
|
onEditRun?: (run: MediaGenerationRun) => void;
|
||||||
|
onRerun?: (run: MediaGenerationRun) => void;
|
||||||
|
runs: MediaGenerationRun[];
|
||||||
|
}) {
|
||||||
|
const timelineRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeline = timelineRef.current;
|
||||||
|
if (!timeline) return;
|
||||||
|
timeline.scrollTo({ behavior: 'smooth', top: timeline.scrollHeight });
|
||||||
|
}, [props.runs.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mediaTaskPage">
|
||||||
|
<section ref={timelineRef} className="mediaTaskTimeline" aria-label="生成任务列表">
|
||||||
|
<h1>今天</h1>
|
||||||
|
{props.message && <p className="playgroundError">{props.message}</p>}
|
||||||
|
{props.runs.map((run) => (
|
||||||
|
<MediaTaskCard
|
||||||
|
key={run.localId}
|
||||||
|
run={run}
|
||||||
|
onEditRun={props.onEditRun}
|
||||||
|
onRerun={props.onRerun}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
<div className="mediaComposerDock">
|
||||||
|
{props.composer}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaTaskCard(props: {
|
||||||
|
run: MediaGenerationRun;
|
||||||
|
onEditRun?: (run: MediaGenerationRun) => void;
|
||||||
|
onRerun?: (run: MediaGenerationRun) => void;
|
||||||
|
}) {
|
||||||
|
const items = mediaResultItems(props.run);
|
||||||
|
const expectedCount = Math.max(mediaOutputCount(props.run.settings), items.length, 1);
|
||||||
|
const columns = expectedCount === 1 ? 1 : expectedCount === 2 ? 2 : Math.min(5, expectedCount);
|
||||||
|
const style = {
|
||||||
|
'--media-grid-columns': columns,
|
||||||
|
'--media-grid-max-width': mediaGridMaxWidth(props.run.settings, columns),
|
||||||
|
'--media-result-aspect': cssAspectRatio(props.run.settings),
|
||||||
|
} as CSSProperties;
|
||||||
|
const status = mediaStatusText(props.run);
|
||||||
|
const unit = props.run.mode === 'video' ? '条' : '张';
|
||||||
|
const isPending = props.run.status === 'submitting' || props.run.status === 'queued' || props.run.status === 'running';
|
||||||
|
const backdropItem = expectedCount === 1 && items[0]?.type === 'image' ? items[0] : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="mediaTaskItem" data-status={props.run.status}>
|
||||||
|
<header className="mediaTaskHeader">
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
<span>{props.run.prompt}</span>
|
||||||
|
<small>{props.run.mode === 'video' ? '视频' : '图片'} {props.run.modelLabel} {props.run.settings.aspectRatio} {props.run.settings.resolution}</small>
|
||||||
|
</p>
|
||||||
|
<time dateTime={props.run.createdAt}>{formatRunTime(props.run.createdAt)}</time>
|
||||||
|
</div>
|
||||||
|
<span className="mediaTaskStatus" data-status={props.run.status}>{status}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mediaPreviewStage" data-count={expectedCount}>
|
||||||
|
{backdropItem && <img aria-hidden="true" className="mediaPreviewBackdrop" src={backdropItem.src} alt="" />}
|
||||||
|
<div className="mediaGrid" data-count={expectedCount} style={style}>
|
||||||
|
{Array.from({ length: expectedCount }).map((_, index) => (
|
||||||
|
<MediaTile
|
||||||
|
expectedCount={expectedCount}
|
||||||
|
index={index}
|
||||||
|
item={items[index]}
|
||||||
|
key={`${props.run.localId}-${index}`}
|
||||||
|
mode={props.run.mode}
|
||||||
|
status={props.run.status}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{props.run.error && <p className="mediaTaskError">{props.run.error}</p>}
|
||||||
|
<footer className="mediaTaskActions">
|
||||||
|
{items[0] ? (
|
||||||
|
<Button asChild size="sm" variant="secondary">
|
||||||
|
<a href={items[0].src} download target="_blank" rel="noreferrer">
|
||||||
|
<Download size={14} />
|
||||||
|
下载
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button type="button" size="sm" variant="secondary" disabled>
|
||||||
|
<Download size={14} />
|
||||||
|
下载
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button type="button" size="sm" variant="secondary" onClick={() => props.onEditRun?.(props.run)}>
|
||||||
|
<Edit3 size={14} />
|
||||||
|
重新编辑
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" variant="secondary" disabled={isPending} onClick={() => props.onRerun?.(props.run)}>
|
||||||
|
<Sparkles size={14} />
|
||||||
|
再次生成
|
||||||
|
</Button>
|
||||||
|
<span>
|
||||||
|
<Sparkles size={14} />
|
||||||
|
{expectedCount} / {unit}
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaTile(props: {
|
||||||
|
expectedCount: number;
|
||||||
|
index: number;
|
||||||
|
item?: MediaResultItem;
|
||||||
|
mode: Exclude<PlaygroundMode, 'chat'>;
|
||||||
|
status: MediaGenerationRun['status'];
|
||||||
|
}) {
|
||||||
|
const isLoading = props.status === 'submitting' || props.status === 'queued' || props.status === 'running';
|
||||||
|
const isFailed = props.status === 'failed' || props.status === 'cancelled';
|
||||||
|
return (
|
||||||
|
<div className="mediaTile" data-count={props.expectedCount} data-empty={!props.item && !isLoading} data-kind={props.mode}>
|
||||||
|
{props.item?.type === 'video' && (
|
||||||
|
<video controls muted playsInline poster={props.item.poster}>
|
||||||
|
<source src={props.item.src} />
|
||||||
|
</video>
|
||||||
|
)}
|
||||||
|
{props.item?.type === 'image' && <img src={props.item.src} alt={`生成结果 ${props.index + 1}`} />}
|
||||||
|
{isLoading && !props.item && (
|
||||||
|
<div className="mediaLoading">
|
||||||
|
<LoaderCircle size={24} />
|
||||||
|
<span>{props.mode === 'video' ? '视频生成中' : '图片生成中'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isFailed && !props.item && (
|
||||||
|
<div className="mediaEmptyTile">
|
||||||
|
<span>生成失败</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && !isFailed && !props.item && (
|
||||||
|
<div className="mediaEmptyTile">
|
||||||
|
<span>暂无结果</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaResultItems(run: MediaGenerationRun): MediaResultItem[] {
|
||||||
|
const data = arrayFromUnknown(run.task?.result?.data);
|
||||||
|
return data
|
||||||
|
.map((entry) => mediaResultItemFromEntry(entry, run.mode))
|
||||||
|
.filter((item): item is MediaResultItem => Boolean(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaResultItemFromEntry(entry: unknown, mode: Exclude<PlaygroundMode, 'chat'>): MediaResultItem | undefined {
|
||||||
|
const record = recordFromUnknown(entry);
|
||||||
|
if (!record) return undefined;
|
||||||
|
const b64 = stringFromUnknown(record.b64_json);
|
||||||
|
if (b64) return { src: `data:image/png;base64,${b64}`, type: 'image' };
|
||||||
|
const videoUrl = firstString(record.video_url, nestedString(record.video_url, 'url'), record.videoUrl, record.output_video_url);
|
||||||
|
if (videoUrl) return { src: resolveApiAssetUrl(videoUrl), type: 'video' };
|
||||||
|
const imageUrl = firstString(record.url, record.image_url, nestedString(record.image_url, 'url'), record.output_url);
|
||||||
|
if (!imageUrl) return undefined;
|
||||||
|
const normalized = resolveApiAssetUrl(imageUrl);
|
||||||
|
const type = mode === 'video' || /\.(mp4|mov|webm|m4v)(\?|#|$)/i.test(normalized) ? 'video' : 'image';
|
||||||
|
return { src: normalized, type };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaSettingsSummary(settings: MediaGenerationSettings, mode: Exclude<PlaygroundMode, 'chat'>) {
|
||||||
|
const count = mediaOutputCount(settings);
|
||||||
|
const unit = mode === 'video' ? '条' : '张';
|
||||||
|
const resolutionLabel = resolutionOptionsForMode(mode).find((item) => item.value === settings.resolution)?.label ?? settings.resolution;
|
||||||
|
const modeLabel = settings.outputMode === 'single' ? '单图' : '组图';
|
||||||
|
return `${settings.aspectRatio} | ${resolutionLabel} | ${settings.width}x${settings.height} | ${modeLabel} ${count}${unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaStatusText(run: MediaGenerationRun) {
|
||||||
|
if (run.status === 'submitting') return '提交中';
|
||||||
|
if (run.status === 'queued') return '排队中';
|
||||||
|
if (run.status === 'running') return '生成中';
|
||||||
|
if (run.status === 'succeeded') return '已完成';
|
||||||
|
if (run.status === 'failed') return '失败';
|
||||||
|
if (run.status === 'cancelled') return '已取消';
|
||||||
|
return run.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRunTime(value: string) {
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssAspectRatio(settings: MediaGenerationSettings) {
|
||||||
|
const [aspectWidth, aspectHeight] = settings.aspectRatio.split(':').map((item) => Number(item));
|
||||||
|
if (Number.isFinite(aspectWidth) && Number.isFinite(aspectHeight) && aspectWidth > 0 && aspectHeight > 0) {
|
||||||
|
return `${aspectWidth} / ${aspectHeight}`;
|
||||||
|
}
|
||||||
|
return `${settings.width || 1} / ${settings.height || 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaGridMaxWidth(settings: MediaGenerationSettings, columns: number) {
|
||||||
|
const ratio = numericAspectRatio(settings);
|
||||||
|
const safeColumns = Math.max(1, columns);
|
||||||
|
const maxWidth = (mediaPreviewMaxHeight * ratio * safeColumns) + (mediaGridGap * (safeColumns - 1));
|
||||||
|
return `${Math.max(120, Math.ceil(maxWidth))}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numericAspectRatio(settings: MediaGenerationSettings) {
|
||||||
|
const [aspectWidth, aspectHeight] = settings.aspectRatio.split(':').map((item) => Number(item));
|
||||||
|
if (Number.isFinite(aspectWidth) && Number.isFinite(aspectHeight) && aspectWidth > 0 && aspectHeight > 0) {
|
||||||
|
return aspectWidth / aspectHeight;
|
||||||
|
}
|
||||||
|
const width = Number(settings.width);
|
||||||
|
const height = Number(settings.height);
|
||||||
|
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
|
||||||
|
return width / height;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveSingleMediaModelCapabilities(
|
||||||
|
model: PlatformModel,
|
||||||
|
mode: Exclude<PlaygroundMode, 'chat'>,
|
||||||
|
contextKey?: string,
|
||||||
|
): MediaModelCapabilities {
|
||||||
|
const source = mergeCapabilityRecords(model.capabilities, model.capabilityOverride);
|
||||||
|
const typeKeys = capabilityTypeKeys(model, source, mode, contextKey);
|
||||||
|
const defaultCapabilities = defaultMediaModelCapabilities(mode);
|
||||||
|
const resolutionValues = normalizeResolutionValues(stringListFromCapability(firstCapabilityValue(source, typeKeys, ['output_resolutions']), [contextKey]));
|
||||||
|
const allowedAspectValues = normalizeAspectRatioValues(stringListFromCapability(firstCapabilityValue(source, typeKeys, ['aspect_ratio_allowed']), [contextKey]));
|
||||||
|
const ratioRange = ratioRangeFromValue(firstCapabilityValue(source, typeKeys, ['aspect_ratio_range']));
|
||||||
|
const rangedAspectValues = ratioRange ? aspectRatioOptions
|
||||||
|
.filter((item) => item.value === 'auto' || aspectRatioWithinRange(item.value, ratioRange))
|
||||||
|
.map((item) => item.value) : [];
|
||||||
|
const aspectRatios = allowedAspectValues.length && rangedAspectValues.length
|
||||||
|
? allowedAspectValues.filter((item) => rangedAspectValues.includes(item))
|
||||||
|
: allowedAspectValues.length ? allowedAspectValues : rangedAspectValues;
|
||||||
|
const maxCountValue = numberFromUnknown(firstCapabilityValue(source, typeKeys, countCapabilityKeys(mode)));
|
||||||
|
const explicitGroupSupport = boolFromUnknown(firstCapabilityValue(source, typeKeys, groupCapabilityKeys(mode)));
|
||||||
|
const maxCount = explicitGroupSupport === false ? 1 : clampNumber(maxCountValue ?? defaultCapabilities.maxCount, 1, 20);
|
||||||
|
const supportsGroup = explicitGroupSupport === false ? false : maxCount > 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
aspectRatios: aspectRatios.length ? aspectRatios : defaultCapabilities.aspectRatios,
|
||||||
|
maxCount,
|
||||||
|
resolutions: resolutionValues.length ? resolutionValues : defaultCapabilities.resolutions,
|
||||||
|
supportsGroup,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultMediaModelCapabilities(mode: Exclude<PlaygroundMode, 'chat'>): MediaModelCapabilities {
|
||||||
|
return {
|
||||||
|
aspectRatios: aspectRatioOptions.map((item) => item.value),
|
||||||
|
maxCount: 20,
|
||||||
|
resolutions: resolutionOptionsForMode(mode).map((item) => item.value),
|
||||||
|
supportsGroup: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterAspectRatioOptions(capabilities: MediaModelCapabilities) {
|
||||||
|
const allowed = new Set(capabilities.aspectRatios);
|
||||||
|
const items = aspectRatioOptions.filter((item) => allowed.has(item.value));
|
||||||
|
return items.length ? items : aspectRatioOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterResolutionOptions(capabilities: MediaModelCapabilities, mode: Exclude<PlaygroundMode, 'chat'>) {
|
||||||
|
const allowed = new Set(capabilities.resolutions);
|
||||||
|
const modeOptions = resolutionOptionsForMode(mode);
|
||||||
|
const items = modeOptions.filter((item) => allowed.has(item.value));
|
||||||
|
return items.length ? items : modeOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolutionOptionsForMode(mode: Exclude<PlaygroundMode, 'chat'>) {
|
||||||
|
return resolutionOptions.filter((item) => item.modes.includes(mode));
|
||||||
|
}
|
||||||
|
|
||||||
|
function intersectOptionValues(values: string[][], fallback: string[]) {
|
||||||
|
const nonEmptyValues = values.filter((items) => items.length > 0);
|
||||||
|
if (!nonEmptyValues.length) return fallback;
|
||||||
|
const intersection = fallback.filter((item) => nonEmptyValues.every((items) => items.includes(item)));
|
||||||
|
return intersection.length ? intersection : nonEmptyValues[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function capabilityTypeKeys(
|
||||||
|
model: PlatformModel,
|
||||||
|
source: Record<string, unknown>,
|
||||||
|
mode: Exclude<PlaygroundMode, 'chat'>,
|
||||||
|
contextKey?: string,
|
||||||
|
) {
|
||||||
|
const modeTypeHints = mode === 'image'
|
||||||
|
? ['image_generate', 'image_edit', 'images.generations', 'images.edits', 'image']
|
||||||
|
: [contextKey, 'text_to_video', 'image_to_video', 'omni_video', 'video_generate', 'video'];
|
||||||
|
return uniqueStrings([
|
||||||
|
...stringListFromCapability(source.originalTypes),
|
||||||
|
model.modelType,
|
||||||
|
...modeTypeHints.filter((item): item is string => Boolean(item)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstCapabilityValue(source: Record<string, unknown>, typeKeys: string[], keys: string[]) {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = nestedCapabilityValue(source, typeKeys, key);
|
||||||
|
if (hasCapabilityValue(value)) return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nestedCapabilityValue(source: Record<string, unknown>, typeKeys: string[], key: string) {
|
||||||
|
for (const type of typeKeys) {
|
||||||
|
const config = recordFromUnknown(source[type]);
|
||||||
|
if (config && key in config) return config[key];
|
||||||
|
}
|
||||||
|
if (key in source) return source[key];
|
||||||
|
for (const value of Object.values(source)) {
|
||||||
|
const config = recordFromUnknown(value);
|
||||||
|
if (config && key in config) return config[key];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupCapabilityKeys(mode: Exclude<PlaygroundMode, 'chat'>) {
|
||||||
|
return mode === 'image'
|
||||||
|
? ['output_multiple_images', 'multiple_images', 'support_multiple_images', 'supports_group']
|
||||||
|
: ['output_multiple_videos', 'multiple_videos', 'support_multiple_videos', 'supports_group'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function countCapabilityKeys(mode: Exclude<PlaygroundMode, 'chat'>) {
|
||||||
|
return mode === 'image'
|
||||||
|
? ['output_max_images_count', 'max_images', 'max_outputs', 'output_max_count']
|
||||||
|
: ['output_max_videos_count', 'max_videos', 'max_outputs', 'output_max_count'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeCapabilityRecords(...records: Array<Record<string, unknown> | undefined>) {
|
||||||
|
return records.reduce<Record<string, unknown>>((acc, record) => {
|
||||||
|
if (!record) return acc;
|
||||||
|
Object.entries(record).forEach(([key, value]) => {
|
||||||
|
const current = recordFromUnknown(acc[key]);
|
||||||
|
const next = recordFromUnknown(value);
|
||||||
|
acc[key] = current && next ? { ...current, ...next } : value;
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringListFromCapability(value: unknown, preferredKeys: Array<string | undefined> = []): string[] {
|
||||||
|
if (Array.isArray(value)) return value.map(String).map((item) => item.trim()).filter(Boolean);
|
||||||
|
if (typeof value === 'string') return value.split(/[,,\s]+/).map((item) => item.trim()).filter(Boolean);
|
||||||
|
const record = recordFromUnknown(value);
|
||||||
|
if (!record) return [];
|
||||||
|
const preferred = preferredKeys
|
||||||
|
.filter((key): key is string => Boolean(key))
|
||||||
|
.flatMap((key) => stringListFromCapability(record[key]));
|
||||||
|
if (preferred.length) return preferred;
|
||||||
|
return Object.values(record).flatMap((item) => stringListFromCapability(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAspectRatioValues(values: string[]) {
|
||||||
|
const allowedValues = new Set(aspectRatioOptions.map((item) => item.value));
|
||||||
|
return uniqueStrings(values.map((value) => {
|
||||||
|
const normalized = value.toLowerCase().replace(/[×x]/g, ':').replace(/\s+/g, '');
|
||||||
|
if (['auto', 'smart', 'adaptive', '智能'].includes(normalized)) return 'auto';
|
||||||
|
return normalized;
|
||||||
|
})).filter((value) => allowedValues.has(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeResolutionValues(values: string[]) {
|
||||||
|
const allowedValues = new Set(resolutionOptions.map((item) => item.value));
|
||||||
|
return uniqueStrings(values.map((value) => {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (normalized.includes('2160')) return '2160p';
|
||||||
|
if (normalized.includes('1080')) return '1080p';
|
||||||
|
if (normalized.includes('720')) return '720p';
|
||||||
|
if (normalized.includes('480')) return '480p';
|
||||||
|
if (normalized.includes('4k') || normalized.includes('4096')) return '4K';
|
||||||
|
if (normalized.includes('2k') || normalized.includes('2048')) return '2K';
|
||||||
|
if (normalized.includes('1k') || normalized.includes('1024')) return '1K';
|
||||||
|
return value.trim();
|
||||||
|
})).filter((value) => allowedValues.has(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratioRangeFromValue(value: unknown): [number, number] | undefined {
|
||||||
|
if (Array.isArray(value) && value.length >= 2) {
|
||||||
|
const min = Number(value[0]);
|
||||||
|
const max = Number(value[1]);
|
||||||
|
if (Number.isFinite(min) && Number.isFinite(max)) return [Math.min(min, max), Math.max(min, max)];
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const numbers = value.match(/-?\d+(?:\.\d+)?/g)?.map(Number) ?? [];
|
||||||
|
if (numbers.length >= 2 && numbers.every(Number.isFinite)) return [Math.min(numbers[0], numbers[1]), Math.max(numbers[0], numbers[1])];
|
||||||
|
}
|
||||||
|
const record = recordFromUnknown(value);
|
||||||
|
if (!record) return undefined;
|
||||||
|
for (const item of Object.values(record)) {
|
||||||
|
const nested = ratioRangeFromValue(item);
|
||||||
|
if (nested) return nested;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aspectRatioWithinRange(value: string, range: [number, number]) {
|
||||||
|
const [width, height] = value.split(':').map(Number);
|
||||||
|
if (!Number.isFinite(width) || !Number.isFinite(height) || height <= 0) return false;
|
||||||
|
const ratio = width / height;
|
||||||
|
return ratio >= range[0] && ratio <= range[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCapabilityValue(value: unknown) {
|
||||||
|
if (value === undefined || value === null || value === '') return false;
|
||||||
|
if (Array.isArray(value)) return value.length > 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boolFromUnknown(value: unknown) {
|
||||||
|
if (value === true || value === 'true') return true;
|
||||||
|
if (value === false || value === 'false') return false;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberFromUnknown(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 uniqueStrings(values: string[]) {
|
||||||
|
return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaSettingsEqual(left: MediaGenerationSettings, right: MediaGenerationSettings) {
|
||||||
|
return left.aspectRatio === right.aspectRatio
|
||||||
|
&& left.countPreset === right.countPreset
|
||||||
|
&& left.customCount === right.customCount
|
||||||
|
&& left.height === right.height
|
||||||
|
&& left.outputMode === right.outputMode
|
||||||
|
&& left.resolution === right.resolution
|
||||||
|
&& left.width === right.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstString(...values: unknown[]) {
|
||||||
|
return values.map(stringFromUnknown).find(Boolean) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function nestedString(value: unknown, key: string) {
|
||||||
|
return stringFromUnknown(recordFromUnknown(value)?.[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringFromUnknown(value: unknown) {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayFromUnknown(value: unknown) {
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 clampNumber(value: number, min: number, max: number) {
|
||||||
|
if (!Number.isFinite(value)) return min;
|
||||||
|
return Math.min(max, Math.max(min, Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaResultItem {
|
||||||
|
poster?: string;
|
||||||
|
src: string;
|
||||||
|
type: 'image' | 'video';
|
||||||
|
}
|
||||||
@ -322,6 +322,17 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playgroundHero[data-media-board="true"] {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
align-content: stretch;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-items: stretch;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.playgroundModeSwitch {
|
.playgroundModeSwitch {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
@ -868,6 +879,530 @@
|
|||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mediaSettingsTrigger {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 34px;
|
||||||
|
max-width: 260px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--input);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-normal);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaSettingsTrigger span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaSettingsPanel {
|
||||||
|
display: grid;
|
||||||
|
width: min(560px, calc(100vw - 24px));
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaSettingsSection {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaSettingsLabel {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaAspectGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, 44px);
|
||||||
|
gap: 4px;
|
||||||
|
justify-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaAspectGrid button {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 46px;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 4px 2px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-normal);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaAspectGrid button[data-active="true"] {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: color-mix(in srgb, var(--primary) 8%, var(--surface));
|
||||||
|
color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaAspectIcon {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaAspectIcon::before {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
border: 1.5px solid currentColor;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaAspectIcon[data-visual="smart"]::before {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaAspectIcon[data-visual="smart"] svg {
|
||||||
|
position: absolute;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaAspectIcon[data-visual="wide"]::before {
|
||||||
|
width: 13px;
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaAspectIcon[data-visual="landscape"]::before {
|
||||||
|
width: 12px;
|
||||||
|
height: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaAspectIcon[data-visual="square"]::before {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaAspectIcon[data-visual="portrait"]::before {
|
||||||
|
width: 7px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaAspectIcon[data-visual="tall"]::before {
|
||||||
|
width: 5px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaAspectGrid strong {
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
line-height: 1;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaResolutionSegment,
|
||||||
|
.mediaOutputModeSegment {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(118px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaResolutionSegment button,
|
||||||
|
.mediaOutputModeSegment button {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 34px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-normal);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaResolutionSegment button[data-active="true"],
|
||||||
|
.mediaOutputModeSegment button[data-active="true"] {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: color-mix(in srgb, var(--primary) 8%, var(--surface));
|
||||||
|
color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaResolutionSegment button:disabled,
|
||||||
|
.mediaOutputModeSegment button:disabled,
|
||||||
|
.mediaCountGrid button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.56;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaResolutionSegment svg,
|
||||||
|
.mediaSettingsHint svg {
|
||||||
|
color: #06a6bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaSizeRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 24px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaSizeRow label {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 24px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaSizeRow label span,
|
||||||
|
.mediaSizeRow strong {
|
||||||
|
color: #607080;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaSizeRow .shInput {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaSizeRow > svg {
|
||||||
|
justify-self: center;
|
||||||
|
color: #607080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaCountGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(64px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaCountGrid button {
|
||||||
|
min-height: 32px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-normal);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaCountGrid button[data-active="true"] {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--primary-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaCustomCount {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 110px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: start;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaUnsupportedNote {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface-muted);
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaSettingsHint {
|
||||||
|
display: inline-flex;
|
||||||
|
width: fit-content;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaTaskPage {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaTaskTimeline {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 22px;
|
||||||
|
justify-items: center;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 38px 40px 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaTaskTimeline h1 {
|
||||||
|
width: min(1240px, 100%);
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaTaskTimeline > .playgroundError {
|
||||||
|
width: min(1240px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaTaskItem {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
width: min(1240px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaTaskHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaTaskHeader > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaTaskHeader p {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 9px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaTaskHeader p span {
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaTaskHeader small,
|
||||||
|
.mediaTaskHeader time {
|
||||||
|
color: #7a8794;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaTaskStatus {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 5px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface-muted);
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaTaskStatus[data-status="succeeded"] {
|
||||||
|
background: #eefdf4;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaTaskStatus[data-status="failed"],
|
||||||
|
.mediaTaskStatus[data-status="cancelled"] {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaPreviewStage {
|
||||||
|
display: grid;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaPreviewStage[data-count="1"] {
|
||||||
|
min-height: min(62vh, 600px);
|
||||||
|
max-height: 600px;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.05);
|
||||||
|
border-radius: 18px;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(248, 250, 252, 0.96), rgba(234, 238, 243, 0.74)),
|
||||||
|
repeating-linear-gradient(90deg, rgba(15, 23, 42, 0.035) 0 1px, transparent 1px 28px),
|
||||||
|
repeating-linear-gradient(0deg, rgba(15, 23, 42, 0.03) 0 1px, transparent 1px 28px);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaPreviewStage[data-count="1"]::after {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.18) 44%, rgba(15, 23, 42, 0.035));
|
||||||
|
content: "";
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaPreviewBackdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: -28px;
|
||||||
|
width: calc(100% + 56px);
|
||||||
|
height: calc(100% + 56px);
|
||||||
|
opacity: 0.2;
|
||||||
|
filter: blur(34px) saturate(1.08);
|
||||||
|
object-fit: cover;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaGrid {
|
||||||
|
--media-grid-columns: 1;
|
||||||
|
display: grid;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
grid-template-columns: repeat(var(--media-grid-columns), minmax(0, 1fr));
|
||||||
|
gap: 2px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--media-grid-max-width, 100%);
|
||||||
|
max-height: 600px;
|
||||||
|
justify-self: center;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaTile {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 210px;
|
||||||
|
max-height: 600px;
|
||||||
|
aspect-ratio: var(--media-result-aspect, 1 / 1);
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #eef0f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaTile[data-count="1"] {
|
||||||
|
min-height: min(62vh, 600px);
|
||||||
|
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaTile[data-count="2"] {
|
||||||
|
min-height: min(48vh, 520px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaTile img,
|
||||||
|
.mediaTile video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaLoading,
|
||||||
|
.mediaEmptyTile {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(110deg, #edf0f2 0%, #f7f8f9 42%, #e6eaee 78%);
|
||||||
|
background-size: 220% 100%;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
animation: mediaLoadingShimmer 1.35s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaLoading svg {
|
||||||
|
animation: mediaLoadingSpin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaEmptyTile {
|
||||||
|
animation: none;
|
||||||
|
background: #eef0f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaTaskError {
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #991b1b;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaTaskActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaTaskActions > span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaTaskActions > span svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: #607080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaComposerDock {
|
||||||
|
padding: 16px 40px 28px;
|
||||||
|
background: linear-gradient(180deg, rgba(250, 250, 250, 0), var(--surface-subtle) 26%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaComposerDock .playgroundComposer {
|
||||||
|
width: min(1240px, 100%);
|
||||||
|
min-height: 190px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mediaLoadingSpin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mediaLoadingShimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 140% 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-position: -80% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
.publicWorksMasonry {
|
.publicWorksMasonry {
|
||||||
column-count: 3;
|
column-count: 3;
|
||||||
@ -954,6 +1489,61 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mediaSettingsTrigger,
|
||||||
|
.composerFooter .playgroundModelSelect,
|
||||||
|
.composerFooter .playgroundApiKeySelect,
|
||||||
|
.playgroundModelSelect,
|
||||||
|
.playgroundApiKeySelect {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaSettingsPanel {
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaAspectGrid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaResolutionSegment button {
|
||||||
|
min-height: 34px;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaSizeRow {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaSizeRow > svg,
|
||||||
|
.mediaSizeRow > strong {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaTaskTimeline {
|
||||||
|
padding: 30px 18px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaGrid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaGrid[data-count="1"] {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaTile[data-count="1"],
|
||||||
|
.mediaTile[data-count="2"] {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaComposerDock {
|
||||||
|
padding: 12px 18px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.composerQuickPrompts {
|
.composerQuickPrompts {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
@ -964,6 +1554,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
|
.mediaAspectGrid,
|
||||||
|
.mediaCountGrid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaTaskHeader {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaTaskActions > span {
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.publicWorksMasonry {
|
.publicWorksMasonry {
|
||||||
column-count: 1;
|
column-count: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user