feat: implement proxy input file resolution and storage
- Added `inputFiles.ts` for handling input file resolution, including validation and normalization of file types. - Introduced `proxyFileStore.ts` for managing file storage, retrieval, and deletion with owner-based isolation. - Created tests for proxy file storage functionality in `proxyFileStore.test.ts`. - Developed `proxyInputFileResolver.ts` to resolve input files in OpenAI and response formats. - Added utility functions for handling reasoning signatures in `reasoningTransport.ts` and corresponding tests. - Enhanced shared input file transformers in `inputFile.ts` for better file handling and type inference.
This commit is contained in:
+311
-35
@@ -10,6 +10,7 @@ import {
|
||||
MESSAGE_STATUS,
|
||||
buildApiPayload,
|
||||
buildEmbeddingsRequestEnvelope,
|
||||
buildFileUploadRequestEnvelope,
|
||||
buildImagesEditRequestEnvelope,
|
||||
buildImagesGenerationsRequestEnvelope,
|
||||
buildRawProxyRequestEnvelope,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
collectModelTesterModelNames,
|
||||
createLoadingAssistantMessage,
|
||||
createMessage,
|
||||
createConversationUserMessage,
|
||||
filterModelTesterModelNames,
|
||||
finalizeIncompleteMessage,
|
||||
findLastLoadingAssistantIndex,
|
||||
@@ -30,6 +32,8 @@ import {
|
||||
syncCustomRequestBodyToMessages,
|
||||
syncMessagesToCustomRequestBody,
|
||||
type ChatMessage,
|
||||
type ConversationContentPart,
|
||||
type ConversationUploadedFile,
|
||||
type DebugTab,
|
||||
type ModelTesterInputs,
|
||||
type ModelTesterModeState,
|
||||
@@ -62,16 +66,19 @@ type UploadState = {
|
||||
dataUrl: string;
|
||||
};
|
||||
|
||||
type ConversationFileState = UploadState & {
|
||||
localId: string;
|
||||
fileId?: string | null;
|
||||
status: 'pending' | 'uploading' | 'uploaded' | 'error';
|
||||
errorMessage?: string | null;
|
||||
};
|
||||
|
||||
const POLL_INTERVAL_MS = 1200;
|
||||
const CONVERSATION_FILE_ACCEPT = '.pdf,.txt,.md,.markdown,.json,image/*,audio/*';
|
||||
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const fileToDataUrl = async (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(reader.error || new Error('read file failed'));
|
||||
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '');
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
const createConversationFileLocalId = () =>
|
||||
`draft-file-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const summarizeModeRequest = (
|
||||
mode: PlaygroundMode,
|
||||
@@ -671,10 +678,12 @@ export default function ModelTester() {
|
||||
const [videoInspectAction, setVideoInspectAction] = useState<'GET' | 'DELETE'>('GET');
|
||||
const [imageSourceFile, setImageSourceFile] = useState<UploadState | null>(null);
|
||||
const [imageMaskFile, setImageMaskFile] = useState<UploadState | null>(null);
|
||||
const [conversationFiles, setConversationFiles] = useState<ConversationFileState[]>([]);
|
||||
|
||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const chatEndRef = useRef<HTMLDivElement>(null);
|
||||
const conversationFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const restoredSessionRef = useRef<ReturnType<typeof parseModelTesterSession>>(null);
|
||||
const streamAbortRef = useRef<AbortController | null>(null);
|
||||
const streamStopRequestedRef = useRef(false);
|
||||
@@ -859,6 +868,107 @@ export default function ModelTester() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleConversationFilesChange = useCallback(async (fileList: FileList | null) => {
|
||||
const files = Array.from(fileList || []);
|
||||
if (files.length <= 0) return;
|
||||
|
||||
try {
|
||||
const nextFiles = await Promise.all(files.map(async (file) => ({
|
||||
localId: createConversationFileLocalId(),
|
||||
name: file.name,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
dataUrl: await readFileAsDataUrl(file),
|
||||
fileId: null,
|
||||
status: 'pending' as const,
|
||||
errorMessage: null,
|
||||
})));
|
||||
setConversationFiles((prev) => [...prev, ...nextFiles]);
|
||||
pushDebug('info', `已添加 ${nextFiles.length} 个会话附件。`);
|
||||
} catch (readError: any) {
|
||||
const message = readError?.message || '读取附件失败';
|
||||
setError(message);
|
||||
pushDebug('error', message);
|
||||
}
|
||||
}, [pushDebug]);
|
||||
|
||||
const removeConversationFile = useCallback((localId: string) => {
|
||||
if (sending) return;
|
||||
setConversationFiles((prev) => prev.filter((item) => item.localId !== localId));
|
||||
}, [sending]);
|
||||
|
||||
const extractUploadedFilesFromMessage = useCallback((message: ChatMessage): ConversationUploadedFile[] => {
|
||||
const parts = Array.isArray(message.parts) ? message.parts : [];
|
||||
return parts.flatMap((part) => {
|
||||
if (part.type !== 'input_file') return [];
|
||||
const fileId = typeof part.fileId === 'string' ? part.fileId.trim() : '';
|
||||
if (!fileId) return [];
|
||||
return [{
|
||||
fileId,
|
||||
filename: typeof part.filename === 'string' && part.filename.trim() ? part.filename.trim() : null,
|
||||
mimeType: typeof part.mimeType === 'string' && part.mimeType.trim() ? part.mimeType.trim() : null,
|
||||
}];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const uploadConversationFiles = useCallback(async (): Promise<ConversationUploadedFile[]> => {
|
||||
if (conversationFiles.length <= 0) return [];
|
||||
|
||||
const uploaded: ConversationUploadedFile[] = [];
|
||||
for (const item of conversationFiles) {
|
||||
if (item.fileId) {
|
||||
uploaded.push({
|
||||
fileId: item.fileId,
|
||||
filename: item.name,
|
||||
mimeType: item.mimeType,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
setConversationFiles((prev) => prev.map((entry) => (
|
||||
entry.localId === item.localId
|
||||
? { ...entry, status: 'uploading', errorMessage: null }
|
||||
: entry
|
||||
)));
|
||||
pushDebug('info', `正在上传附件:${item.name}`);
|
||||
|
||||
try {
|
||||
const result = await api.proxyTest(buildFileUploadRequestEnvelope({
|
||||
name: item.name,
|
||||
mimeType: item.mimeType,
|
||||
dataUrl: item.dataUrl,
|
||||
})) as { id?: unknown; filename?: unknown; mime_type?: unknown };
|
||||
const fileId = typeof result?.id === 'string' ? result.id.trim() : '';
|
||||
if (!fileId) {
|
||||
throw new Error('上传成功但未返回 file_id');
|
||||
}
|
||||
const filename = typeof result?.filename === 'string' && result.filename.trim()
|
||||
? result.filename.trim()
|
||||
: item.name;
|
||||
const mimeType = typeof result?.mime_type === 'string' && result.mime_type.trim()
|
||||
? result.mime_type.trim()
|
||||
: item.mimeType;
|
||||
|
||||
setConversationFiles((prev) => prev.map((entry) => (
|
||||
entry.localId === item.localId
|
||||
? { ...entry, fileId, name: filename, mimeType, status: 'uploaded', errorMessage: null }
|
||||
: entry
|
||||
)));
|
||||
uploaded.push({ fileId, filename, mimeType });
|
||||
pushDebug('info', `附件上传完成:${filename} -> ${fileId}`);
|
||||
} catch (uploadError: any) {
|
||||
const message = uploadError?.message || '附件上传失败';
|
||||
setConversationFiles((prev) => prev.map((entry) => (
|
||||
entry.localId === item.localId
|
||||
? { ...entry, status: 'error', errorMessage: message }
|
||||
: entry
|
||||
)));
|
||||
throw new Error(`${item.name}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return uploaded;
|
||||
}, [conversationFiles, pushDebug]);
|
||||
|
||||
const buildConversationMessagesWithSystem = useCallback((baseMessages: ChatMessage[]) => {
|
||||
if (!inputs.systemPrompt.trim()) return baseMessages;
|
||||
return [
|
||||
@@ -1260,6 +1370,7 @@ export default function ModelTester() {
|
||||
() => filteredModels.map((item) => ({ value: item, label: item })),
|
||||
[filteredModels],
|
||||
);
|
||||
const conversationFileSupported = inputs.protocol === 'openai' || inputs.protocol === 'responses';
|
||||
const canSend = useMemo(() => {
|
||||
if (sending || pendingJobId || !inputs.model) return false;
|
||||
if (inputs.mode !== 'conversation') {
|
||||
@@ -1272,9 +1383,9 @@ export default function ModelTester() {
|
||||
return false;
|
||||
}
|
||||
const hasPrompt = input.trim().length > 0;
|
||||
if (!customRequestMode) return hasPrompt;
|
||||
if (!customRequestMode) return hasPrompt || (conversationFileSupported && conversationFiles.length > 0);
|
||||
return hasPrompt || customRequestBody.trim().length > 0;
|
||||
}, [assetPrompt, customRequestBody, customRequestMode, embeddingInputText, imageSourceFile, input, inputs.mode, inputs.model, pendingJobId, searchQueryValue, sending, videoInspectId]);
|
||||
}, [assetPrompt, conversationFileSupported, conversationFiles.length, customRequestBody, customRequestMode, embeddingInputText, imageSourceFile, input, inputs.mode, inputs.model, pendingJobId, searchQueryValue, sending, videoInspectId]);
|
||||
|
||||
const startChatJob = useCallback(async (payload: TestChatPayload) => {
|
||||
try {
|
||||
@@ -1627,8 +1738,12 @@ export default function ModelTester() {
|
||||
};
|
||||
}, [buildConversationMessagesWithSystem, buildConversationProxyEnvelope, customRequestBody, customRequestMode, inputs, parameterEnabled]);
|
||||
|
||||
const sendWithPrompt = useCallback(async (prompt: string, baseMessages: ChatMessage[]) => {
|
||||
const userMessage = createMessage('user', prompt);
|
||||
const sendWithPrompt = useCallback(async (
|
||||
prompt: string,
|
||||
baseMessages: ChatMessage[],
|
||||
files: ConversationUploadedFile[] = [],
|
||||
) => {
|
||||
const userMessage = createConversationUserMessage(prompt, files);
|
||||
const loadingAssistant = createLoadingAssistantMessage();
|
||||
const nextMessages = [...baseMessages, userMessage, loadingAssistant];
|
||||
const useProxyTransport = inputs.protocol === 'gemini' || customRequestMode;
|
||||
@@ -1646,7 +1761,7 @@ export default function ModelTester() {
|
||||
}
|
||||
|
||||
await dispatchPayload(nextMessages, payload, { syncedCustomBody });
|
||||
}, [buildConversationProxyEnvelope, buildPayloadWithMessages, customRequestMode, dispatchPayload, dispatchProxyEnvelope, inputs.protocol, pushDebug]);
|
||||
}, [buildConversationProxyEnvelope, buildPayloadWithMessages, createConversationUserMessage, customRequestMode, dispatchPayload, dispatchProxyEnvelope, inputs.protocol, pushDebug]);
|
||||
|
||||
const sendModeRequest = useCallback(async () => {
|
||||
const envelope = buildModeProxyEnvelope();
|
||||
@@ -1666,6 +1781,36 @@ export default function ModelTester() {
|
||||
}
|
||||
|
||||
const trimmed = input.trim();
|
||||
if (conversationFiles.length > 0 && customRequestMode) {
|
||||
const message = '自定义请求模式不会自动注入会话附件,请先关闭自定义请求模式或移除附件。';
|
||||
setError(message);
|
||||
pushDebug('warn', message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (conversationFiles.length > 0 && !conversationFileSupported) {
|
||||
const message = '当前协议的会话附件仅支持 OpenAI / Responses;请切换协议或移除附件。';
|
||||
setError(message);
|
||||
pushDebug('warn', message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!customRequestMode && conversationFileSupported && conversationFiles.length > 0) {
|
||||
setSending(true);
|
||||
try {
|
||||
const uploadedFiles = await uploadConversationFiles();
|
||||
setInput('');
|
||||
setConversationFiles([]);
|
||||
await sendWithPrompt(trimmed, messages, uploadedFiles);
|
||||
} catch (uploadError: any) {
|
||||
const message = uploadError?.message || '附件上传失败';
|
||||
setError(message);
|
||||
pushDebug('error', message);
|
||||
setSending(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed.length > 0) {
|
||||
setInput('');
|
||||
await sendWithPrompt(trimmed, messages);
|
||||
@@ -1691,7 +1836,7 @@ export default function ModelTester() {
|
||||
{ stream: inputs.stream, jobMode: !inputs.stream },
|
||||
),
|
||||
);
|
||||
}, [canSend, customRequestBody, customRequestMode, dispatchPayload, input, inputs.mode, messages, pushDebug, sendModeRequest, sendWithPrompt]);
|
||||
}, [canSend, conversationFileSupported, conversationFiles.length, customRequestBody, customRequestMode, dispatchPayload, input, inputs.mode, messages, pushDebug, sendModeRequest, sendWithPrompt, uploadConversationFiles]);
|
||||
|
||||
const retryPending = useCallback(async () => {
|
||||
if (sending || pendingJobId || !pendingPayload) return;
|
||||
@@ -1780,6 +1925,7 @@ export default function ModelTester() {
|
||||
setVideoInspectAction('GET');
|
||||
setImageSourceFile(null);
|
||||
setImageMaskFile(null);
|
||||
setConversationFiles([]);
|
||||
localStorage.removeItem(MODEL_TESTER_STORAGE_KEY);
|
||||
pushDebug('info', '对话已清除。');
|
||||
}, [pendingJobId, pushDebug]);
|
||||
@@ -1867,10 +2013,11 @@ export default function ModelTester() {
|
||||
|
||||
const base = messages.slice(0, userIndex);
|
||||
const prompt = messages[userIndex].content;
|
||||
const files = extractUploadedFilesFromMessage(messages[userIndex]);
|
||||
setEditingMessageId(null);
|
||||
setEditValue('');
|
||||
void sendWithPrompt(prompt, base);
|
||||
}, [messages, pendingJobId, sendWithPrompt, sending]);
|
||||
void sendWithPrompt(prompt, base, files);
|
||||
}, [extractUploadedFilesFromMessage, messages, pendingJobId, sendWithPrompt, sending]);
|
||||
|
||||
const startEditMessage = useCallback((target: ChatMessage) => {
|
||||
if (sending) return;
|
||||
@@ -1904,9 +2051,9 @@ export default function ModelTester() {
|
||||
|
||||
if (retry && target.role === 'user') {
|
||||
const base = updated.slice(0, targetIndex);
|
||||
void sendWithPrompt(nextContent, base);
|
||||
void sendWithPrompt(nextContent, base, extractUploadedFilesFromMessage(target));
|
||||
}
|
||||
}, [cancelEditMessage, editValue, editingMessageId, messages, sendWithPrompt]);
|
||||
}, [cancelEditMessage, editValue, editingMessageId, extractUploadedFilesFromMessage, messages, sendWithPrompt]);
|
||||
|
||||
const syncMessageToBody = useCallback(() => {
|
||||
const nextBody = syncMessagesToCustomRequestBody(customRequestBody, messages, inputs);
|
||||
@@ -2422,6 +2569,9 @@ export default function ModelTester() {
|
||||
const isError = message.status === MESSAGE_STATUS.ERROR;
|
||||
const showReasoning = Boolean(message.reasoningContent);
|
||||
const isEditing = editingMessageId === message.id;
|
||||
const fileParts = Array.isArray(message.parts)
|
||||
? message.parts.filter((part): part is Extract<ConversationContentPart, { type: 'input_file' }> => part.type === 'input_file')
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -2538,6 +2688,34 @@ export default function ModelTester() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isEditing && fileParts.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 8 }}>
|
||||
{fileParts.map((part, index) => (
|
||||
<span
|
||||
key={`${message.id}-file-${part.fileId || part.filename || index}`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '4px 8px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid var(--color-border-light)',
|
||||
background: 'var(--color-bg-subtle)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontSize: 11,
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
title={part.fileId || part.filename || '附件'}
|
||||
>
|
||||
<span>📎</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{part.filename || part.fileId || '附件'}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEditing && (
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{!isLoading && (
|
||||
@@ -2583,25 +2761,123 @@ export default function ModelTester() {
|
||||
|
||||
{inputs.mode === 'conversation' ? (
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end' }}>
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
if (sending) {
|
||||
void stopGenerating();
|
||||
return;
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{
|
||||
padding: '10px 12px',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--color-border-light)',
|
||||
background: 'var(--color-bg-subtle)',
|
||||
}}>
|
||||
<input
|
||||
ref={conversationFileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={CONVERSATION_FILE_ACCEPT}
|
||||
style={{ display: 'none' }}
|
||||
onChange={(event) => {
|
||||
void handleConversationFilesChange(event.target.files);
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)', padding: '6px 10px' }}
|
||||
disabled={sending || customRequestMode || !conversationFileSupported}
|
||||
onClick={() => conversationFileInputRef.current?.click()}
|
||||
>
|
||||
添加文件
|
||||
</button>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
|
||||
{customRequestMode
|
||||
? '自定义请求模式不会自动上传这些附件;关闭自定义模式后可走标准 /v1/files 链路。'
|
||||
: !conversationFileSupported
|
||||
? '当前协议暂不支持会话附件注入;请切换到 OpenAI 或 Responses。'
|
||||
: '支持 PDF / TXT / Markdown / JSON / 图片 / 音频;发送前会先上传到 /v1/files。'}
|
||||
</span>
|
||||
</div>
|
||||
{conversationFiles.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 10 }}>
|
||||
{conversationFiles.map((file) => {
|
||||
const statusText = file.status === 'uploading'
|
||||
? '上传中'
|
||||
: file.status === 'uploaded'
|
||||
? '已上传'
|
||||
: file.status === 'error'
|
||||
? '失败'
|
||||
: '待上传';
|
||||
const statusColor = file.status === 'error'
|
||||
? 'var(--color-danger)'
|
||||
: file.status === 'uploaded'
|
||||
? 'var(--color-success)'
|
||||
: file.status === 'uploading'
|
||||
? 'var(--color-warning)'
|
||||
: 'var(--color-text-muted)';
|
||||
|
||||
return (
|
||||
<span
|
||||
key={file.localId}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
maxWidth: '100%',
|
||||
padding: '6px 10px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid var(--color-border-light)',
|
||||
background: 'var(--color-bg-card)',
|
||||
fontSize: 11,
|
||||
}}
|
||||
title={file.errorMessage || file.fileId || file.name}
|
||||
>
|
||||
<span>📎</span>
|
||||
<span style={{ maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{file.name}
|
||||
</span>
|
||||
<span style={{ color: statusColor }}>· {statusText}</span>
|
||||
{!sending && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeConversationFile(file.localId)}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--color-text-muted)',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
if (sending) {
|
||||
void stopGenerating();
|
||||
return;
|
||||
}
|
||||
void send();
|
||||
}
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
placeholder={customRequestMode
|
||||
? '自定义模式下输入可选。回车发送时将优先使用右侧自定义请求体。'
|
||||
: '输入提示词...(回车发送,Shift+回车换行)'}
|
||||
rows={3}
|
||||
style={{ ...inputBaseStyle, resize: 'none', flex: 1 }}
|
||||
/>
|
||||
}}
|
||||
placeholder={customRequestMode
|
||||
? '自定义模式下输入可选。回车发送时将优先使用右侧自定义请求体。'
|
||||
: '输入提示词,或只上传文件后直接发送…(回车发送,Shift+回车换行)'}
|
||||
rows={3}
|
||||
style={{ ...inputBaseStyle, resize: 'none', flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (sending) {
|
||||
|
||||
@@ -8,16 +8,19 @@ import {
|
||||
MESSAGE_STATUS,
|
||||
buildApiPayload,
|
||||
buildEmbeddingsRequestEnvelope,
|
||||
buildFileUploadRequestEnvelope,
|
||||
buildRawProxyRequestEnvelope,
|
||||
buildSearchRequestEnvelope,
|
||||
collectModelTesterModelNames,
|
||||
countConversationTurns,
|
||||
createConversationUserMessage,
|
||||
filterModelTesterModelNames,
|
||||
parseCustomRequestBody,
|
||||
parseModelTesterSession,
|
||||
serializeModelTesterSession,
|
||||
syncMessagesToCustomRequestBody,
|
||||
toApiMessages,
|
||||
type ChatMessage,
|
||||
type ModelTesterSessionState,
|
||||
} from './modelTesterSession.js';
|
||||
|
||||
@@ -171,6 +174,93 @@ describe('modelTesterSession', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('builds multipart upload envelopes for /v1/files', () => {
|
||||
expect(buildFileUploadRequestEnvelope({
|
||||
name: 'paper.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
dataUrl: 'data:application/pdf;base64,JVBERi0xLjc=',
|
||||
})).toEqual({
|
||||
method: 'POST',
|
||||
path: '/v1/files',
|
||||
requestKind: 'multipart',
|
||||
stream: false,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
multipartFields: {
|
||||
purpose: 'assistants',
|
||||
},
|
||||
multipartFiles: [
|
||||
{
|
||||
field: 'file',
|
||||
name: 'paper.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
dataUrl: 'data:application/pdf;base64,JVBERi0xLjc=',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('creates user conversation messages that preserve uploaded file references', () => {
|
||||
const message = createConversationUserMessage('请总结附件', [
|
||||
{
|
||||
fileId: 'file-metapi-123',
|
||||
filename: 'paper.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(message.role).toBe('user');
|
||||
expect(message.content).toBe('请总结附件');
|
||||
expect(message.parts).toEqual([
|
||||
{
|
||||
type: 'input_file',
|
||||
fileId: 'file-metapi-123',
|
||||
filename: 'paper.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves file reference parts when building responses conversation payloads', () => {
|
||||
const payload = buildApiPayload(
|
||||
[{
|
||||
id: 'u1',
|
||||
role: 'user',
|
||||
content: '请总结上传文件',
|
||||
createAt: 1,
|
||||
parts: [
|
||||
{
|
||||
type: 'input_file',
|
||||
fileId: 'file_123',
|
||||
filename: 'notes.txt',
|
||||
mimeType: 'text/plain',
|
||||
},
|
||||
],
|
||||
} as ChatMessage],
|
||||
{
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'gpt-5',
|
||||
protocol: 'responses',
|
||||
},
|
||||
DEFAULT_PARAMETER_ENABLED,
|
||||
);
|
||||
|
||||
expect(payload.jsonBody).toEqual({
|
||||
model: 'gpt-5',
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'input_text', text: '请总结上传文件' },
|
||||
{ type: 'input_file', file_id: 'file_123', filename: 'notes.txt', mime_type: 'text/plain' },
|
||||
],
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
temperature: 0.7,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds gemini conversation envelope with generationConfig', () => {
|
||||
const payload = buildApiPayload(
|
||||
[{ id: 'u1', role: 'user', content: 'hello', createAt: 1 }],
|
||||
@@ -196,6 +286,87 @@ describe('modelTesterSession', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes conversation file parts into openai and responses payloads', () => {
|
||||
const message = {
|
||||
id: 'u1',
|
||||
role: 'user' as const,
|
||||
content: 'summarize this',
|
||||
createAt: 1,
|
||||
parts: [
|
||||
{
|
||||
type: 'input_file' as const,
|
||||
fileId: 'file_metapi_123',
|
||||
filename: 'paper.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const openaiPayload = buildApiPayload(
|
||||
[message],
|
||||
{
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'gpt-4.1',
|
||||
protocol: 'openai',
|
||||
},
|
||||
DEFAULT_PARAMETER_ENABLED,
|
||||
);
|
||||
|
||||
expect(openaiPayload.jsonBody).toEqual({
|
||||
model: 'gpt-4.1',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'summarize this' },
|
||||
{
|
||||
type: 'file',
|
||||
file: {
|
||||
file_id: 'file_metapi_123',
|
||||
filename: 'paper.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
temperature: 0.7,
|
||||
frequency_penalty: 0,
|
||||
presence_penalty: 0,
|
||||
});
|
||||
|
||||
const responsesPayload = buildApiPayload(
|
||||
[message],
|
||||
{
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'gpt-4.1',
|
||||
protocol: 'responses',
|
||||
},
|
||||
DEFAULT_PARAMETER_ENABLED,
|
||||
);
|
||||
|
||||
expect(responsesPayload.jsonBody).toEqual({
|
||||
model: 'gpt-4.1',
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'input_text', text: 'summarize this' },
|
||||
{
|
||||
type: 'input_file',
|
||||
file_id: 'file_metapi_123',
|
||||
filename: 'paper.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
temperature: 0.7,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds embeddings and search envelopes', () => {
|
||||
expect(buildEmbeddingsRequestEnvelope('hello', { ...DEFAULT_INPUTS, model: 'text-embedding-3-large' })).toEqual({
|
||||
method: 'POST',
|
||||
|
||||
@@ -35,6 +35,7 @@ export type ConversationContentPart =
|
||||
| { type: 'image_url'; url: string }
|
||||
| { type: 'image_inline'; dataUrl: string; mimeType?: string | null }
|
||||
| { type: 'input_audio'; dataUrl: string; mimeType?: string | null }
|
||||
| { type: 'input_file'; fileId?: string | null; filename?: string | null; mimeType?: string | null; data?: string | null }
|
||||
| { type: 'output_audio'; dataUrl: string; mimeType?: string | null }
|
||||
| { type: 'tool_call'; name?: string; argumentsText?: string }
|
||||
| { type: 'tool_result'; name?: string; outputText?: string }
|
||||
@@ -58,6 +59,7 @@ export type ChatMessage = {
|
||||
type ApiChatMessage = {
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
parts?: ConversationContentPart[] | null;
|
||||
};
|
||||
|
||||
export type PlaygroundMultipartFile = {
|
||||
@@ -67,6 +69,12 @@ export type PlaygroundMultipartFile = {
|
||||
dataUrl: string;
|
||||
};
|
||||
|
||||
export type ConversationUploadedFile = {
|
||||
fileId: string;
|
||||
filename?: string | null;
|
||||
mimeType?: string | null;
|
||||
};
|
||||
|
||||
export type TesterProxyEnvelope = {
|
||||
method: ProxyRequestMethod;
|
||||
path: string;
|
||||
@@ -249,21 +257,151 @@ const appendOptionalNumber = (target: Record<string, unknown>, key: string, enab
|
||||
}
|
||||
};
|
||||
|
||||
const parseDataUrl = (dataUrl: string): { mimeType: string; data: string } | null => {
|
||||
const match = /^data:([^;,]+)?;base64,(.+)$/i.exec((dataUrl || '').trim());
|
||||
if (!match) return null;
|
||||
return {
|
||||
mimeType: match[1] || 'application/octet-stream',
|
||||
data: match[2],
|
||||
};
|
||||
};
|
||||
|
||||
const toOpenAiContentPart = (part: ConversationContentPart): Record<string, unknown> | null => {
|
||||
if (part.type === 'image_url') {
|
||||
return {
|
||||
type: 'image_url',
|
||||
image_url: part.url,
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'image_inline') {
|
||||
return {
|
||||
type: 'image_url',
|
||||
image_url: part.dataUrl,
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'input_audio') {
|
||||
const parsed = parseDataUrl(part.dataUrl);
|
||||
if (!parsed) return null;
|
||||
return {
|
||||
type: 'input_audio',
|
||||
input_audio: {
|
||||
data: parsed.data,
|
||||
format: (part.mimeType || parsed.mimeType || 'audio/wav').split('/').pop() || 'wav',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'input_file') {
|
||||
const filePayload: Record<string, unknown> = {};
|
||||
if (typeof part.fileId === 'string' && part.fileId.trim()) filePayload.file_id = part.fileId.trim();
|
||||
if (typeof part.filename === 'string' && part.filename.trim()) filePayload.filename = part.filename.trim();
|
||||
if (typeof part.data === 'string' && part.data.trim()) filePayload.file_data = part.data.trim();
|
||||
if (typeof part.mimeType === 'string' && part.mimeType.trim()) filePayload.mime_type = part.mimeType.trim();
|
||||
if (Object.keys(filePayload).length === 0) return null;
|
||||
return {
|
||||
type: 'file',
|
||||
file: filePayload,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const toResponsesContentPart = (part: ConversationContentPart): Record<string, unknown> | null => {
|
||||
if (part.type === 'image_url') {
|
||||
return {
|
||||
type: 'input_image',
|
||||
image_url: part.url,
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'image_inline') {
|
||||
return {
|
||||
type: 'input_image',
|
||||
image_url: part.dataUrl,
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'input_audio') {
|
||||
const parsed = parseDataUrl(part.dataUrl);
|
||||
if (!parsed) return null;
|
||||
return {
|
||||
type: 'input_audio',
|
||||
input_audio: {
|
||||
data: parsed.data,
|
||||
format: (part.mimeType || parsed.mimeType || 'audio/wav').split('/').pop() || 'wav',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'input_file') {
|
||||
const fileBlock: Record<string, unknown> = {
|
||||
type: 'input_file',
|
||||
};
|
||||
if (typeof part.fileId === 'string' && part.fileId.trim()) fileBlock.file_id = part.fileId.trim();
|
||||
if (typeof part.filename === 'string' && part.filename.trim()) fileBlock.filename = part.filename.trim();
|
||||
if (typeof part.data === 'string' && part.data.trim()) fileBlock.file_data = part.data.trim();
|
||||
if (typeof part.mimeType === 'string' && part.mimeType.trim()) fileBlock.mime_type = part.mimeType.trim();
|
||||
if (Object.keys(fileBlock).length === 1) return null;
|
||||
return fileBlock;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const toGeminiContents = (messages: ApiChatMessage[]) =>
|
||||
messages.map((message) => ({
|
||||
role: message.role === 'assistant' ? 'model' : 'user',
|
||||
parts: [{ text: message.content }],
|
||||
}));
|
||||
|
||||
const toOpenAiMessageContent = (message: ApiChatMessage): unknown => message.content;
|
||||
const toOpenAiMessageContent = (message: ApiChatMessage): unknown => {
|
||||
const parts = Array.isArray(message.parts)
|
||||
? message.parts
|
||||
.map((part) => toOpenAiContentPart(part))
|
||||
.filter((item): item is Record<string, unknown> => !!item)
|
||||
: [];
|
||||
|
||||
if (parts.length <= 0) {
|
||||
return message.content;
|
||||
}
|
||||
|
||||
if (message.content.trim()) {
|
||||
return [
|
||||
{ type: 'text', text: message.content },
|
||||
...parts,
|
||||
];
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
||||
|
||||
const toResponsesInput = (messages: ApiChatMessage[]) => {
|
||||
if (messages.length === 1 && messages[0].role === 'user') {
|
||||
const hasStructuredParts = messages.some((message) => Array.isArray(message.parts) && message.parts.length > 0);
|
||||
if (!hasStructuredParts && messages.length === 1 && messages[0].role === 'user') {
|
||||
return messages[0].content;
|
||||
}
|
||||
return messages.map((message) => ({
|
||||
role: message.role === 'assistant' ? 'assistant' : 'user',
|
||||
content: message.content,
|
||||
content: [
|
||||
...(message.content.trim() ? [{ type: 'input_text', text: message.content }] : []),
|
||||
...((Array.isArray(message.parts)
|
||||
? message.parts
|
||||
.map((part) => toResponsesContentPart(part))
|
||||
.filter((item): item is Record<string, unknown> => !!item)
|
||||
: [])),
|
||||
].length > 0
|
||||
? [
|
||||
...(message.content.trim() ? [{ type: 'input_text', text: message.content }] : []),
|
||||
...((Array.isArray(message.parts)
|
||||
? message.parts
|
||||
.map((part) => toResponsesContentPart(part))
|
||||
.filter((item): item is Record<string, unknown> => !!item)
|
||||
: [])),
|
||||
]
|
||||
: message.content,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -636,6 +774,27 @@ export const createMessage = (role: ChatRole, content: string, extra: Partial<Ch
|
||||
...extra,
|
||||
});
|
||||
|
||||
export const createConversationInputFilePart = (
|
||||
file: ConversationUploadedFile,
|
||||
): ConversationContentPart => ({
|
||||
type: 'input_file',
|
||||
fileId: file.fileId,
|
||||
filename: file.filename ?? null,
|
||||
mimeType: file.mimeType ?? null,
|
||||
});
|
||||
|
||||
export const createConversationUserMessage = (
|
||||
content: string,
|
||||
files: ConversationUploadedFile[] = [],
|
||||
extra: Partial<ChatMessage> = {},
|
||||
): ChatMessage => {
|
||||
const parts = files.map((file) => createConversationInputFilePart(file));
|
||||
return createMessage('user', content, {
|
||||
...extra,
|
||||
...(parts.length > 0 ? { parts } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
export const createLoadingAssistantMessage = (): ChatMessage =>
|
||||
createMessage('assistant', '', {
|
||||
status: MESSAGE_STATUS.LOADING,
|
||||
@@ -772,6 +931,7 @@ export const toApiMessages = (messages: ChatMessage[]): ApiChatMessage[] =>
|
||||
.map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
parts: message.parts,
|
||||
}));
|
||||
|
||||
export const buildConversationRequestEnvelope = (
|
||||
@@ -844,6 +1004,27 @@ export const buildImagesGenerationsRequestEnvelope = (
|
||||
},
|
||||
});
|
||||
|
||||
export const buildFileUploadRequestEnvelope = (
|
||||
file: Omit<PlaygroundMultipartFile, 'field'> & { field?: string },
|
||||
purpose = 'assistants',
|
||||
): TesterProxyEnvelope => ({
|
||||
method: 'POST',
|
||||
path: '/v1/files',
|
||||
requestKind: 'multipart',
|
||||
stream: false,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
multipartFields: {
|
||||
purpose,
|
||||
},
|
||||
multipartFiles: [{
|
||||
field: file.field || 'file',
|
||||
name: file.name,
|
||||
mimeType: file.mimeType,
|
||||
dataUrl: file.dataUrl,
|
||||
}],
|
||||
});
|
||||
|
||||
export const buildImagesEditRequestEnvelope = (
|
||||
inputs: ModelTesterInputs,
|
||||
modeState: ModelTesterModeState,
|
||||
|
||||
Reference in New Issue
Block a user