feat: add file storage settings and uploads
This commit is contained in:
@@ -19,9 +19,9 @@ 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, Image as ImageIcon, MessageSquarePlus, Paperclip, Send, Sparkles, Video } from 'lucide-react';
|
||||
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, createImageGenerationTask, createVideoGenerationTask, pollTaskUntilSettled, streamChatCompletionText, taskIsPending } from '../api';
|
||||
import { GatewayApiError, createImageEditTask, createImageGenerationTask, createVideoGenerationTask, pollTaskUntilSettled, streamChatCompletionText, taskIsPending, uploadFileToStorage } from '../api';
|
||||
import type { PlaygroundMode } from '../types';
|
||||
import {
|
||||
defaultMediaGenerationSettings,
|
||||
@@ -58,6 +58,18 @@ interface ModelOption {
|
||||
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} /> },
|
||||
@@ -82,6 +94,35 @@ const quickPrompts: Record<PlaygroundMode, string[]> = {
|
||||
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' },
|
||||
@@ -119,13 +160,17 @@ export function PlaygroundPage(props: {
|
||||
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, imageHasReference, videoMode)),
|
||||
[imageHasReference, props.mode, props.models, videoMode],
|
||||
() => 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] ?? '' : '';
|
||||
@@ -170,6 +215,38 @@ export function PlaygroundPage(props: {
|
||||
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;
|
||||
@@ -227,6 +304,7 @@ export function PlaygroundPage(props: {
|
||||
}
|
||||
|
||||
const localId = newLocalId();
|
||||
const runUploads = overrides ? [] : mediaUploads;
|
||||
const modelLabel = modelOptions.find((item) => item.value === runModel)?.label ?? runModel;
|
||||
const run: MediaGenerationRun = {
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -242,15 +320,24 @@ export function PlaygroundPage(props: {
|
||||
setMediaRuns((current) => [...current, run]);
|
||||
setMediaMessage('');
|
||||
try {
|
||||
const uploadPayload = mediaUploadRequestPayload(runUploads, runMode);
|
||||
const requestPayload = {
|
||||
model: runModel,
|
||||
prompt: trimmedPrompt,
|
||||
prompt: promptWithUploadSummary(trimmedPrompt, runUploads),
|
||||
...mediaRequestPayload(runSettings, runMode),
|
||||
...uploadPayload,
|
||||
};
|
||||
const response = runMode === 'video'
|
||||
? await createVideoGenerationTask(credential, requestPayload)
|
||||
: await createImageGenerationTask(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 : '生成任务提交失败';
|
||||
@@ -325,9 +412,13 @@ export function PlaygroundPage(props: {
|
||||
prompt={prompt}
|
||||
selectedApiKeyId={activeApiKeyId}
|
||||
selectedModel={selectedModel}
|
||||
imageHasReference={imageHasReference}
|
||||
imageHasReference={effectiveImageHasReference}
|
||||
mediaSettings={mediaSettings}
|
||||
mediaCapabilities={mediaCapabilities}
|
||||
uploadAccept={mediaUploadAccept}
|
||||
uploadMessage={mediaUploadMessage}
|
||||
uploads={mediaUploads}
|
||||
uploading={mediaUploading}
|
||||
videoMode={videoMode}
|
||||
onApiKeyChange={props.onApiKeyChange}
|
||||
onCreateApiKey={props.onCreateApiKey}
|
||||
@@ -336,7 +427,15 @@ export function PlaygroundPage(props: {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
@@ -491,6 +590,42 @@ function AssistantChatPlayground(props: {
|
||||
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) {
|
||||
@@ -503,11 +638,17 @@ function AssistantChatPlayground(props: {
|
||||
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),
|
||||
messages: toGatewayChatMessages(messages, requestUploads),
|
||||
model: props.selectedModel,
|
||||
},
|
||||
abortSignal,
|
||||
@@ -541,10 +682,16 @@ function AssistantChatPlayground(props: {
|
||||
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>
|
||||
@@ -573,10 +720,16 @@ function AssistantChatPlayground(props: {
|
||||
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>
|
||||
@@ -645,10 +798,16 @@ function AssistantEmptyState(props: {
|
||||
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);
|
||||
@@ -666,10 +825,16 @@ function AssistantEmptyState(props: {
|
||||
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>
|
||||
);
|
||||
@@ -685,24 +850,41 @@ function AssistantChatComposer(props: {
|
||||
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">
|
||||
<button type="button" className="composerUpload" aria-label="上传参考" disabled>
|
||||
<Paperclip size={18} />
|
||||
</button>
|
||||
<ComposerPrimitive.Input
|
||||
className="assistantEmptyInput"
|
||||
disabled={!props.canRun}
|
||||
placeholder={props.placeholder}
|
||||
<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)}>
|
||||
@@ -780,14 +962,25 @@ function AssistantMarkdownText() {
|
||||
);
|
||||
}
|
||||
|
||||
function toGatewayChatMessages(messages: readonly ThreadMessage[]) {
|
||||
return messages
|
||||
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) {
|
||||
@@ -914,6 +1107,10 @@ function Composer(props: {
|
||||
prompt: string;
|
||||
selectedApiKeyId?: string;
|
||||
selectedModel?: string;
|
||||
uploadAccept?: string;
|
||||
uploadMessage?: string;
|
||||
uploads?: PlaygroundUpload[];
|
||||
uploading?: boolean;
|
||||
videoMode?: VideoCreateMode;
|
||||
onApiKeyChange?: (apiKeyId: string) => void;
|
||||
onCreateApiKey?: () => void;
|
||||
@@ -922,7 +1119,9 @@ function Composer(props: {
|
||||
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];
|
||||
@@ -930,21 +1129,26 @@ function Composer(props: {
|
||||
return (
|
||||
<div className={props.compact ? 'playgroundComposer compact' : 'playgroundComposer'}>
|
||||
<div className="composerBody">
|
||||
<button
|
||||
type="button"
|
||||
className="composerUpload"
|
||||
aria-label="上传参考"
|
||||
data-active={props.imageHasReference === true}
|
||||
onClick={() => props.mode === 'image' && props.onImageReferenceChange?.(!props.imageHasReference)}
|
||||
>
|
||||
<Paperclip size={18} />
|
||||
</button>
|
||||
<Textarea
|
||||
size={props.compact ? 'sm' : 'md'}
|
||||
value={props.prompt}
|
||||
placeholder={placeholderByMode[props.mode]}
|
||||
onChange={(event) => props.onPromptChange(event.target.value)}
|
||||
<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)}>
|
||||
@@ -992,6 +1196,81 @@ function Composer(props: {
|
||||
);
|
||||
}
|
||||
|
||||
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[];
|
||||
@@ -1019,6 +1298,154 @@ function ApiKeySelect(props: {
|
||||
);
|
||||
}
|
||||
|
||||
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']);
|
||||
|
||||
Reference in New Issue
Block a user