fix modeltester attachment replay and add proxy file retention

This commit is contained in:
cita
2026-03-21 00:04:06 +08:00
parent 94b6cac871
commit 95bf8df499
11 changed files with 676 additions and 43 deletions
+31 -9
View File
@@ -30,10 +30,12 @@ import {
parseCustomRequestBody,
parseModelTesterSession,
processThinkTags,
resolveConversationReplayFiles,
serializeModelTesterSession,
syncCustomRequestBodyToMessages,
syncMessagesToCustomRequestBody,
type ChatMessage,
type ConversationDraftFile,
type ConversationContentPart,
type ConversationUploadedFile,
type DebugTab,
@@ -74,12 +76,7 @@ type UploadState = {
dataUrl: string;
};
type ConversationFileState = UploadState & {
localId: string;
fileId?: string | null;
status: 'pending' | 'uploading' | 'uploaded' | 'error';
errorMessage?: string | null;
};
type ConversationFileState = ConversationDraftFile;
const POLL_INTERVAL_MS = 1200;
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
@@ -767,6 +764,7 @@ export default function ModelTester() {
setAssetPrompt(restored.modeState.imagesPrompt || restored.modeState.videosPrompt);
setVideoInspectId(restored.modeState.videosInspectId);
setVideoInspectAction(restored.inputs.videoInspectAction === 'delete' ? 'DELETE' : 'GET');
setConversationFiles(restored.conversationFiles);
if (restored.pendingJobId) {
setSending(true);
@@ -827,6 +825,7 @@ export default function ModelTester() {
inputs,
parameterEnabled,
messages,
conversationFiles,
modeState: {
embeddingsInput: embeddingInputText,
searchQuery: searchQueryValue,
@@ -852,6 +851,7 @@ export default function ModelTester() {
input,
inputs,
messages,
conversationFiles,
assetPrompt,
customRequestBody,
embeddingInputText,
@@ -1014,6 +1014,19 @@ export default function ModelTester() {
return false;
}, [conversationFileCapability, conversationFileHint, pushDebug]);
const loadLocalConversationFile = useCallback(async (fileId: string) => {
const resolved = await api.getProxyFileContentDataUrl(fileId) as {
filename?: string | null;
mimeType?: string | null;
data: string;
};
return {
filename: resolved.filename || null,
mimeType: resolved.mimeType || null,
data: resolved.data,
};
}, []);
const buildConversationMessagesWithSystem = useCallback((baseMessages: ChatMessage[]) => {
if (!inputs.systemPrompt.trim()) return baseMessages;
return [
@@ -1805,10 +1818,19 @@ export default function ModelTester() {
baseMessages: ChatMessage[],
files: ConversationUploadedFile[] = [],
) => {
if (!ensureSupportedConversationFiles(files)) {
let resolvedFiles = files;
try {
resolvedFiles = await resolveConversationReplayFiles(files, inputs.protocol, loadLocalConversationFile);
} catch (resolveError: any) {
const message = resolveError?.message || '读取会话附件失败';
setError(message);
pushDebug('error', message);
return;
}
const userMessage = createConversationUserMessage(prompt, files);
if (!ensureSupportedConversationFiles(resolvedFiles)) {
return;
}
const userMessage = createConversationUserMessage(prompt, resolvedFiles);
const loadingAssistant = createLoadingAssistantMessage();
const nextMessages = [...baseMessages, userMessage, loadingAssistant];
const useProxyTransport = inputs.protocol === 'gemini' || customRequestMode;
@@ -1826,7 +1848,7 @@ export default function ModelTester() {
}
await dispatchPayload(nextMessages, payload, { syncedCustomBody });
}, [buildConversationProxyEnvelope, buildPayloadWithMessages, createConversationUserMessage, customRequestMode, dispatchPayload, dispatchProxyEnvelope, ensureSupportedConversationFiles, inputs.protocol, pushDebug]);
}, [buildConversationProxyEnvelope, buildPayloadWithMessages, createConversationUserMessage, customRequestMode, dispatchPayload, dispatchProxyEnvelope, ensureSupportedConversationFiles, inputs.protocol, loadLocalConversationFile, pushDebug]);
const sendModeRequest = useCallback(async () => {
const envelope = buildModeProxyEnvelope();
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import * as modelTesterSessionModule from './modelTesterSession.js';
import {
DEBUG_TABS,
DEFAULT_INPUTS,
@@ -80,6 +81,17 @@ describe('modelTesterSession', () => {
customRequestBody: '{"model":"gemini-2.5-pro","contents":[]}',
showDebugPanel: true,
activeDebugTab: DEBUG_TABS.REQUEST,
conversationFiles: [
{
localId: 'draft-file-1',
name: 'draft.pdf',
mimeType: 'application/pdf',
dataUrl: 'data:application/pdf;base64,JVBERi0x',
fileId: 'file-metapi-draft',
status: 'uploaded',
errorMessage: null,
},
],
modeState: {
...DEFAULT_MODE_STATE,
searchQuery: 'hello',
@@ -273,6 +285,37 @@ describe('modelTesterSession', () => {
]);
});
it('hydrates local file ids into inline replay files for claude', async () => {
const resolveConversationReplayFiles = (modelTesterSessionModule as Record<string, any>).resolveConversationReplayFiles;
const loader = vi.fn(async () => ({
filename: 'brief.pdf',
mimeType: 'application/pdf',
data: 'data:application/pdf;base64,JVBERi0x',
}));
const files = await resolveConversationReplayFiles?.(
[
{
fileId: 'file-metapi-123',
filename: 'brief.pdf',
mimeType: 'application/pdf',
},
],
'claude',
loader,
);
expect(loader).toHaveBeenCalledWith('file-metapi-123');
expect(files).toEqual([
{
fileId: 'file-metapi-123',
filename: 'brief.pdf',
mimeType: 'application/pdf',
data: 'data:application/pdf;base64,JVBERi0x',
},
]);
});
it('preserves file reference parts when building responses conversation payloads', () => {
const payload = buildApiPayload(
[{
@@ -486,6 +529,136 @@ describe('modelTesterSession', () => {
});
});
it('prefers native file ids over inline backups for OpenAI and Responses payloads', () => {
const message = {
id: 'u1',
role: 'user' as const,
content: 'reuse native file id',
createAt: 1,
parts: [
{
type: 'input_file' as const,
fileId: 'file-metapi-123',
filename: 'brief.pdf',
mimeType: 'application/pdf',
data: 'data:application/pdf;base64,JVBERi0x',
},
],
};
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: 'reuse native file id' },
{
type: 'file',
file: {
file_id: 'file-metapi-123',
filename: 'brief.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: 'reuse native file id' },
{
type: 'input_file',
file_id: 'file-metapi-123',
filename: 'brief.pdf',
},
],
},
],
stream: false,
temperature: 0.7,
});
});
it('strips data URL wrappers when replaying inline files into OpenAI chat payloads', () => {
const payload = buildApiPayload(
[{
id: 'u1',
role: 'user',
content: 'summarize this inline file',
createAt: 1,
parts: [
{
type: 'input_file',
filename: 'brief.pdf',
mimeType: 'application/pdf',
data: 'data:application/pdf;base64,JVBERi0x',
},
],
} as ChatMessage],
{
...DEFAULT_INPUTS,
model: 'gpt-4.1',
protocol: 'openai',
},
DEFAULT_PARAMETER_ENABLED,
);
expect(payload.jsonBody).toEqual({
model: 'gpt-4.1',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'summarize this inline file' },
{
type: 'file',
file: {
file_data: 'JVBERi0x',
filename: 'brief.pdf',
mime_type: 'application/pdf',
},
},
],
},
],
stream: false,
temperature: 0.7,
frequency_penalty: 0,
presence_penalty: 0,
});
});
it('builds Claude conversation payloads with inline document blocks for conversation files', () => {
const payload = buildApiPayload(
[{
+106 -4
View File
@@ -76,6 +76,16 @@ export type ConversationUploadedFile = {
data?: string | null;
};
export type ConversationDraftFile = {
localId: string;
name: string;
mimeType: string;
dataUrl: string;
fileId?: string | null;
status: 'pending' | 'uploading' | 'uploaded' | 'error';
errorMessage?: string | null;
};
export type TesterProxyEnvelope = {
method: ProxyRequestMethod;
path: string;
@@ -133,6 +143,7 @@ export type ModelTesterSessionState = {
inputs: ModelTesterInputs;
parameterEnabled: ParameterEnabled;
messages: ChatMessage[];
conversationFiles: ConversationDraftFile[];
pendingPayload: TesterProxyEnvelope | null;
pendingJobId?: string | null;
customRequestMode: boolean;
@@ -200,8 +211,10 @@ const VALID_MODES: ReadonlySet<string> = new Set([
'videos.inspect',
]);
const VALID_PROTOCOLS: ReadonlySet<string> = new Set(['openai', 'responses', 'claude', 'gemini']);
const VALID_CONVERSATION_DRAFT_STATUSES: ReadonlySet<string> = new Set(['pending', 'uploading', 'uploaded', 'error']);
const VALID_PROXY_METHODS: ReadonlySet<string> = new Set(['POST', 'GET', 'DELETE']);
const VALID_REQUEST_KINDS: ReadonlySet<string> = new Set(['json', 'multipart', 'empty']);
const LOCAL_PROXY_FILE_ID_PREFIX = 'file-metapi-';
let messageCounter = 0;
@@ -296,10 +309,20 @@ const toOpenAiContentPart = (part: ConversationContentPart): Record<string, unkn
if (part.type === 'input_file') {
const filePayload: Record<string, unknown> = {};
if (typeof part.data === 'string' && part.data.trim()) filePayload.file_data = part.data.trim();
else if (typeof part.fileId === 'string' && part.fileId.trim()) filePayload.file_id = part.fileId.trim();
const fileId = typeof part.fileId === 'string' && part.fileId.trim()
? part.fileId.trim()
: '';
const rawData = typeof part.data === 'string' && part.data.trim()
? part.data.trim()
: '';
const parsed = rawData
? parseDataUrl(rawData)
: null;
if (fileId) filePayload.file_id = fileId;
else if (rawData) filePayload.file_data = parsed?.data || rawData;
if (typeof part.filename === 'string' && part.filename.trim()) filePayload.filename = part.filename.trim();
if (typeof part.mimeType === 'string' && part.mimeType.trim()) filePayload.mime_type = part.mimeType.trim();
else if (parsed?.mimeType) filePayload.mime_type = parsed.mimeType;
if (Object.keys(filePayload).length === 0) return null;
return {
type: 'file',
@@ -341,8 +364,8 @@ const toResponsesContentPart = (part: ConversationContentPart): Record<string, u
const fileBlock: Record<string, unknown> = {
type: 'input_file',
};
if (typeof part.data === 'string' && part.data.trim()) fileBlock.file_data = part.data.trim();
else if (typeof part.fileId === 'string' && part.fileId.trim()) fileBlock.file_id = part.fileId.trim();
if (typeof part.fileId === 'string' && part.fileId.trim()) fileBlock.file_id = part.fileId.trim();
else if (typeof part.data === 'string' && part.data.trim()) fileBlock.file_data = part.data.trim();
if (typeof part.filename === 'string' && part.filename.trim()) fileBlock.filename = part.filename.trim();
if (Object.keys(fileBlock).length === 1) return null;
return fileBlock;
@@ -702,6 +725,42 @@ const parseParameterEnabled = (value: unknown): ParameterEnabled => {
};
};
const parseConversationDraftFiles = (value: unknown): ConversationDraftFile[] => {
if (!Array.isArray(value)) return [];
return value.flatMap((item, index) => {
if (!isRecord(item)) return [];
const localId = typeof item.localId === 'string' && item.localId.trim()
? item.localId.trim()
: `draft-file-restored-${index}`;
const name = typeof item.name === 'string' && item.name.trim()
? item.name.trim()
: 'upload.bin';
const mimeType = typeof item.mimeType === 'string' && item.mimeType.trim()
? item.mimeType.trim()
: 'application/octet-stream';
const dataUrl = typeof item.dataUrl === 'string' && item.dataUrl.trim()
? item.dataUrl.trim()
: '';
if (!dataUrl) return [];
return [{
localId,
name,
mimeType,
dataUrl,
...(typeof item.fileId === 'string' && item.fileId.trim()
? { fileId: item.fileId.trim() }
: {}),
status: typeof item.status === 'string' && VALID_CONVERSATION_DRAFT_STATUSES.has(item.status)
? item.status as ConversationDraftFile['status']
: 'pending',
errorMessage: typeof item.errorMessage === 'string'
? item.errorMessage
: null,
}];
});
};
const parseModeState = (value: unknown): ModelTesterModeState => {
if (!isRecord(value)) return { ...DEFAULT_MODE_STATE };
return {
@@ -943,6 +1002,48 @@ export const extractConversationUploadedFilesFromMessage = (
});
};
export async function resolveConversationReplayFiles(
files: ConversationUploadedFile[],
protocol: PlaygroundProtocol,
loadLocalFile: (fileId: string) => Promise<{ filename?: string | null; mimeType?: string | null; data: string }>,
): Promise<ConversationUploadedFile[]> {
if (protocol !== 'claude' && protocol !== 'gemini') {
return files.map((file) => ({ ...file }));
}
return Promise.all(files.map(async (file) => {
const fileId = typeof file.fileId === 'string' && file.fileId.trim()
? file.fileId.trim()
: '';
const data = typeof file.data === 'string' && file.data.trim()
? file.data.trim()
: '';
if (!fileId || data) {
return { ...file };
}
if (!fileId.startsWith(LOCAL_PROXY_FILE_ID_PREFIX)) {
throw new Error(`会话附件 ${fileId} 只有 file_id,当前协议需要可重放的内联数据。`);
}
const resolved = await loadLocalFile(fileId);
const resolvedData = typeof resolved?.data === 'string' ? resolved.data.trim() : '';
if (!resolvedData) {
throw new Error(`会话附件 ${fileId} 缺少可重放的数据。`);
}
return {
...file,
...(typeof resolved?.filename === 'string' && resolved.filename.trim()
? { filename: resolved.filename.trim() }
: {}),
...(typeof resolved?.mimeType === 'string' && resolved.mimeType.trim()
? { mimeType: resolved.mimeType.trim() }
: {}),
data: resolvedData,
};
}));
}
export const createLoadingAssistantMessage = (): ChatMessage =>
createMessage('assistant', '', {
status: MESSAGE_STATUS.LOADING,
@@ -1047,6 +1148,7 @@ export const parseModelTesterSession = (raw: string | null): ModelTesterSessionS
inputs,
parameterEnabled,
messages: sanitizeMessages(parsed.messages),
conversationFiles: parseConversationDraftFiles(parsed.conversationFiles),
pendingPayload: parsePendingPayload(parsed.pendingPayload, inputs, parameterEnabled),
customRequestMode: toBoolean(parsed.customRequestMode, false),
customRequestBody: typeof parsed.customRequestBody === 'string' ? parsed.customRequestBody : '',