diff --git a/src/server/config.ts b/src/server/config.ts index 80d11df..7f1f083 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -110,6 +110,8 @@ export function buildConfig(env: NodeJS.ProcessEnv) { tokenRouterCacheTtlMs: Math.max(100, Math.trunc(parseNumber(env.TOKEN_ROUTER_CACHE_TTL_MS, 1_500))), proxyLogRetentionDays: Math.max(0, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_DAYS, 30))), proxyLogRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_PRUNE_INTERVAL_MINUTES, 30))), + proxyFileRetentionDays: Math.max(0, Math.trunc(parseNumber(env.PROXY_FILE_RETENTION_DAYS, 30))), + proxyFileRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_FILE_RETENTION_PRUNE_INTERVAL_MINUTES, 60))), proxyErrorKeywords: parseCsvList(env.PROXY_ERROR_KEYWORDS), proxyEmptyContentFailEnabled: parseBoolean(env.PROXY_EMPTY_CONTENT_FAIL, false), codexHeaderDefaults: { diff --git a/src/server/index.ts b/src/server/index.ts index c6b388d..2963c40 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -23,6 +23,7 @@ import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js'; import { proxyRoutes } from './routes/proxy/router.js'; import { startScheduler } from './services/checkinScheduler.js'; import { rebuildTokenRoutesFromAvailability } from './services/modelService.js'; +import { startProxyFileRetentionService, stopProxyFileRetentionService } from './services/proxyFileRetentionService.js'; import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js'; import { buildStartupSummaryLines } from './services/startupInfo.js'; import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js'; @@ -406,7 +407,9 @@ try { console.warn(`Failed to start OAuth callback listeners: ${(error as Error)?.message || 'unknown error'}`); } setLegacyProxyLogRetentionFallbackEnabled(!config.logCleanupConfigured); +startProxyFileRetentionService(); app.addHook('onClose', async () => { + stopProxyFileRetentionService(); stopProxyLogRetentionService(); await stopOAuthLoopbackCallbackServers(); }); diff --git a/src/server/services/proxyFileRetentionService.test.ts b/src/server/services/proxyFileRetentionService.test.ts new file mode 100644 index 0000000..c139573 --- /dev/null +++ b/src/server/services/proxyFileRetentionService.test.ts @@ -0,0 +1,112 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +type DbModule = typeof import('../db/index.js'); +type StoreModule = typeof import('./proxyFileStore.js'); +type RetentionModule = typeof import('./proxyFileRetentionService.js'); +type ConfigModule = typeof import('../config.js'); + +describe('proxyFileRetentionService', () => { + let db: DbModule['db']; + let schema: DbModule['schema']; + let store: StoreModule; + let retention: RetentionModule; + let config: ConfigModule['config']; + let dataDir = ''; + let originalRetentionDays = 0; + let originalPruneIntervalMinutes = 0; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-proxy-file-retention-')); + process.env.DATA_DIR = dataDir; + + await import('../db/migrate.js'); + const dbModule = await import('../db/index.js'); + const storeModule = await import('./proxyFileStore.js'); + const retentionModule = await import('./proxyFileRetentionService.js'); + const configModule = await import('../config.js'); + db = dbModule.db; + schema = dbModule.schema; + store = storeModule; + retention = retentionModule; + config = configModule.config; + originalRetentionDays = config.proxyFileRetentionDays; + originalPruneIntervalMinutes = config.proxyFileRetentionPruneIntervalMinutes; + }); + + beforeEach(async () => { + config.proxyFileRetentionDays = originalRetentionDays; + config.proxyFileRetentionPruneIntervalMinutes = originalPruneIntervalMinutes; + await db.delete(schema.proxyFiles).run(); + }); + + afterAll(async () => { + config.proxyFileRetentionDays = originalRetentionDays; + config.proxyFileRetentionPruneIntervalMinutes = originalPruneIntervalMinutes; + const dbModule = await import('../db/index.js'); + await dbModule.closeDbConnections(); + delete process.env.DATA_DIR; + }); + + it('returns disabled when proxy file retention is turned off', async () => { + config.proxyFileRetentionDays = 0; + + const result = await retention.cleanupExpiredProxyFiles(Date.parse('2026-03-20T00:00:00Z')); + + expect(result).toEqual({ + enabled: false, + retentionDays: 0, + cutoffUtc: null, + deleted: 0, + }); + }); + + it('purges files older than the configured retention window', async () => { + config.proxyFileRetentionDays = 7; + + await db.insert(schema.proxyFiles).values([ + { + publicId: 'file-metapi-old', + ownerType: 'global_proxy_token', + ownerId: 'global', + filename: 'old.txt', + mimeType: 'text/plain', + purpose: 'assistants', + byteSize: 3, + sha256: 'old-hash', + contentBase64: Buffer.from('old').toString('base64'), + createdAt: '2026-03-10 00:00:00', + updatedAt: '2026-03-10 00:00:00', + deletedAt: null, + }, + { + publicId: 'file-metapi-new', + ownerType: 'global_proxy_token', + ownerId: 'global', + filename: 'new.txt', + mimeType: 'text/plain', + purpose: 'assistants', + byteSize: 3, + sha256: 'new-hash', + contentBase64: Buffer.from('new').toString('base64'), + createdAt: '2026-03-18 00:00:00', + updatedAt: '2026-03-18 00:00:00', + deletedAt: null, + }, + ]).run(); + + const result = await retention.cleanupExpiredProxyFiles(Date.parse('2026-03-20T00:00:00Z')); + + expect(result).toEqual({ + enabled: true, + retentionDays: 7, + cutoffUtc: '2026-03-13 00:00:00', + deleted: 1, + }); + + const remaining = await store.listProxyFilesByOwner({ ownerType: 'global_proxy_token', ownerId: 'global' }); + expect(remaining.map((item) => item.publicId)).toEqual(['file-metapi-new']); + }); +}); diff --git a/src/server/services/proxyFileRetentionService.ts b/src/server/services/proxyFileRetentionService.ts new file mode 100644 index 0000000..8b5cd62 --- /dev/null +++ b/src/server/services/proxyFileRetentionService.ts @@ -0,0 +1,63 @@ +import { config } from '../config.js'; +import { getLogCleanupCutoffUtc } from './logCleanupService.js'; +import { purgeExpiredProxyFiles } from './proxyFileStore.js'; + +let retentionTimer: ReturnType | null = null; + +export function getProxyFileRetentionCutoffUtc(nowMs = Date.now()): string | null { + const days = Math.max(0, Math.trunc(config.proxyFileRetentionDays)); + if (days <= 0) return null; + return getLogCleanupCutoffUtc(days, nowMs); +} + +export async function cleanupExpiredProxyFiles(nowMs = Date.now()): Promise<{ + enabled: boolean; + retentionDays: number; + cutoffUtc: string | null; + deleted: number; +}> { + const retentionDays = Math.max(0, Math.trunc(config.proxyFileRetentionDays)); + const cutoffUtc = getProxyFileRetentionCutoffUtc(nowMs); + if (!cutoffUtc) { + return { + enabled: false, + retentionDays, + cutoffUtc: null, + deleted: 0, + }; + } + + const deleted = await purgeExpiredProxyFiles(cutoffUtc); + return { + enabled: true, + retentionDays, + cutoffUtc, + deleted, + }; +} + +export function startProxyFileRetentionService(): void { + if (retentionTimer) return; + + const intervalMinutes = Math.max(1, Math.trunc(config.proxyFileRetentionPruneIntervalMinutes)); + const intervalMs = intervalMinutes * 60 * 1000; + const runCleanup = async () => { + try { + const result = await cleanupExpiredProxyFiles(); + if (!result.enabled || result.deleted <= 0) return; + console.info(`[proxy-file-retention] deleted ${result.deleted} files before ${result.cutoffUtc}`); + } catch (error) { + console.warn('[proxy-file-retention] cleanup failed', error); + } + }; + + void runCleanup(); + retentionTimer = setInterval(() => { void runCleanup(); }, intervalMs); + retentionTimer.unref?.(); +} + +export function stopProxyFileRetentionService(): void { + if (!retentionTimer) return; + clearInterval(retentionTimer); + retentionTimer = null; +} diff --git a/src/server/services/proxyFileStore.test.ts b/src/server/services/proxyFileStore.test.ts index 8f7431d..05e0a7b 100644 --- a/src/server/services/proxyFileStore.test.ts +++ b/src/server/services/proxyFileStore.test.ts @@ -112,4 +112,45 @@ describe('proxyFileStore', () => { }); expect(loaded).toBeNull(); }); + + it('purges expired proxy files before a cutoff to control database growth', async () => { + await db.insert(schema.proxyFiles).values([ + { + publicId: 'file-metapi-old', + ownerType: 'global_proxy_token', + ownerId: 'global', + filename: 'old.txt', + mimeType: 'text/plain', + purpose: 'assistants', + byteSize: 3, + sha256: 'old-hash', + contentBase64: Buffer.from('old').toString('base64'), + createdAt: '2026-03-01 00:00:00', + updatedAt: '2026-03-01 00:00:00', + deletedAt: null, + }, + { + publicId: 'file-metapi-new', + ownerType: 'global_proxy_token', + ownerId: 'global', + filename: 'new.txt', + mimeType: 'text/plain', + purpose: 'assistants', + byteSize: 3, + sha256: 'new-hash', + contentBase64: Buffer.from('new').toString('base64'), + createdAt: '2026-03-19 00:00:00', + updatedAt: '2026-03-19 00:00:00', + deletedAt: null, + }, + ]).run(); + + const purgeExpiredProxyFiles = (store as Record).purgeExpiredProxyFiles; + const deleted = await purgeExpiredProxyFiles?.('2026-03-10 00:00:00'); + + expect(deleted).toBe(1); + + const remainingRows = await db.select().from(schema.proxyFiles).all(); + expect(remainingRows.map((row) => row.publicId)).toEqual(['file-metapi-new']); + }); }); diff --git a/src/server/services/proxyFileStore.ts b/src/server/services/proxyFileStore.ts index 1324169..63ca312 100644 --- a/src/server/services/proxyFileStore.ts +++ b/src/server/services/proxyFileStore.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, isNull, or } from 'drizzle-orm'; +import { and, desc, eq, isNull, lt, or } from 'drizzle-orm'; import { createHash } from 'node:crypto'; import { db, schema } from '../db/index.js'; import type { ProxyResourceOwner } from '../middleware/auth.js'; @@ -204,3 +204,17 @@ export async function softDeleteProxyFile(publicId: string): Promise { .run(); return Number(result?.changes || 0) > 0; } + +export async function purgeExpiredProxyFiles(cutoffUtc: string): Promise { + const normalizedCutoff = cutoffUtc.trim(); + if (!normalizedCutoff) return 0; + + const result = await db.delete(schema.proxyFiles) + .where(or( + lt(schema.proxyFiles.createdAt, normalizedCutoff), + and(isNull(schema.proxyFiles.createdAt), lt(schema.proxyFiles.updatedAt, normalizedCutoff)), + )) + .run(); + + return Number(result?.changes || 0); +} diff --git a/src/web/api.test.ts b/src/web/api.test.ts index 1c0a0fa..69297ad 100644 --- a/src/web/api.test.ts +++ b/src/web/api.test.ts @@ -97,4 +97,56 @@ describe('api proxy test timeout handling', () => { await vi.advanceTimersByTimeAsync(30_000); await expect(promise).resolves.toMatchObject({ message: '请求超时(30s)' }); }); + + it('times out replay hydration file-content fetches after 30 seconds', async () => { + installPendingFetch(); + + const getProxyFileContentDataUrl = (api as Record).getProxyFileContentDataUrl; + let settled = false; + const handled = getProxyFileContentDataUrl?.('file-metapi-123') + .then(() => ({ ok: true as const })) + .catch((error: Error) => ({ ok: false as const, error })) + .finally(() => { + settled = true; + }); + + await vi.advanceTimersByTimeAsync(30_000); + expect(settled).toBe(true); + + const result = await handled; + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error('Expected replay hydration file-content fetch to time out'); + } + expect(result.error.message).toBe('请求超时(30s)'); + }); + + it('loads proxy file content as a data URL for replay hydration', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response( + new Blob([Buffer.from('PDF')], { type: 'application/pdf' }), + { + status: 200, + headers: { + 'content-type': 'application/pdf', + 'content-disposition': 'inline; filename="brief.pdf"', + }, + }, + )); + vi.stubGlobal('fetch', fetchMock); + + const getProxyFileContentDataUrl = (api as Record).getProxyFileContentDataUrl; + const result = await getProxyFileContentDataUrl?.('file-metapi-123'); + + expect(fetchMock).toHaveBeenCalledWith('/v1/files/file-metapi-123/content', expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: 'Bearer token-1', + }), + })); + expect(result).toEqual({ + filename: 'brief.pdf', + mimeType: 'application/pdf', + data: 'data:application/pdf;base64,UERG', + }); + }); }); diff --git a/src/web/api.ts b/src/web/api.ts index abccbef..3e231f1 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -4,7 +4,74 @@ type RequestOptions = RequestInit & { timeoutMs?: number; }; -async function request(url: string, options: RequestOptions = {}) { +function requireAuthToken(): string { + const token = getAuthToken(localStorage); + if (!token) { + const hadToken = !!localStorage.getItem('auth_token'); + clearAuthSession(localStorage); + if (hadToken && typeof window !== 'undefined' && typeof window.location?.reload === 'function') { + window.location.reload(); + } + throw new Error('Session expired'); + } + return token; +} + +async function extractResponseErrorMessage(res: Response): Promise { + let message = `HTTP ${res.status}`; + try { + const text = await res.text(); + if (text) { + try { + const json = JSON.parse(text); + if (json?.message && typeof json.message === 'string') { + message = json.message; + } else if (json?.error && typeof json.error === 'string') { + message = json.error; + } else if (json?.error?.message && typeof json.error.message === 'string') { + message = json.error.message; + } else { + message = `${message}: ${text.slice(0, 120)}`; + } + } catch { + message = `${message}: ${text.slice(0, 120)}`; + } + } + } catch { } + return message; +} + +function parseContentDispositionFilename(headerValue: string | null): string | null { + if (!headerValue) return null; + const utf8Match = /filename\*=UTF-8''([^;]+)/i.exec(headerValue); + if (utf8Match?.[1]) { + try { + return decodeURIComponent(utf8Match[1]); + } catch { + return utf8Match[1]; + } + } + const quotedMatch = /filename="([^"]+)"/i.exec(headerValue); + if (quotedMatch?.[1]) return quotedMatch[1]; + const bareMatch = /filename=([^;]+)/i.exec(headerValue); + return bareMatch?.[1]?.trim() || null; +} + +function arrayBufferToBase64(buffer: ArrayBuffer): string { + if (typeof Buffer !== 'undefined') { + return Buffer.from(buffer).toString('base64'); + } + + let binary = ''; + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + for (let index = 0; index < bytes.length; index += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)); + } + return btoa(binary); +} + +async function fetchAuthenticatedResponse(url: string, options: RequestOptions = {}): Promise { const { timeoutMs = 30_000, signal: externalSignal, ...fetchOptions } = options; const controller = new AbortController(); let timeoutHandle: ReturnType | null = setTimeout(() => { @@ -22,15 +89,7 @@ async function request(url: string, options: RequestOptions = {}) { } } - const token = getAuthToken(localStorage); - if (!token) { - const hadToken = !!localStorage.getItem('auth_token'); - clearAuthSession(localStorage); - if (hadToken && typeof window !== 'undefined' && typeof window.location?.reload === 'function') { - window.location.reload(); - } - throw new Error('Session expired'); - } + const token = requireAuthToken(); const headers: Record = { 'Authorization': `Bearer ${token}`, }; @@ -48,31 +107,12 @@ async function request(url: string, options: RequestOptions = {}) { if (res.status === 401 || res.status === 403) { const hadToken = !!getAuthToken(localStorage); clearAuthSession(localStorage); - if (hadToken) window.location.reload(); + if (hadToken && typeof window !== 'undefined' && typeof window.location?.reload === 'function') { + window.location.reload(); + } throw new Error('Session expired'); } - if (!res.ok) { - let message = `HTTP ${res.status}`; - try { - const text = await res.text(); - if (text) { - try { - const json = JSON.parse(text); - if (json?.message && typeof json.message === 'string') { - message = json.message; - } else if (json?.error && typeof json.error === 'string') { - message = json.error; - } else { - message = `${message}: ${text.slice(0, 120)}`; - } - } catch { - message = `${message}: ${text.slice(0, 120)}`; - } - } - } catch { } - throw new Error(message); - } - return res.json(); + return res; } catch (error: any) { if (error?.name === 'AbortError') { if (externalSignal?.aborted) throw error; @@ -88,6 +128,14 @@ async function request(url: string, options: RequestOptions = {}) { } } +async function request(url: string, options: RequestOptions = {}) { + const res = await fetchAuthenticatedResponse(url, options); + if (!res.ok) { + throw new Error(await extractResponseErrorMessage(res)); + } + return res.json(); +} + function buildQueryString(params?: Record) { if (!params) return ''; const searchParams = new URLSearchParams(); @@ -623,6 +671,29 @@ export const api = { }), getProxyTestJob: (jobId: string) => request(`/api/test/proxy/jobs/${encodeURIComponent(jobId)}`), deleteProxyTestJob: (jobId: string) => request(`/api/test/proxy/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE' }), + getProxyFileContentDataUrl: async ( + fileId: string, + options: Pick = {}, + ) => { + const response = await fetchAuthenticatedResponse(`/v1/files/${encodeURIComponent(fileId)}/content`, { + method: 'GET', + ...options, + }); + if (!response.ok) { + throw new Error(await extractResponseErrorMessage(response)); + } + + const mimeType = (response.headers.get('content-type') || 'application/octet-stream') + .split(';')[0] + .trim() || 'application/octet-stream'; + const filename = parseContentDispositionFilename(response.headers.get('content-disposition')); + const base64 = arrayBufferToBase64(await response.arrayBuffer()); + return { + filename, + mimeType, + data: `data:${mimeType};base64,${base64}`, + }; + }, testProxy: (data: ProxyTestRequestEnvelope) => request('/api/test/proxy', { method: 'POST', diff --git a/src/web/pages/ModelTester.tsx b/src/web/pages/ModelTester.tsx index 759e3c4..a930b21 100644 --- a/src/web/pages/ModelTester.tsx +++ b/src/web/pages/ModelTester.tsx @@ -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((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(); diff --git a/src/web/pages/helpers/modelTesterSession.test.ts b/src/web/pages/helpers/modelTesterSession.test.ts index 6fa358d..2133ec1 100644 --- a/src/web/pages/helpers/modelTesterSession.test.ts +++ b/src/web/pages/helpers/modelTesterSession.test.ts @@ -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).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( [{ diff --git a/src/web/pages/helpers/modelTesterSession.ts b/src/web/pages/helpers/modelTesterSession.ts index 5c7f2a1..b8ff818 100644 --- a/src/web/pages/helpers/modelTesterSession.ts +++ b/src/web/pages/helpers/modelTesterSession.ts @@ -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 = new Set([ 'videos.inspect', ]); const VALID_PROTOCOLS: ReadonlySet = new Set(['openai', 'responses', 'claude', 'gemini']); +const VALID_CONVERSATION_DRAFT_STATUSES: ReadonlySet = new Set(['pending', 'uploading', 'uploaded', 'error']); const VALID_PROXY_METHODS: ReadonlySet = new Set(['POST', 'GET', 'DELETE']); const VALID_REQUEST_KINDS: ReadonlySet = 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 = {}; - 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 = { 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 { + 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 : '',