easyai-ai-gateway/apps/web/src/pages/PlaygroundPage.tsx

1678 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import {
AssistantRuntimeProvider,
ComposerPrimitive,
ErrorPrimitive,
MessagePrimitive,
ThreadPrimitive,
useMessage,
useMessagePartText,
useLocalRuntime,
useThread,
type ChatModelAdapter,
type ThreadMessage,
type ThreadMessageLike,
} from '@assistant-ui/react';
import { StreamdownTextPrimitive } from '@assistant-ui/react-streamdown';
import { cjk } from '@streamdown/cjk';
import { code } from '@streamdown/code';
import { math } from '@streamdown/math';
import { mermaid } from '@streamdown/mermaid';
import type { GatewayApiKey, GatewayTask, PlatformModel } from '@easyai-ai-gateway/contracts';
import { Bot, ChevronDown, FileText, Image as ImageIcon, LoaderCircle, MessageSquarePlus, Music2, Paperclip, Send, Sparkles, Video, X } from 'lucide-react';
import { Badge, Button, Select, Textarea } from '../components/ui';
import { GatewayApiError, createImageEditTask, createImageGenerationTask, createVideoGenerationTask, pollTaskUntilSettled, streamChatCompletionText, taskIsPending, uploadFileToStorage } from '../api';
import type { PlaygroundMode } from '../types';
import {
defaultMediaGenerationSettings,
deriveMediaModelCapabilities,
gatewayTaskErrorText,
mediaRequestPayload,
MediaSettingsPopover,
MediaTaskBoard,
normalizeMediaSettingsForCapabilities,
type MediaGenerationRun,
type MediaGenerationSettings,
type MediaModelCapabilities,
} from './playground-media';
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 {
count: number;
label: string;
models: PlatformModel[];
provider: string;
value: string;
}
type PlaygroundUploadKind = 'audio' | 'file' | 'image' | 'video';
interface PlaygroundUpload {
contentType: string;
id: string;
kind: PlaygroundUploadKind;
name: string;
raw: Record<string, unknown>;
size: number;
url: string;
}
const modeOptions: Array<{ description: string; icon: ReactNode; label: string; value: PlaygroundMode }> = [
{ value: 'chat', label: '大模型对话', description: '对话、推理、结构化输出', icon: <Bot size={16} /> },
{ value: 'image', label: '图像生成', description: '文生图、图像编辑参数预览', icon: <ImageIcon size={16} /> },
{ value: 'video', label: '视频生成', description: '图生视频、文生视频任务测试', icon: <Video size={16} /> },
];
const videoModeOptions: Array<{ label: string; value: VideoCreateMode }> = [
{ value: 'text_to_video', label: '文生视频' },
{ value: 'first_last_frame', label: '首尾帧' },
{ value: 'omni_reference', label: '全能参考' },
];
const placeholderByMode: Record<PlaygroundMode, string> = {
chat: '输入问题、角色设定或测试提示词,支持 OpenAI 兼容格式验证...',
image: '描述你想生成的画面,例如:未来城市中的玻璃温室,晨光,电影级构图...',
video: '描述视频镜头、主体运动和风格,例如:低角度跟拍,一辆复古跑车穿过雨夜街道...',
};
const quickPrompts: Record<PlaygroundMode, string[]> = {
chat: ['写一个产品发布摘要', '生成接口调用示例', '分析失败重试策略'],
image: ['产品海报', '角色设定图', '电商主图'],
video: ['5 秒运镜', '首帧转视频', '宣传短片'],
};
const mediaUploadAccept = 'image/*,video/*,audio/*';
const chatUploadAccept = [
mediaUploadAccept,
'.csv',
'.doc',
'.docx',
'.json',
'.jsonl',
'.md',
'.markdown',
'.pdf',
'.ppt',
'.pptx',
'.txt',
'.xls',
'.xlsx',
'.yaml',
'.yml',
'application/json',
'application/msword',
'application/pdf',
'application/vnd.ms-excel',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/*',
].join(',');
const publicWorks = [
{ title: '雨夜霓虹街区', type: '图像生成', image: 'https://picsum.photos/seed/easyai-neon-city/720/960' },
{ title: '玻璃温室晨光', type: '图像生成', image: 'https://picsum.photos/seed/easyai-glasshouse/720/540' },
{ title: '山谷航拍镜头', type: '视频生成', image: 'https://picsum.photos/seed/easyai-valley-flight/720/1040' },
{ title: '极简产品广告', type: '图像生成', image: 'https://picsum.photos/seed/easyai-product-ad/720/680' },
{ title: '机械结构设定', type: '图像编辑', image: 'https://picsum.photos/seed/easyai-mecha-sketch/720/920' },
{ title: '城市模型推演', type: '大模型', image: 'https://picsum.photos/seed/easyai-city-plan/720/620' },
{ title: '海边人物电影感', type: '图像生成', image: 'https://picsum.photos/seed/easyai-cinematic-sea/720/980' },
{ title: '空间站漫游', type: '视频生成', image: 'https://picsum.photos/seed/easyai-orbital-walk/720/860' },
{ title: '水彩建筑手稿', type: '图像生成', image: 'https://picsum.photos/seed/easyai-watercolor-arch/720/760' },
{ title: '品牌 KV 探索', type: '图像生成', image: 'https://picsum.photos/seed/easyai-brand-kv/720/560' },
{ title: '古城夜游镜头', type: '视频生成', image: 'https://picsum.photos/seed/easyai-night-town/720/1020' },
{ title: '界面概念板', type: '大模型', image: 'https://picsum.photos/seed/easyai-ui-board/720/650' },
];
const streamdownPlugins = { cjk, code, math, mermaid };
export function PlaygroundPage(props: {
apiKeySecretsById: Record<string, string>;
apiKeys: GatewayApiKey[];
mode: PlaygroundMode;
models: PlatformModel[];
selectedApiKeyId: string;
token: string;
onApiKeyChange: (apiKeyId: string) => void;
onCreateApiKey: () => void;
onLogin: () => void;
onModeChange: (mode: PlaygroundMode) => void;
}) {
const [prompt, setPrompt] = useState('');
const [selectedModel, setSelectedModel] = useState('');
const [imageHasReference, setImageHasReference] = useState(false);
const [videoMode, setVideoMode] = useState<VideoCreateMode>('text_to_video');
const [threadKey, setThreadKey] = useState(0);
const [mediaSettings, setMediaSettings] = useState(defaultMediaGenerationSettings);
const [mediaRuns, setMediaRuns] = useState<MediaGenerationRun[]>(readStoredMediaRuns);
const [mediaMessage, setMediaMessage] = useState('');
const [mediaUploadMessage, setMediaUploadMessage] = useState('');
const [mediaUploads, setMediaUploads] = useState<PlaygroundUpload[]>([]);
const [mediaUploading, setMediaUploading] = useState(false);
const isMountedRef = useRef(false);
const pendingMediaModelRef = useRef('');
const resumedTaskIdsRef = useRef(new Set<string>());
const activeMode = useMemo(() => modeOptions.find((item) => item.value === props.mode) ?? modeOptions[0], [props.mode]);
const effectiveImageHasReference = imageHasReference || (props.mode === 'image' && mediaUploads.some((item) => item.kind === 'image'));
const modelOptions = useMemo(
() => buildModelOptions(filterModelsForMode(props.models, props.mode, effectiveImageHasReference, videoMode)),
[effectiveImageHasReference, props.mode, props.models, videoMode],
);
const activeApiKeyId = resolveSelectedApiKeyId(props.apiKeys, props.apiKeySecretsById, props.selectedApiKeyId);
const activeApiKeySecret = activeApiKeyId ? props.apiKeySecretsById[activeApiKeyId] ?? '' : '';
const activeModelOption = useMemo(() => modelOptions.find((item) => item.value === selectedModel), [modelOptions, selectedModel]);
const mediaCapabilities = useMemo(
() => props.mode === 'chat'
? undefined
: activeModelOption
? deriveMediaModelCapabilities(activeModelOption.models, props.mode, videoMode, mediaSettings.resolution)
: undefined,
[activeModelOption, mediaSettings.resolution, props.mode, videoMode],
);
useEffect(() => {
setSelectedModel((current) => {
const pendingModel = pendingMediaModelRef.current;
if (pendingModel) {
const resolvedPending = resolveModelOptionValue(pendingModel, modelOptions);
if (resolvedPending) {
pendingMediaModelRef.current = '';
return resolvedPending;
}
}
return modelOptions.some((item) => item.value === current) ? current : modelOptions[0]?.value ?? '';
});
}, [modelOptions]);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
useEffect(() => {
if (props.mode === 'chat' || !mediaCapabilities) return;
const mediaMode = props.mode;
setMediaSettings((current) => normalizeMediaSettingsForCapabilities(current, mediaCapabilities, mediaMode));
}, [mediaCapabilities, props.mode]);
useEffect(() => {
writeStoredMediaRuns(mediaRuns);
}, [mediaRuns]);
async function uploadMediaFiles(files: File[]) {
if (!files.length) return;
const credential = activeApiKeySecret || props.token;
if (!props.token) {
props.onLogin();
return;
}
if (!credential) {
setMediaUploadMessage('请选择可用于测试的 API Key 后再上传。');
return;
}
setMediaUploading(true);
setMediaUploadMessage('');
try {
const { items, warnings } = await uploadPlaygroundFiles(credential, files, {
allowFiles: false,
source: `ai-gateway-playground-${props.mode}`,
});
if (items.length) {
setMediaUploads((current) => [...current, ...items]);
if (props.mode === 'image' && items.some((item) => item.kind === 'image')) {
setImageHasReference(true);
}
}
setMediaUploadMessage(warnings[0] ?? (items.length ? `已上传 ${items.length} 个参考素材。` : ''));
} catch (err) {
setMediaUploadMessage(err instanceof Error ? err.message : '文件上传失败');
} finally {
setMediaUploading(false);
}
}
useEffect(() => {
const credential = activeApiKeySecret || props.token;
if (!credential) return;
const resumableRuns = mediaRuns.filter((run) => (
run.task?.id
&& taskIsPending(run.status)
&& !resumedTaskIdsRef.current.has(run.task.id)
));
resumableRuns.forEach((run) => {
if (!run.task?.id) return;
resumedTaskIdsRef.current.add(run.task.id);
void pollTaskUntilSettled(credential, run.task, {
onUpdate: (detail) => updateMediaRunFromTask(run.localId, detail),
})
.then((detail) => {
if (!isMountedRef.current) return;
updateMediaRunFromTask(run.localId, detail);
})
.catch((err) => {
if (!isMountedRef.current) return;
const errorMessage = err instanceof Error ? err.message : '任务状态同步失败';
setMediaRuns((current) => updateMediaRun(current, run.localId, { error: errorMessage, status: 'failed' }));
});
});
}, [activeApiKeySecret, mediaRuns, props.token]);
async function submitMediaTask(overrides?: {
model?: string;
mode?: Exclude<PlaygroundMode, 'chat'>;
prompt?: string;
settings?: MediaGenerationSettings;
}) {
const runMode = overrides?.mode ?? props.mode;
if (runMode === 'chat') return;
const sourceSettings = overrides?.settings ?? mediaSettings;
const runSettings = mediaCapabilities ? normalizeMediaSettingsForCapabilities(sourceSettings, mediaCapabilities, runMode) : sourceSettings;
const trimmedPrompt = (overrides?.prompt ?? prompt).trim();
const credential = activeApiKeySecret || props.token;
if (!props.token) {
props.onLogin();
return;
}
if (!credential) {
setMediaMessage('请选择可用于测试的 API Key如果列表为空请先创建一个 Key。');
return;
}
const runModel = overrides?.model ?? selectedModel;
if (!runModel) {
setMediaMessage('当前没有可用模型,请确认用户组权限或平台模型配置。');
return;
}
if (!trimmedPrompt) {
setMediaMessage(runMode === 'video' ? '请输入视频提示词。' : '请输入图片提示词。');
return;
}
const localId = newLocalId();
const runUploads = overrides ? [] : mediaUploads;
const modelLabel = modelOptions.find((item) => item.value === runModel)?.label ?? runModel;
const run: MediaGenerationRun = {
createdAt: new Date().toISOString(),
localId,
mode: runMode,
modelLabel,
modelValue: runModel,
prompt: trimmedPrompt,
settings: runSettings,
status: 'submitting',
};
setMediaRuns((current) => [...current, run]);
setMediaMessage('');
try {
const uploadPayload = mediaUploadRequestPayload(runUploads, runMode);
const requestPayload = {
model: runModel,
prompt: promptWithUploadSummary(trimmedPrompt, runUploads),
...mediaRequestPayload(runSettings, runMode),
...uploadPayload,
};
const response = runMode === 'video'
? await createVideoGenerationTask(credential, requestPayload)
: runUploads.some((item) => item.kind === 'image')
? await createImageEditTask(credential, requestPayload)
: await createImageGenerationTask(credential, requestPayload);
setMediaRuns((current) => updateMediaRun(current, localId, { status: response.task.status, task: response.task }));
if (!overrides) {
setMediaUploads([]);
setMediaUploadMessage('');
setImageHasReference(false);
}
void pollMediaRunUntilSettled(credential, localId, response.task);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '生成任务提交失败';
setMediaMessage(errorMessage);
setMediaRuns((current) => updateMediaRun(current, localId, { error: errorMessage, status: 'failed' }));
}
}
async function pollMediaRunUntilSettled(credential: string, localId: string, task: GatewayTask) {
try {
const detail = await pollTaskUntilSettled(credential, task, {
onUpdate: (nextTask) => updateMediaRunFromTask(localId, nextTask),
});
if (!isMountedRef.current) return;
updateMediaRunFromTask(localId, detail);
} catch (err) {
if (!isMountedRef.current) return;
const errorMessage = err instanceof Error ? err.message : '任务状态同步失败';
setMediaRuns((current) => updateMediaRun(current, localId, { error: errorMessage, status: 'failed' }));
}
}
function updateMediaRunFromTask(localId: string, task: GatewayTask) {
if (!isMountedRef.current) return;
setMediaRuns((current) => updateMediaRun(current, localId, {
error: taskIsPending(task.status) ? '' : gatewayTaskErrorText(task, '任务执行失败'),
status: task.status,
task,
}));
}
function selectMediaRunModel(run: MediaGenerationRun) {
const runModel = resolveMediaRunModelValue(run, modelOptions);
if (runModel) {
pendingMediaModelRef.current = '';
setSelectedModel(runModel);
return runModel;
}
const fallbackModel = firstString(run.modelValue, run.task?.requestedModel, taskRequestModel(run.task), run.task?.model, run.task?.resolvedModel);
pendingMediaModelRef.current = fallbackModel;
return fallbackModel;
}
function editMediaRun(run: MediaGenerationRun) {
setPrompt(run.prompt);
setMediaSettings(run.settings);
selectMediaRunModel(run);
if (props.mode !== run.mode) {
props.onModeChange(run.mode);
}
setMediaMessage('已带入这条任务的模型、提示词和参数,可调整后再次生成。');
}
function rerunMediaRun(run: MediaGenerationRun) {
setPrompt(run.prompt);
setMediaSettings(run.settings);
const runModel = selectMediaRunModel(run);
if (props.mode !== run.mode) {
props.onModeChange(run.mode);
setMediaMessage('已切换到对应模式并带入模型和参数,请确认后再次生成。');
return;
}
void submitMediaTask({ mode: run.mode, model: runModel, prompt: run.prompt, settings: run.settings });
}
const mediaComposer = props.mode === 'chat' ? null : (
<Composer
apiKeySecretsById={props.apiKeySecretsById}
apiKeys={props.apiKeys}
mode={props.mode}
modelOptions={modelOptions}
prompt={prompt}
selectedApiKeyId={activeApiKeyId}
selectedModel={selectedModel}
imageHasReference={effectiveImageHasReference}
mediaSettings={mediaSettings}
mediaCapabilities={mediaCapabilities}
uploadAccept={mediaUploadAccept}
uploadMessage={mediaUploadMessage}
uploads={mediaUploads}
uploading={mediaUploading}
videoMode={videoMode}
onApiKeyChange={props.onApiKeyChange}
onCreateApiKey={props.onCreateApiKey}
onImageReferenceChange={setImageHasReference}
onMediaSettingsChange={setMediaSettings}
onModeChange={props.onModeChange}
onModelChange={setSelectedModel}
onPromptChange={setPrompt}
onRemoveUpload={(id) => setMediaUploads((current) => {
const next = current.filter((item) => item.id !== id);
if (!next.some((item) => item.kind === 'image')) {
setImageHasReference(false);
}
return next;
})}
onSubmit={() => void submitMediaTask()}
onUploadFiles={(files) => void uploadMediaFiles(files)}
onVideoModeChange={setVideoMode}
/>
);
function startNewThread() {
clearStoredChatMessages();
setThreadKey((value) => value + 1);
}
return (
<div className="playgroundPage">
<aside className="playgroundSidebar">
<div className="playgroundSidebarTitle">
<strong></strong>
<Badge variant="secondary">Test</Badge>
</div>
<button type="button" className="playgroundSideItem active" onClick={startNewThread}>
<MessageSquarePlus size={15} />
</button>
<button type="button" className="playgroundSideItem">
<Sparkles size={15} />
</button>
</aside>
<main className="playgroundStage">
<section className="playgroundHero" data-chat={props.mode === 'chat'} data-media-board={props.mode !== 'chat' && mediaRuns.length > 0}>
{props.mode === 'chat' ? (
<AssistantChatPlayground
key={threadKey}
apiKeySecretsById={props.apiKeySecretsById}
apiKeys={props.apiKeys}
modelOptions={modelOptions}
selectedApiKeyId={props.selectedApiKeyId}
selectedModel={selectedModel}
token={props.token}
onApiKeyChange={props.onApiKeyChange}
onCreateApiKey={props.onCreateApiKey}
onModeChange={props.onModeChange}
onModelChange={setSelectedModel}
onLogin={props.onLogin}
/>
) : mediaRuns.length > 0 && mediaComposer ? (
<MediaTaskBoard
composer={mediaComposer}
message={mediaMessage}
runs={mediaRuns}
onEditRun={editMediaRun}
onRerun={rerunMediaRun}
/>
) : (
<>
<ModeSwitch activeMode={props.mode} onModeChange={props.onModeChange} />
<PlaygroundGreeting activeMode={activeMode} />
{mediaMessage && <p className="playgroundError">{mediaMessage}</p>}
{mediaComposer}
</>
)}
</section>
</main>
</div>
);
}
export function PlaygroundEntry(props: {
onModeChange: (mode: PlaygroundMode) => void;
}) {
const [mode, setMode] = useState<PlaygroundMode>('chat');
const [prompt, setPrompt] = useState('');
const activeMode = modeOptions.find((item) => item.value === mode) ?? modeOptions[0];
function openPlayground(nextMode = mode) {
props.onModeChange(nextMode);
}
return (
<section className="homePlaygroundEntry">
<h2>
<button type="button" onClick={() => openPlayground(mode)}>{activeMode.label}<ChevronDown size={20} /></button>
</h2>
<Composer
mode={mode}
modelOptions={[]}
prompt={prompt}
compact
onModeChange={(nextMode) => {
setMode(nextMode);
openPlayground(nextMode);
}}
onModelChange={() => undefined}
onPromptChange={setPrompt}
onSubmit={() => openPlayground(mode)}
/>
<div className="playgroundModeCards">
{modeOptions.map((item) => (
<button type="button" key={item.value} onClick={() => openPlayground(item.value)}>
<span>{item.icon}</span>
<strong>{item.label}</strong>
<small>{item.description}</small>
</button>
))}
</div>
</section>
);
}
export function PublicWorksGallery() {
return (
<section className="publicWorksSection">
<div className="publicWorksHeader">
<div>
<p className="eyebrow">Community Gallery</p>
<h2></h2>
</div>
<div className="publicWorksTabs" aria-label="作品类型">
<button type="button" data-active="true"></button>
<button type="button"></button>
<button type="button"></button>
</div>
</div>
<div className="publicWorksMasonry">
{publicWorks.map((item) => (
<article className="publicWorkCard" key={item.title}>
<img src={item.image} alt={item.title} loading="lazy" />
<div>
<Badge variant="secondary">{item.type}</Badge>
<strong>{item.title}</strong>
</div>
</article>
))}
</div>
</section>
);
}
function AssistantChatPlayground(props: {
apiKeySecretsById: Record<string, string>;
apiKeys: GatewayApiKey[];
modelOptions: ModelOption[];
selectedApiKeyId: string;
selectedModel: string;
token: string;
onApiKeyChange: (apiKeyId: string) => void;
onCreateApiKey: () => void;
onLogin: () => void;
onModeChange: (mode: PlaygroundMode) => void;
onModelChange: (value: string) => void;
}) {
const activeApiKeyId = resolveSelectedApiKeyId(props.apiKeys, props.apiKeySecretsById, props.selectedApiKeyId);
const activeApiKeySecret = activeApiKeyId ? props.apiKeySecretsById[activeApiKeyId] ?? '' : '';
const canRun = Boolean(props.token && props.selectedModel && activeApiKeySecret);
const apiKeyNotice = apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById);
const initialMessages = useMemo(() => readStoredChatMessages(), []);
const [chatUploadMessage, setChatUploadMessage] = useState('');
const [chatUploads, setChatUploads] = useState<PlaygroundUpload[]>([]);
const [chatUploading, setChatUploading] = useState(false);
const chatUploadsRef = useRef(chatUploads);
useEffect(() => {
chatUploadsRef.current = chatUploads;
}, [chatUploads]);
async function uploadChatFiles(files: File[]) {
if (!files.length) return;
if (!props.token) {
props.onLogin();
return;
}
if (!activeApiKeySecret) {
setChatUploadMessage('请选择可用于测试的 API Key 后再上传。');
return;
}
setChatUploading(true);
setChatUploadMessage('');
try {
const { items, warnings } = await uploadPlaygroundFiles(activeApiKeySecret, files, {
allowFiles: true,
source: 'ai-gateway-playground-chat',
});
if (items.length) {
setChatUploads((current) => [...current, ...items]);
}
setChatUploadMessage(warnings[0] ?? (items.length ? `已上传 ${items.length} 个附件。` : ''));
} catch (err) {
setChatUploadMessage(err instanceof Error ? err.message : '文件上传失败');
} finally {
setChatUploading(false);
}
}
const adapter = useMemo<ChatModelAdapter>(() => ({
async *run({ abortSignal, messages }) {
if (!props.token) {
props.onLogin();
throw new GatewayApiError('请先登录后再测试模型。');
}
if (!activeApiKeySecret) {
throw new GatewayApiError('请选择可用于测试的 API Key如果列表为空请刷新或重新创建一个 Key。');
}
if (!props.selectedModel) {
throw new GatewayApiError('当前没有可用的大模型,请确认用户组权限或平台模型配置。');
}
const requestUploads = chatUploadsRef.current;
if (requestUploads.length) {
chatUploadsRef.current = [];
setChatUploads([]);
setChatUploadMessage('');
}
let text = '';
for await (const delta of streamChatCompletionText(
activeApiKeySecret,
{
messages: toGatewayChatMessages(messages, requestUploads),
model: props.selectedModel,
},
abortSignal,
)) {
text += delta;
yield {
content: [{ type: 'text', text }],
};
}
yield {
content: [{ type: 'text', text }],
status: { type: 'complete', reason: 'stop' },
};
},
}), [activeApiKeySecret, props]);
const runtime = useLocalRuntime(adapter, { initialMessages });
return (
<AssistantRuntimeProvider runtime={runtime}>
<AssistantChatPersistenceBridge />
<ThreadPrimitive.Root className="assistantThreadRoot">
<ThreadPrimitive.Empty>
<div className="assistantEmptyStage">
<AssistantEmptyState
apiKeyNotice={apiKeyNotice}
apiKeySecretsById={props.apiKeySecretsById}
apiKeys={props.apiKeys}
canRun={canRun}
modelOptions={props.modelOptions}
selectedApiKeyId={activeApiKeyId}
selectedModel={props.selectedModel}
token={props.token}
activeApiKeySecret={activeApiKeySecret}
uploadAccept={chatUploadAccept}
uploadMessage={chatUploadMessage}
uploads={chatUploads}
uploading={chatUploading}
onApiKeyChange={props.onApiKeyChange}
onCreateApiKey={props.onCreateApiKey}
onModeChange={props.onModeChange}
onModelChange={props.onModelChange}
onRemoveUpload={(id) => setChatUploads((current) => current.filter((item) => item.id !== id))}
onUploadFiles={(files) => void uploadChatFiles(files)}
/>
</div>
</ThreadPrimitive.Empty>
<ThreadPrimitive.If empty={false}>
<div className="assistantShell" data-has-notice={Boolean(apiKeyNotice)}>
{apiKeyNotice && (
<div className="assistantApiKeyNotice">
<span>{apiKeyNotice}</span>
<Button type="button" size="sm" variant="secondary" onClick={props.onCreateApiKey}>
API Key
</Button>
</div>
)}
<ThreadPrimitive.Viewport className="assistantThreadViewport">
<div className="assistantMessageList">
<ThreadPrimitive.Messages components={{ Message: AssistantMessage }} />
</div>
<ThreadPrimitive.ViewportFooter className="assistantComposerDock">
<AssistantChatComposer
apiKeyNotice={apiKeyNotice}
apiKeySecretsById={props.apiKeySecretsById}
apiKeys={props.apiKeys}
canRun={canRun}
docked
modelOptions={props.modelOptions}
placeholder={assistantPlaceholder(props.token, props.selectedModel, activeApiKeySecret)}
selectedApiKeyId={activeApiKeyId}
selectedModel={props.selectedModel}
uploadAccept={chatUploadAccept}
uploadMessage={chatUploadMessage}
uploads={chatUploads}
uploading={chatUploading}
onApiKeyChange={props.onApiKeyChange}
onCreateApiKey={props.onCreateApiKey}
onModeChange={props.onModeChange}
onModelChange={props.onModelChange}
onRemoveUpload={(id) => setChatUploads((current) => current.filter((item) => item.id !== id))}
onUploadFiles={(files) => void uploadChatFiles(files)}
/>
</ThreadPrimitive.ViewportFooter>
</ThreadPrimitive.Viewport>
</div>
</ThreadPrimitive.If>
</ThreadPrimitive.Root>
</AssistantRuntimeProvider>
);
}
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: {
activeMode: PlaygroundMode;
onModeChange: (mode: PlaygroundMode) => void;
}) {
return (
<div className="playgroundModeSwitch">
{modeOptions.map((item) => (
<button
type="button"
key={item.value}
data-active={props.activeMode === item.value}
onClick={() => props.onModeChange(item.value)}
>
{item.icon}
<span>{item.label}</span>
</button>
))}
</div>
);
}
function PlaygroundGreeting(props: {
activeMode: { description: string; label: string };
}) {
return (
<div className="playgroundGreeting">
<span></span>
<strong>{props.activeMode.label}</strong>
<small>{props.activeMode.description}</small>
</div>
);
}
function AssistantEmptyState(props: {
activeApiKeySecret: string;
apiKeyNotice: string;
apiKeySecretsById: Record<string, string>;
apiKeys: GatewayApiKey[];
canRun: boolean;
modelOptions: ModelOption[];
selectedApiKeyId: string;
selectedModel: string;
token: string;
uploadAccept: string;
uploadMessage: string;
uploads: PlaygroundUpload[];
uploading: boolean;
onApiKeyChange: (apiKeyId: string) => void;
onCreateApiKey: () => void;
onModeChange: (mode: PlaygroundMode) => void;
onModelChange: (value: string) => void;
onRemoveUpload: (id: string) => void;
onUploadFiles: (files: File[]) => void;
}) {
const activeMode = modeOptions.find((item) => item.value === 'chat') ?? modeOptions[0];
const placeholder = props.canRun ? placeholderByMode.chat : assistantPlaceholder(props.token, props.selectedModel, props.activeApiKeySecret);
return (
<div className="assistantEmpty">
<ModeSwitch activeMode="chat" onModeChange={props.onModeChange} />
<PlaygroundGreeting activeMode={activeMode} />
<AssistantChatComposer
apiKeyNotice={props.apiKeyNotice}
apiKeySecretsById={props.apiKeySecretsById}
apiKeys={props.apiKeys}
canRun={props.canRun}
modelOptions={props.modelOptions}
placeholder={placeholder}
selectedApiKeyId={props.selectedApiKeyId}
selectedModel={props.selectedModel}
uploadAccept={props.uploadAccept}
uploadMessage={props.uploadMessage}
uploads={props.uploads}
uploading={props.uploading}
onApiKeyChange={props.onApiKeyChange}
onCreateApiKey={props.onCreateApiKey}
onModeChange={props.onModeChange}
onModelChange={props.onModelChange}
onRemoveUpload={props.onRemoveUpload}
onUploadFiles={props.onUploadFiles}
/>
</div>
);
}
function AssistantChatComposer(props: {
apiKeyNotice: string;
apiKeySecretsById: Record<string, string>;
apiKeys: GatewayApiKey[];
canRun: boolean;
docked?: boolean;
modelOptions: ModelOption[];
placeholder: string;
selectedApiKeyId: string;
selectedModel: string;
uploadAccept?: string;
uploadMessage?: string;
uploads?: PlaygroundUpload[];
uploading?: boolean;
onApiKeyChange: (apiKeyId: string) => void;
onCreateApiKey: () => void;
onModeChange: (mode: PlaygroundMode) => void;
onModelChange: (value: string) => void;
onRemoveUpload?: (id: string) => void;
onUploadFiles?: (files: File[]) => void;
}) {
const className = ['playgroundComposer', 'assistantChatComposer', props.docked ? 'assistantDockComposer' : 'assistantEmptyComposer'].join(' ');
return (
<ComposerPrimitive.Root className={className}>
<div className="composerBody">
<ComposerUploadButton
accept={props.uploadAccept ?? chatUploadAccept}
active={Boolean(props.uploads?.length)}
disabled={!props.canRun || !props.onUploadFiles}
uploading={props.uploading}
onFiles={props.onUploadFiles}
/>
<div className="composerInputStack">
<ComposerPrimitive.Input
className="assistantEmptyInput"
disabled={!props.canRun}
placeholder={props.placeholder}
/>
<UploadAttachmentList
message={props.uploadMessage}
uploads={props.uploads ?? []}
onRemove={props.onRemoveUpload}
/>
</div>
</div>
<div className="composerFooter">
<Select value="chat" onChange={(event) => props.onModeChange(event.target.value as PlaygroundMode)}>
{modeOptions.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}
</Select>
<Select
className="playgroundModelSelect"
value={props.selectedModel}
disabled={!props.modelOptions.length}
onChange={(event) => props.onModelChange(event.target.value)}
>
{props.modelOptions.length ? props.modelOptions.map((item) => (
<option value={item.value} key={item.value}>{modelOptionLabel(item)}</option>
)) : <option value=""></option>}
</Select>
<ApiKeySelect
apiKeySecretsById={props.apiKeySecretsById}
apiKeys={props.apiKeys}
selectedApiKeyId={props.selectedApiKeyId}
onApiKeyChange={props.onApiKeyChange}
/>
{props.apiKeyNotice && (
<Button type="button" size="sm" variant="secondary" onClick={props.onCreateApiKey}>
API Key
</Button>
)}
<ComposerPrimitive.Send className="composerSendButton" disabled={!props.canRun} aria-label="发送消息">
<Send size={18} />
</ComposerPrimitive.Send>
</div>
</ComposerPrimitive.Root>
);
}
function AssistantMessage() {
const hasError = useMessage((state) => state.status?.type === 'incomplete' && state.status.reason === 'error');
return (
<MessagePrimitive.Root className="assistantMessage">
<MessagePrimitive.If user>
<div className="assistantBubble user">
<MessagePrimitive.Parts components={{ Text: PlainMessageText }} />
</div>
</MessagePrimitive.If>
<MessagePrimitive.If assistant>
<div className={hasError ? 'assistantBubble assistant error' : 'assistantBubble assistant'}>
<MessagePrimitive.Parts components={{ Text: AssistantMarkdownText }} />
<MessagePrimitive.Error>
<strong></strong>
<ErrorPrimitive.Message className="assistantErrorMessage" />
</MessagePrimitive.Error>
{!hasError && (
<MessagePrimitive.If hasContent={false}>
<span className="assistantTyping">...</span>
</MessagePrimitive.If>
)}
</div>
</MessagePrimitive.If>
</MessagePrimitive.Root>
);
}
function PlainMessageText() {
const { text } = useMessagePartText();
return <span className="assistantPlainText">{text}</span>;
}
function AssistantMarkdownText() {
return (
<StreamdownTextPrimitive
containerClassName="assistantMarkdown"
plugins={streamdownPlugins}
shikiTheme={['github-light', 'github-dark']}
/>
);
}
function toGatewayChatMessages(messages: readonly ThreadMessage[], uploads: PlaygroundUpload[] = []) {
const gatewayMessages = messages
.filter((message) => message.role === 'user' || message.role === 'assistant')
.map((message) => ({
content: threadMessageText(message),
role: message.role,
}))
.filter((message) => message.content.trim().length > 0);
let lastUserIndex = -1;
gatewayMessages.forEach((message, index) => {
if (message.role === 'user') lastUserIndex = index;
});
if (lastUserIndex >= 0 && uploads.length) {
gatewayMessages[lastUserIndex] = {
...gatewayMessages[lastUserIndex],
content: promptWithUploadSummary(gatewayMessages[lastUserIndex].content, uploads),
};
}
return gatewayMessages;
}
function threadMessageText(message: ThreadMessage) {
return message.content
.map((part) => part.type === 'text' ? part.text : '')
.join('')
.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) {
if (selectedApiKeyId && secretsById[selectedApiKeyId]) return selectedApiKeyId;
const firstUsable = apiKeys.find((item) => Boolean(secretsById[item.id]));
return firstUsable?.id ?? '';
}
function apiKeyNoticeText(apiKeys: GatewayApiKey[], secretsById: Record<string, string>) {
if (!apiKeys.length) return '当前账号还没有可用 API Key请先创建一个 Key。';
if (!apiKeys.some((item) => Boolean(secretsById[item.id]))) {
return '当前没有可用于在线测试的完整 API Key请重新加载或创建一个 Key。';
}
return '';
}
function assistantPlaceholder(token: string, selectedModel: string, apiKeySecret: string) {
if (!token) return '请先登录后再测试模型';
if (!apiKeySecret) return '请选择可用于测试的 API Key';
if (!selectedModel) return '当前没有可用模型';
return '输入消息Enter 发送Shift + Enter 换行';
}
function Composer(props: {
apiKeySecretsById?: Record<string, string>;
apiKeys?: GatewayApiKey[];
compact?: boolean;
imageHasReference?: boolean;
mediaCapabilities?: MediaModelCapabilities;
mediaSettings?: MediaGenerationSettings;
mode: PlaygroundMode;
modelOptions: ModelOption[];
prompt: string;
selectedApiKeyId?: string;
selectedModel?: string;
uploadAccept?: string;
uploadMessage?: string;
uploads?: PlaygroundUpload[];
uploading?: boolean;
videoMode?: VideoCreateMode;
onApiKeyChange?: (apiKeyId: string) => void;
onCreateApiKey?: () => void;
onImageReferenceChange?: (value: boolean) => void;
onMediaSettingsChange?: (settings: MediaGenerationSettings) => void;
onModeChange: (mode: PlaygroundMode) => void;
onModelChange: (value: string) => void;
onPromptChange: (value: string) => void;
onRemoveUpload?: (id: string) => void;
onSubmit?: () => void;
onUploadFiles?: (files: File[]) => void;
onVideoModeChange?: (value: VideoCreateMode) => void;
}) {
const quickItems = quickPrompts[props.mode];
const apiKeyNotice = props.apiKeys && props.apiKeySecretsById ? apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById) : '';
return (
<div className={props.compact ? 'playgroundComposer compact' : 'playgroundComposer'}>
<div className="composerBody">
<ComposerUploadButton
accept={props.uploadAccept ?? mediaUploadAccept}
active={Boolean(props.uploads?.length) || props.imageHasReference === true}
disabled={!props.onUploadFiles}
uploading={props.uploading}
onFiles={props.onUploadFiles}
/>
<div className="composerInputStack">
<Textarea
size={props.compact ? 'sm' : 'md'}
value={props.prompt}
placeholder={placeholderByMode[props.mode]}
onChange={(event) => props.onPromptChange(event.target.value)}
/>
<UploadAttachmentList
message={props.uploadMessage}
uploads={props.uploads ?? []}
onRemove={props.onRemoveUpload}
/>
</div>
</div>
<div className="composerFooter">
<Select value={props.mode} onChange={(event) => props.onModeChange(event.target.value as PlaygroundMode)}>
{modeOptions.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}
</Select>
{props.mode === 'video' && (
<Select value={props.videoMode ?? 'text_to_video'} onChange={(event) => props.onVideoModeChange?.(event.target.value as VideoCreateMode)}>
{videoModeOptions.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}
</Select>
)}
<Select className="playgroundModelSelect" value={props.selectedModel ?? ''} disabled={!props.modelOptions.length} onChange={(event) => props.onModelChange(event.target.value)}>
{props.modelOptions.length ? props.modelOptions.map((item) => (
<option value={item.value} key={item.value}>{modelOptionLabel(item)}</option>
)) : <option value=""></option>}
</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 && (
<ApiKeySelect
apiKeySecretsById={props.apiKeySecretsById}
apiKeys={props.apiKeys}
selectedApiKeyId={props.selectedApiKeyId ?? ''}
onApiKeyChange={props.onApiKeyChange}
/>
)}
{apiKeyNotice && props.onCreateApiKey && (
<Button type="button" size="sm" variant="secondary" onClick={props.onCreateApiKey}>
API Key
</Button>
)}
<div className="composerQuickPrompts">
{quickItems.map((item) => <button type="button" key={item} onClick={() => props.onPromptChange(item)}>{item}</button>)}
</div>
<Button type="button" size="icon" aria-label="发送测试" onClick={props.onSubmit}>
<Send size={15} />
</Button>
</div>
</div>
);
}
function ComposerUploadButton(props: {
accept: string;
active?: boolean;
disabled?: boolean;
uploading?: boolean;
onFiles?: (files: File[]) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const disabled = props.disabled || props.uploading;
return (
<>
<button
type="button"
className="composerUpload"
aria-label="上传附件"
data-active={props.active === true}
disabled={disabled}
onClick={() => inputRef.current?.click()}
>
{props.uploading ? <LoaderCircle className="composerUploadSpinner" size={18} /> : <Paperclip size={18} />}
</button>
<input
ref={inputRef}
type="file"
multiple
hidden
accept={props.accept}
disabled={disabled}
onChange={(event) => {
const files = Array.from(event.currentTarget.files ?? []);
event.currentTarget.value = '';
props.onFiles?.(files);
}}
/>
</>
);
}
function UploadAttachmentList(props: {
message?: string;
uploads: PlaygroundUpload[];
onRemove?: (id: string) => void;
}) {
if (!props.uploads.length && !props.message) return null;
return (
<div className="composerUploadArea">
{props.uploads.length > 0 && (
<div className="composerUploadList">
{props.uploads.map((item) => (
<span className="composerUploadChip" key={item.id} title={`${item.name} · ${item.url}`}>
{uploadKindIcon(item.kind)}
<span>{item.name}</span>
<small>{formatFileSize(item.size)}</small>
{props.onRemove && (
<button type="button" aria-label={`移除 ${item.name}`} onClick={() => props.onRemove?.(item.id)}>
<X size={13} />
</button>
)}
</span>
))}
</div>
)}
{props.message && <div className="composerUploadMessage">{props.message}</div>}
</div>
);
}
function uploadKindIcon(kind: PlaygroundUploadKind) {
if (kind === 'image') return <ImageIcon size={14} />;
if (kind === 'video') return <Video size={14} />;
if (kind === 'audio') return <Music2 size={14} />;
return <FileText size={14} />;
}
function ApiKeySelect(props: {
apiKeySecretsById: Record<string, string>;
apiKeys: GatewayApiKey[];
selectedApiKeyId: string;
onApiKeyChange: (apiKeyId: string) => void;
}) {
const activeApiKeyId = resolveSelectedApiKeyId(props.apiKeys, props.apiKeySecretsById, props.selectedApiKeyId);
return (
<Select
className="playgroundApiKeySelect"
value={activeApiKeyId}
disabled={!props.apiKeys.length}
onChange={(event) => props.onApiKeyChange(event.target.value)}
>
{!activeApiKeyId && <option value="">{props.apiKeys.length ? '选择 API Key' : '暂无 API Key'}</option>}
{props.apiKeys.map((item) => {
const usable = Boolean(props.apiKeySecretsById[item.id]);
return (
<option value={item.id} key={item.id} disabled={!usable}>
{item.name} · {item.keyPrefix}
</option>
);
})}
</Select>
);
}
async function uploadPlaygroundFiles(
token: string,
files: File[],
options: { allowFiles: boolean; source: string },
): Promise<{ items: PlaygroundUpload[]; warnings: string[] }> {
const accepted: Array<{ file: File; kind: PlaygroundUploadKind }> = [];
const warnings: string[] = [];
files.forEach((file) => {
const kind = acceptedUploadKind(file, options.allowFiles);
if (!kind) {
warnings.push(options.allowFiles
? `已跳过 ${file.name},聊天仅支持图片、视频、音频和常见文档。`
: `已跳过 ${file.name},当前场景仅支持图片、视频和音频。`);
return;
}
accepted.push({ file, kind });
});
if (!accepted.length) return { items: [], warnings };
const items = await Promise.all(accepted.map(async ({ file, kind }) => {
const response = await uploadFileToStorage(token, file, options.source);
const url = uploadResponseUrl(response);
if (!url) {
throw new Error(`${file.name} 上传成功,但网关没有返回可用文件 URL。`);
}
return {
contentType: file.type || '',
id: newLocalId(),
kind,
name: file.name || '未命名文件',
raw: response,
size: file.size,
url,
};
}));
return { items, warnings };
}
function acceptedUploadKind(file: File, allowFiles: boolean): PlaygroundUploadKind | undefined {
const mime = file.type.toLowerCase();
const extension = fileExtension(file.name);
if (mime.startsWith('image/') || imageExtensions.has(extension)) return 'image';
if (mime.startsWith('video/') || videoExtensions.has(extension)) return 'video';
if (mime.startsWith('audio/') || audioExtensions.has(extension)) return 'audio';
if (allowFiles && (documentExtensions.has(extension) || documentMimes.has(mime))) return 'file';
return undefined;
}
const imageExtensions = new Set(['avif', 'bmp', 'gif', 'heic', 'heif', 'jpeg', 'jpg', 'png', 'svg', 'tif', 'tiff', 'webp']);
const videoExtensions = new Set(['avi', 'm4v', 'mkv', 'mov', 'mp4', 'mpeg', 'mpg', 'webm']);
const audioExtensions = new Set(['aac', 'flac', 'm4a', 'mp3', 'oga', 'ogg', 'opus', 'wav', 'weba']);
const documentExtensions = new Set(['csv', 'doc', 'docx', 'json', 'jsonl', 'md', 'markdown', 'pdf', 'ppt', 'pptx', 'txt', 'xls', 'xlsx', 'yaml', 'yml']);
const documentMimes = new Set([
'application/json',
'application/msword',
'application/pdf',
'application/vnd.ms-excel',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/csv',
'text/markdown',
'text/plain',
'text/yaml',
]);
function fileExtension(name: string) {
const index = name.lastIndexOf('.');
return index >= 0 ? name.slice(index + 1).toLowerCase() : '';
}
function uploadResponseUrl(response: Record<string, unknown>) {
const data = recordFromUnknown(response.data);
const file = recordFromUnknown(response.file);
const result = recordFromUnknown(response.result);
return firstString(
response.url,
response.fileUrl,
response.file_url,
response.objectUrl,
response.object_url,
response.downloadUrl,
response.download_url,
data?.url,
data?.fileUrl,
data?.file_url,
file?.url,
file?.fileUrl,
file?.file_url,
result?.url,
result?.fileUrl,
result?.file_url,
);
}
function promptWithUploadSummary(prompt: string, uploads: PlaygroundUpload[]) {
if (!uploads.length) return prompt;
const lines = uploads.map((item) => `- ${uploadKindLabel(item.kind)} ${item.name}: ${item.url}`);
return `${prompt}\n\n参考附件\n${lines.join('\n')}`;
}
function mediaUploadRequestPayload(uploads: PlaygroundUpload[], mode: Exclude<PlaygroundMode, 'chat'>) {
const images = uploads.filter((item) => item.kind === 'image').map((item) => item.url);
const videos = uploads.filter((item) => item.kind === 'video').map((item) => item.url);
const audios = uploads.filter((item) => item.kind === 'audio').map((item) => item.url);
const payload: Record<string, string | string[]> = {};
if (mode === 'image') {
if (images.length) {
payload.image = singleOrMany(images);
payload.images = images;
}
return payload;
}
if (images.length) {
payload.image = singleOrMany(images);
payload.image_url = images[0];
payload.images = images;
payload.reference_image = singleOrMany(images);
}
if (videos.length) {
payload.reference_video = singleOrMany(videos);
payload.video_url = videos[0];
}
if (audios.length) {
payload.reference_audio = singleOrMany(audios);
payload.audio_url = audios[0];
}
return payload;
}
function singleOrMany(values: string[]) {
return values.length === 1 ? values[0] : values;
}
function uploadKindLabel(kind: PlaygroundUploadKind) {
if (kind === 'image') return '图片';
if (kind === 'video') return '视频';
if (kind === 'audio') return '音频';
return '文件';
}
function formatFileSize(size: number) {
if (!Number.isFinite(size) || size <= 0) return '';
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / 1024 / 1024).toFixed(1)} MB`;
}
function filterModelsForMode(models: PlatformModel[], mode: PlaygroundMode, hasReference: boolean, videoMode: VideoCreateMode) {
if (mode === 'chat') {
return filterWithFallback(models, ['text_generate', 'chat', 'responses', 'text']);
}
if (mode === 'image') {
const preferredTypes = hasReference ? ['image_edit', 'images.edits'] : ['image_generate', 'images.generations'];
return filterWithFallback(models, [...preferredTypes, 'image']);
}
const videoTypesByMode: Record<VideoCreateMode, string[]> = {
first_last_frame: ['image_to_video', 'video_first_last_frame', 'video_generate'],
omni_reference: ['omni_video', 'video_reference', 'video_generate'],
text_to_video: ['text_to_video', 'video_generate'],
};
return filterWithFallback(models, [...videoTypesByMode[videoMode], 'video']);
}
function filterWithFallback(models: PlatformModel[], modelTypes: string[]) {
const exact = models.filter((model) => model.modelType.some((type) => modelTypes.includes(type)));
return exact.length ? exact : models.filter((model) => modelTypes.some((type) => model.modelType.some((modelType) => modelType.includes(type) || type.includes(modelType))));
}
function buildModelOptions(models: PlatformModel[]): ModelOption[] {
const grouped = new Map<string, ModelOption>();
models.forEach((model) => {
const value = model.modelAlias || model.modelName;
if (!value) return;
const current = grouped.get(value);
if (current) {
current.count += 1;
current.models.push(model);
if (!current.provider.includes(model.provider || '')) {
current.provider = [current.provider, model.provider].filter(Boolean).join(' / ');
}
return;
}
grouped.set(value, {
count: 1,
label: model.modelAlias || model.displayName || model.modelName,
models: [model],
provider: model.provider || model.platformName || '',
value,
});
});
return Array.from(grouped.values()).sort((a, b) => a.label.localeCompare(b.label));
}
function modelOptionLabel(option: ModelOption) {
const count = option.count > 1 ? ` · ${option.count} 个客户端` : '';
const provider = option.provider ? ` · ${option.provider}` : '';
return `${option.label}${provider}${count}`;
}
function resolveMediaRunModelValue(run: MediaGenerationRun, modelOptions: ModelOption[]) {
const candidates = [
run.modelValue,
run.task?.requestedModel,
taskRequestModel(run.task),
run.task?.model,
run.task?.resolvedModel,
];
for (const candidate of candidates) {
const value = resolveModelOptionValue(candidate, modelOptions);
if (value) return value;
}
return '';
}
function resolveModelOptionValue(value: unknown, modelOptions: ModelOption[]) {
const raw = stringFromUnknown(value);
if (!raw) return '';
const direct = modelOptions.find((item) => item.value === raw);
if (direct) return direct.value;
const matched = modelOptions.find((item) => item.models.some((model) => (
model.modelAlias === raw
|| model.modelName === raw
|| model.displayName === raw
)));
return matched?.value ?? '';
}
function taskRequestModel(task: GatewayTask | undefined) {
return stringFromUnknown(task?.request?.model);
}
function firstString(...values: unknown[]) {
for (const value of values) {
const text = stringFromUnknown(value);
if (text) return text;
}
return '';
}
function updateMediaRun(runs: MediaGenerationRun[], localId: string, patch: Partial<MediaGenerationRun>) {
return runs.map((run) => run.localId === localId ? { ...run, ...patch } : run);
}
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 modelValue = stringFromUnknown(record.modelValue) || task?.requestedModel || taskRequestModel(task) || task?.model || '';
const modelLabel = stringFromUnknown(record.modelLabel) || modelValue || '未知模型';
let status: MediaGenerationRun['status'] = stringFromUnknown(record.status) || task?.status || 'failed';
let error = stringFromUnknown(record.error);
if (status === 'submitting' && !task?.id) {
status = 'failed';
error = error || '任务提交已中断,请重新生成。';
}
return {
createdAt,
error,
localId,
mode,
modelLabel,
modelValue,
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),
durationSeconds: numberFromUnknown(record.durationSeconds ?? record.duration_seconds ?? record.duration, fallback.durationSeconds, 1, 3600),
height: numberFromUnknown(record.height, fallback.height, 128, 8192),
outputMode: record.outputMode === 'group' ? 'group' : 'single',
outputAudio: booleanFromUnknown(record.outputAudio ?? record.output_audio ?? record.audio, fallback.outputAudio),
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 booleanFromUnknown(value: unknown, fallback: boolean) {
if (value === true || value === 'true') return true;
if (value === false || value === 'false') return false;
return fallback;
}
function newLocalId() {
return typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}