forked from boos/BoosAPI
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))),
|
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))),
|
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))),
|
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),
|
proxyErrorKeywords: parseCsvList(env.PROXY_ERROR_KEYWORDS),
|
||||||
proxyEmptyContentFailEnabled: parseBoolean(env.PROXY_EMPTY_CONTENT_FAIL, false),
|
proxyEmptyContentFailEnabled: parseBoolean(env.PROXY_EMPTY_CONTENT_FAIL, false),
|
||||||
codexHeaderDefaults: {
|
codexHeaderDefaults: {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js';
|
|||||||
import { proxyRoutes } from './routes/proxy/router.js';
|
import { proxyRoutes } from './routes/proxy/router.js';
|
||||||
import { startScheduler } from './services/checkinScheduler.js';
|
import { startScheduler } from './services/checkinScheduler.js';
|
||||||
import { rebuildTokenRoutesFromAvailability } from './services/modelService.js';
|
import { rebuildTokenRoutesFromAvailability } from './services/modelService.js';
|
||||||
|
import { startProxyFileRetentionService, stopProxyFileRetentionService } from './services/proxyFileRetentionService.js';
|
||||||
import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
|
import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
|
||||||
import { buildStartupSummaryLines } from './services/startupInfo.js';
|
import { buildStartupSummaryLines } from './services/startupInfo.js';
|
||||||
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.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'}`);
|
console.warn(`Failed to start OAuth callback listeners: ${(error as Error)?.message || 'unknown error'}`);
|
||||||
}
|
}
|
||||||
setLegacyProxyLogRetentionFallbackEnabled(!config.logCleanupConfigured);
|
setLegacyProxyLogRetentionFallbackEnabled(!config.logCleanupConfigured);
|
||||||
|
startProxyFileRetentionService();
|
||||||
app.addHook('onClose', async () => {
|
app.addHook('onClose', async () => {
|
||||||
|
stopProxyFileRetentionService();
|
||||||
stopProxyLogRetentionService();
|
stopProxyLogRetentionService();
|
||||||
await stopOAuthLoopbackCallbackServers();
|
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();
|
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 { createHash } from 'node:crypto';
|
||||||
import { db, schema } from '../db/index.js';
|
import { db, schema } from '../db/index.js';
|
||||||
import type { ProxyResourceOwner } from '../middleware/auth.js';
|
import type { ProxyResourceOwner } from '../middleware/auth.js';
|
||||||
@@ -204,3 +204,17 @@ export async function softDeleteProxyFile(publicId: string): Promise<boolean> {
|
|||||||
.run();
|
.run();
|
||||||
return Number(result?.changes || 0) > 0;
|
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 vi.advanceTimersByTimeAsync(30_000);
|
||||||
await expect(promise).resolves.toMatchObject({ message: '请求超时(30s)' });
|
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;
|
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 = {}) {
|
async function request(url: string, options: RequestOptions = {}) {
|
||||||
const { timeoutMs = 30_000, signal: externalSignal, ...fetchOptions } = options;
|
const { timeoutMs = 30_000, signal: externalSignal, ...fetchOptions } = options;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -22,15 +89,7 @@ async function request(url: string, options: RequestOptions = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = getAuthToken(localStorage);
|
const token = requireAuthToken();
|
||||||
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 headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
};
|
};
|
||||||
@@ -52,25 +111,7 @@ async function request(url: string, options: RequestOptions = {}) {
|
|||||||
throw new Error('Session expired');
|
throw new Error('Session expired');
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let message = `HTTP ${res.status}`;
|
throw new Error(await extractResponseErrorMessage(res));
|
||||||
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.json();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -623,6 +664,37 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
getProxyTestJob: (jobId: string) => request(`/api/test/proxy/jobs/${encodeURIComponent(jobId)}`),
|
getProxyTestJob: (jobId: string) => request(`/api/test/proxy/jobs/${encodeURIComponent(jobId)}`),
|
||||||
deleteProxyTestJob: (jobId: string) => request(`/api/test/proxy/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE' }),
|
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) =>
|
testProxy: (data: ProxyTestRequestEnvelope) =>
|
||||||
request('/api/test/proxy', {
|
request('/api/test/proxy', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -30,10 +30,12 @@ import {
|
|||||||
parseCustomRequestBody,
|
parseCustomRequestBody,
|
||||||
parseModelTesterSession,
|
parseModelTesterSession,
|
||||||
processThinkTags,
|
processThinkTags,
|
||||||
|
resolveConversationReplayFiles,
|
||||||
serializeModelTesterSession,
|
serializeModelTesterSession,
|
||||||
syncCustomRequestBodyToMessages,
|
syncCustomRequestBodyToMessages,
|
||||||
syncMessagesToCustomRequestBody,
|
syncMessagesToCustomRequestBody,
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
|
type ConversationDraftFile,
|
||||||
type ConversationContentPart,
|
type ConversationContentPart,
|
||||||
type ConversationUploadedFile,
|
type ConversationUploadedFile,
|
||||||
type DebugTab,
|
type DebugTab,
|
||||||
@@ -74,12 +76,7 @@ type UploadState = {
|
|||||||
dataUrl: string;
|
dataUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ConversationFileState = UploadState & {
|
type ConversationFileState = ConversationDraftFile;
|
||||||
localId: string;
|
|
||||||
fileId?: string | null;
|
|
||||||
status: 'pending' | 'uploading' | 'uploaded' | 'error';
|
|
||||||
errorMessage?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 1200;
|
const POLL_INTERVAL_MS = 1200;
|
||||||
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
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);
|
setAssetPrompt(restored.modeState.imagesPrompt || restored.modeState.videosPrompt);
|
||||||
setVideoInspectId(restored.modeState.videosInspectId);
|
setVideoInspectId(restored.modeState.videosInspectId);
|
||||||
setVideoInspectAction(restored.inputs.videoInspectAction === 'delete' ? 'DELETE' : 'GET');
|
setVideoInspectAction(restored.inputs.videoInspectAction === 'delete' ? 'DELETE' : 'GET');
|
||||||
|
setConversationFiles(restored.conversationFiles);
|
||||||
|
|
||||||
if (restored.pendingJobId) {
|
if (restored.pendingJobId) {
|
||||||
setSending(true);
|
setSending(true);
|
||||||
@@ -827,6 +825,7 @@ export default function ModelTester() {
|
|||||||
inputs,
|
inputs,
|
||||||
parameterEnabled,
|
parameterEnabled,
|
||||||
messages,
|
messages,
|
||||||
|
conversationFiles,
|
||||||
modeState: {
|
modeState: {
|
||||||
embeddingsInput: embeddingInputText,
|
embeddingsInput: embeddingInputText,
|
||||||
searchQuery: searchQueryValue,
|
searchQuery: searchQueryValue,
|
||||||
@@ -852,6 +851,7 @@ export default function ModelTester() {
|
|||||||
input,
|
input,
|
||||||
inputs,
|
inputs,
|
||||||
messages,
|
messages,
|
||||||
|
conversationFiles,
|
||||||
assetPrompt,
|
assetPrompt,
|
||||||
customRequestBody,
|
customRequestBody,
|
||||||
embeddingInputText,
|
embeddingInputText,
|
||||||
@@ -1014,6 +1014,19 @@ export default function ModelTester() {
|
|||||||
return false;
|
return false;
|
||||||
}, [conversationFileCapability, conversationFileHint, pushDebug]);
|
}, [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[]) => {
|
const buildConversationMessagesWithSystem = useCallback((baseMessages: ChatMessage[]) => {
|
||||||
if (!inputs.systemPrompt.trim()) return baseMessages;
|
if (!inputs.systemPrompt.trim()) return baseMessages;
|
||||||
return [
|
return [
|
||||||
@@ -1805,10 +1818,19 @@ export default function ModelTester() {
|
|||||||
baseMessages: ChatMessage[],
|
baseMessages: ChatMessage[],
|
||||||
files: ConversationUploadedFile[] = [],
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const userMessage = createConversationUserMessage(prompt, files);
|
if (!ensureSupportedConversationFiles(resolvedFiles)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userMessage = createConversationUserMessage(prompt, resolvedFiles);
|
||||||
const loadingAssistant = createLoadingAssistantMessage();
|
const loadingAssistant = createLoadingAssistantMessage();
|
||||||
const nextMessages = [...baseMessages, userMessage, loadingAssistant];
|
const nextMessages = [...baseMessages, userMessage, loadingAssistant];
|
||||||
const useProxyTransport = inputs.protocol === 'gemini' || customRequestMode;
|
const useProxyTransport = inputs.protocol === 'gemini' || customRequestMode;
|
||||||
@@ -1826,7 +1848,7 @@ export default function ModelTester() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await dispatchPayload(nextMessages, payload, { syncedCustomBody });
|
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 sendModeRequest = useCallback(async () => {
|
||||||
const envelope = buildModeProxyEnvelope();
|
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 {
|
import {
|
||||||
DEBUG_TABS,
|
DEBUG_TABS,
|
||||||
DEFAULT_INPUTS,
|
DEFAULT_INPUTS,
|
||||||
@@ -80,6 +81,17 @@ describe('modelTesterSession', () => {
|
|||||||
customRequestBody: '{"model":"gemini-2.5-pro","contents":[]}',
|
customRequestBody: '{"model":"gemini-2.5-pro","contents":[]}',
|
||||||
showDebugPanel: true,
|
showDebugPanel: true,
|
||||||
activeDebugTab: DEBUG_TABS.REQUEST,
|
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: {
|
modeState: {
|
||||||
...DEFAULT_MODE_STATE,
|
...DEFAULT_MODE_STATE,
|
||||||
searchQuery: 'hello',
|
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', () => {
|
it('preserves file reference parts when building responses conversation payloads', () => {
|
||||||
const payload = buildApiPayload(
|
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', () => {
|
it('builds Claude conversation payloads with inline document blocks for conversation files', () => {
|
||||||
const payload = buildApiPayload(
|
const payload = buildApiPayload(
|
||||||
[{
|
[{
|
||||||
|
|||||||
@@ -76,6 +76,16 @@ export type ConversationUploadedFile = {
|
|||||||
data?: string | null;
|
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 = {
|
export type TesterProxyEnvelope = {
|
||||||
method: ProxyRequestMethod;
|
method: ProxyRequestMethod;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -133,6 +143,7 @@ export type ModelTesterSessionState = {
|
|||||||
inputs: ModelTesterInputs;
|
inputs: ModelTesterInputs;
|
||||||
parameterEnabled: ParameterEnabled;
|
parameterEnabled: ParameterEnabled;
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
|
conversationFiles: ConversationDraftFile[];
|
||||||
pendingPayload: TesterProxyEnvelope | null;
|
pendingPayload: TesterProxyEnvelope | null;
|
||||||
pendingJobId?: string | null;
|
pendingJobId?: string | null;
|
||||||
customRequestMode: boolean;
|
customRequestMode: boolean;
|
||||||
@@ -200,8 +211,10 @@ const VALID_MODES: ReadonlySet<string> = new Set([
|
|||||||
'videos.inspect',
|
'videos.inspect',
|
||||||
]);
|
]);
|
||||||
const VALID_PROTOCOLS: ReadonlySet<string> = new Set(['openai', 'responses', 'claude', 'gemini']);
|
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_PROXY_METHODS: ReadonlySet<string> = new Set(['POST', 'GET', 'DELETE']);
|
||||||
const VALID_REQUEST_KINDS: ReadonlySet<string> = new Set(['json', 'multipart', 'empty']);
|
const VALID_REQUEST_KINDS: ReadonlySet<string> = new Set(['json', 'multipart', 'empty']);
|
||||||
|
const LOCAL_PROXY_FILE_ID_PREFIX = 'file-metapi-';
|
||||||
|
|
||||||
let messageCounter = 0;
|
let messageCounter = 0;
|
||||||
|
|
||||||
@@ -296,10 +309,20 @@ const toOpenAiContentPart = (part: ConversationContentPart): Record<string, unkn
|
|||||||
|
|
||||||
if (part.type === 'input_file') {
|
if (part.type === 'input_file') {
|
||||||
const filePayload: Record<string, unknown> = {};
|
const filePayload: Record<string, unknown> = {};
|
||||||
if (typeof part.data === 'string' && part.data.trim()) filePayload.file_data = part.data.trim();
|
const fileId = typeof part.fileId === 'string' && part.fileId.trim()
|
||||||
else if (typeof part.fileId === 'string' && part.fileId.trim()) filePayload.file_id = 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.filename === 'string' && part.filename.trim()) filePayload.filename = part.filename.trim();
|
||||||
if (typeof part.mimeType === 'string' && part.mimeType.trim()) filePayload.mime_type = part.mimeType.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;
|
if (Object.keys(filePayload).length === 0) return null;
|
||||||
return {
|
return {
|
||||||
type: 'file',
|
type: 'file',
|
||||||
@@ -341,8 +364,8 @@ const toResponsesContentPart = (part: ConversationContentPart): Record<string, u
|
|||||||
const fileBlock: Record<string, unknown> = {
|
const fileBlock: Record<string, unknown> = {
|
||||||
type: 'input_file',
|
type: 'input_file',
|
||||||
};
|
};
|
||||||
if (typeof part.data === 'string' && part.data.trim()) fileBlock.file_data = part.data.trim();
|
if (typeof part.fileId === 'string' && part.fileId.trim()) fileBlock.file_id = part.fileId.trim();
|
||||||
else 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 (typeof part.filename === 'string' && part.filename.trim()) fileBlock.filename = part.filename.trim();
|
||||||
if (Object.keys(fileBlock).length === 1) return null;
|
if (Object.keys(fileBlock).length === 1) return null;
|
||||||
return fileBlock;
|
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 => {
|
const parseModeState = (value: unknown): ModelTesterModeState => {
|
||||||
if (!isRecord(value)) return { ...DEFAULT_MODE_STATE };
|
if (!isRecord(value)) return { ...DEFAULT_MODE_STATE };
|
||||||
return {
|
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 =>
|
export const createLoadingAssistantMessage = (): ChatMessage =>
|
||||||
createMessage('assistant', '', {
|
createMessage('assistant', '', {
|
||||||
status: MESSAGE_STATUS.LOADING,
|
status: MESSAGE_STATUS.LOADING,
|
||||||
@@ -1047,6 +1148,7 @@ export const parseModelTesterSession = (raw: string | null): ModelTesterSessionS
|
|||||||
inputs,
|
inputs,
|
||||||
parameterEnabled,
|
parameterEnabled,
|
||||||
messages: sanitizeMessages(parsed.messages),
|
messages: sanitizeMessages(parsed.messages),
|
||||||
|
conversationFiles: parseConversationDraftFiles(parsed.conversationFiles),
|
||||||
pendingPayload: parsePendingPayload(parsed.pendingPayload, inputs, parameterEnabled),
|
pendingPayload: parsePendingPayload(parsed.pendingPayload, inputs, parameterEnabled),
|
||||||
customRequestMode: toBoolean(parsed.customRequestMode, false),
|
customRequestMode: toBoolean(parsed.customRequestMode, false),
|
||||||
customRequestBody: typeof parsed.customRequestBody === 'string' ? parsed.customRequestBody : '',
|
customRequestBody: typeof parsed.customRequestBody === 'string' ? parsed.customRequestBody : '',
|
||||||
|
|||||||
Reference in New Issue
Block a user