fix modeltester attachment replay and add proxy file retention
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { config } from '../config.js';
|
||||
import { getLogCleanupCutoffUtc } from './logCleanupService.js';
|
||||
import { purgeExpiredProxyFiles } from './proxyFileStore.js';
|
||||
|
||||
let retentionTimer: ReturnType<typeof setInterval> | 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;
|
||||
}
|
||||
@@ -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<string, any>).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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<boolean> {
|
||||
.run();
|
||||
return Number(result?.changes || 0) > 0;
|
||||
}
|
||||
|
||||
export async function purgeExpiredProxyFiles(cutoffUtc: string): Promise<number> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -97,4 +97,33 @@ describe('api proxy test timeout handling', () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
await expect(promise).resolves.toMatchObject({ message: '请求超时(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<string, any>).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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+100
-28
@@ -4,6 +4,73 @@ type RequestOptions = RequestInit & {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
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<string> {
|
||||
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 request(url: string, options: RequestOptions = {}) {
|
||||
const { timeoutMs = 30_000, signal: externalSignal, ...fetchOptions } = options;
|
||||
const controller = new AbortController();
|
||||
@@ -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<string, string> = {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
};
|
||||
@@ -52,25 +111,7 @@ async function request(url: string, options: RequestOptions = {}) {
|
||||
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);
|
||||
throw new Error(await extractResponseErrorMessage(res));
|
||||
}
|
||||
return res.json();
|
||||
} catch (error: any) {
|
||||
@@ -623,6 +664,37 @@ 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) => {
|
||||
const token = requireAuthToken();
|
||||
const response = await fetch(`/v1/files/${encodeURIComponent(fileId)}/content`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
const hadToken = !!getAuthToken(localStorage);
|
||||
clearAuthSession(localStorage);
|
||||
if (hadToken && typeof window !== 'undefined' && typeof window.location?.reload === 'function') {
|
||||
window.location.reload();
|
||||
}
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
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',
|
||||
|
||||
@@ -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(
|
||||
[{
|
||||
|
||||
@@ -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 : '',
|
||||
|
||||
Reference in New Issue
Block a user