1678 lines
62 KiB
TypeScript
1678 lines
62 KiB
TypeScript
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)}`;
|
||
}
|