@lobehub/chat 1.99.2 → 1.99.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cursor/rules/project-introduce.mdc +1 -56
- package/.cursor/rules/testing-guide/db-model-test.mdc +453 -0
- package/.cursor/rules/testing-guide/electron-ipc-test.mdc +80 -0
- package/.cursor/rules/testing-guide/testing-guide.mdc +401 -0
- package/CHANGELOG.md +50 -0
- package/changelog/v1.json +18 -0
- package/docs/usage/providers/ai21.mdx +1 -1
- package/docs/usage/providers/ai21.zh-CN.mdx +1 -1
- package/docs/usage/providers/ai360.mdx +1 -1
- package/docs/usage/providers/ai360.zh-CN.mdx +1 -1
- package/docs/usage/providers/anthropic.mdx +1 -1
- package/docs/usage/providers/anthropic.zh-CN.mdx +1 -1
- package/docs/usage/providers/azure.mdx +1 -1
- package/docs/usage/providers/azure.zh-CN.mdx +1 -1
- package/docs/usage/providers/baichuan.mdx +1 -1
- package/docs/usage/providers/baichuan.zh-CN.mdx +1 -1
- package/docs/usage/providers/bedrock.mdx +1 -1
- package/docs/usage/providers/bedrock.zh-CN.mdx +1 -1
- package/docs/usage/providers/cloudflare.mdx +1 -1
- package/docs/usage/providers/cloudflare.zh-CN.mdx +1 -1
- package/docs/usage/providers/deepseek.mdx +1 -1
- package/docs/usage/providers/deepseek.zh-CN.mdx +1 -1
- package/docs/usage/providers/fal.mdx +69 -0
- package/docs/usage/providers/fal.zh-CN.mdx +68 -0
- package/docs/usage/providers/fireworksai.mdx +1 -1
- package/docs/usage/providers/fireworksai.zh-CN.mdx +1 -1
- package/docs/usage/providers/giteeai.mdx +1 -1
- package/docs/usage/providers/giteeai.zh-CN.mdx +1 -1
- package/docs/usage/providers/github.mdx +1 -1
- package/docs/usage/providers/github.zh-CN.mdx +1 -1
- package/docs/usage/providers/google.mdx +1 -1
- package/docs/usage/providers/google.zh-CN.mdx +1 -1
- package/docs/usage/providers/groq.mdx +1 -1
- package/docs/usage/providers/groq.zh-CN.mdx +1 -1
- package/docs/usage/providers/hunyuan.mdx +1 -1
- package/docs/usage/providers/hunyuan.zh-CN.mdx +1 -1
- package/docs/usage/providers/internlm.mdx +1 -1
- package/docs/usage/providers/internlm.zh-CN.mdx +1 -1
- package/docs/usage/providers/jina.mdx +1 -1
- package/docs/usage/providers/jina.zh-CN.mdx +1 -1
- package/docs/usage/providers/minimax.mdx +1 -1
- package/docs/usage/providers/minimax.zh-CN.mdx +1 -1
- package/docs/usage/providers/mistral.mdx +1 -1
- package/docs/usage/providers/mistral.zh-CN.mdx +1 -1
- package/docs/usage/providers/moonshot.mdx +1 -1
- package/docs/usage/providers/moonshot.zh-CN.mdx +1 -1
- package/docs/usage/providers/novita.mdx +1 -1
- package/docs/usage/providers/novita.zh-CN.mdx +1 -1
- package/docs/usage/providers/ollama.mdx +1 -1
- package/docs/usage/providers/ollama.zh-CN.mdx +1 -1
- package/docs/usage/providers/openai.mdx +4 -4
- package/docs/usage/providers/openai.zh-CN.mdx +4 -4
- package/docs/usage/providers/openrouter.mdx +1 -1
- package/docs/usage/providers/openrouter.zh-CN.mdx +1 -1
- package/docs/usage/providers/perplexity.mdx +1 -1
- package/docs/usage/providers/perplexity.zh-CN.mdx +1 -1
- package/docs/usage/providers/ppio.mdx +1 -1
- package/docs/usage/providers/ppio.zh-CN.mdx +1 -1
- package/docs/usage/providers/qiniu.mdx +1 -1
- package/docs/usage/providers/qiniu.zh-CN.mdx +1 -1
- package/docs/usage/providers/qwen.mdx +1 -1
- package/docs/usage/providers/qwen.zh-CN.mdx +1 -1
- package/docs/usage/providers/sambanova.mdx +1 -1
- package/docs/usage/providers/sambanova.zh-CN.mdx +1 -1
- package/docs/usage/providers/sensenova.mdx +1 -1
- package/docs/usage/providers/sensenova.zh-CN.mdx +1 -1
- package/docs/usage/providers/siliconcloud.mdx +1 -1
- package/docs/usage/providers/siliconcloud.zh-CN.mdx +1 -1
- package/docs/usage/providers/spark.mdx +1 -1
- package/docs/usage/providers/spark.zh-CN.mdx +1 -1
- package/docs/usage/providers/stepfun.mdx +1 -1
- package/docs/usage/providers/stepfun.zh-CN.mdx +1 -1
- package/docs/usage/providers/taichu.mdx +1 -1
- package/docs/usage/providers/taichu.zh-CN.mdx +1 -1
- package/docs/usage/providers/togetherai.mdx +1 -1
- package/docs/usage/providers/togetherai.zh-CN.mdx +1 -1
- package/docs/usage/providers/upstage.mdx +1 -1
- package/docs/usage/providers/upstage.zh-CN.mdx +1 -1
- package/docs/usage/providers/vllm.mdx +1 -1
- package/docs/usage/providers/vllm.zh-CN.mdx +1 -1
- package/docs/usage/providers/wenxin.mdx +1 -1
- package/docs/usage/providers/wenxin.zh-CN.mdx +1 -1
- package/docs/usage/providers/xai.mdx +1 -1
- package/docs/usage/providers/xai.zh-CN.mdx +1 -1
- package/docs/usage/providers/zeroone.mdx +1 -1
- package/docs/usage/providers/zeroone.zh-CN.mdx +1 -1
- package/docs/usage/providers/zhipu.mdx +1 -1
- package/docs/usage/providers/zhipu.zh-CN.mdx +1 -1
- package/package.json +2 -2
- package/src/config/aiModels/openai.ts +24 -9
- package/src/libs/model-runtime/BaseAI.ts +1 -0
- package/src/libs/model-runtime/ModelRuntime.ts +0 -1
- package/src/libs/model-runtime/hunyuan/index.ts +4 -6
- package/src/libs/model-runtime/novita/__snapshots__/index.test.ts.snap +18 -0
- package/src/libs/model-runtime/openai/__snapshots__/index.test.ts.snap +28 -0
- package/src/libs/model-runtime/openai/index.test.ts +1 -338
- package/src/libs/model-runtime/openai/index.ts +0 -127
- package/src/libs/model-runtime/openrouter/__snapshots__/index.test.ts.snap +3 -0
- package/src/libs/model-runtime/ppio/__snapshots__/index.test.ts.snap +2 -0
- package/src/libs/model-runtime/utils/modelParse.ts +1 -0
- package/src/libs/model-runtime/utils/openaiCompatibleFactory/index.test.ts +364 -12
- package/src/libs/model-runtime/utils/openaiCompatibleFactory/index.ts +145 -43
- package/src/libs/model-runtime/utils/openaiHelpers.test.ts +151 -0
- package/src/libs/model-runtime/utils/openaiHelpers.ts +26 -1
- package/src/libs/model-runtime/xai/index.ts +1 -4
- package/src/store/aiInfra/slices/aiModel/action.ts +1 -1
- package/src/store/aiInfra/slices/aiProvider/action.ts +5 -2
- package/src/types/aiModel.ts +1 -0
- package/src/types/llm.ts +3 -1
- package/.cursor/rules/testing-guide.mdc +0 -881
@@ -1,9 +1,11 @@
|
|
1
1
|
import dayjs from 'dayjs';
|
2
2
|
import utc from 'dayjs/plugin/utc';
|
3
|
+
import createDebug from 'debug';
|
3
4
|
import OpenAI, { ClientOptions } from 'openai';
|
4
5
|
import { Stream } from 'openai/streaming';
|
5
6
|
|
6
|
-
import { LOBE_DEFAULT_MODEL_LIST } from '@/config/
|
7
|
+
import { LOBE_DEFAULT_MODEL_LIST } from '@/config/aiModels';
|
8
|
+
import { RuntimeImageGenParamsValue } from '@/libs/standard-parameters/meta-schema';
|
7
9
|
import type { ChatModelCard } from '@/types/llm';
|
8
10
|
|
9
11
|
import { LobeRuntimeAI } from '../../BaseAI';
|
@@ -27,7 +29,11 @@ import { AgentRuntimeError } from '../createError';
|
|
27
29
|
import { debugResponse, debugStream } from '../debugStream';
|
28
30
|
import { desensitizeUrl } from '../desensitizeUrl';
|
29
31
|
import { handleOpenAIError } from '../handleOpenAIError';
|
30
|
-
import {
|
32
|
+
import {
|
33
|
+
convertImageUrlToFile,
|
34
|
+
convertOpenAIMessages,
|
35
|
+
convertOpenAIResponseInputs,
|
36
|
+
} from '../openaiHelpers';
|
31
37
|
import { StreamingResponse } from '../response';
|
32
38
|
import { OpenAIResponsesStream, OpenAIStream, OpenAIStreamOptions } from '../streams';
|
33
39
|
|
@@ -168,7 +174,6 @@ export const createOpenAICompatibleRuntime = <T extends Record<string, any> = an
|
|
168
174
|
debug,
|
169
175
|
constructorOptions,
|
170
176
|
chatCompletion,
|
171
|
-
createImage,
|
172
177
|
models,
|
173
178
|
customClient,
|
174
179
|
responses,
|
@@ -311,58 +316,155 @@ export const createOpenAICompatibleRuntime = <T extends Record<string, any> = an
|
|
311
316
|
}
|
312
317
|
|
313
318
|
async createImage(payload: CreateImagePayload) {
|
314
|
-
|
315
|
-
|
316
|
-
client: this.client,
|
317
|
-
});
|
318
|
-
}
|
319
|
+
const { model, params } = payload;
|
320
|
+
const log = createDebug(`lobe-image:model-runtime`);
|
319
321
|
|
320
|
-
|
321
|
-
if (typeof models === 'function') return models({ client: this.client });
|
322
|
+
log('Creating image with model: %s and params: %O', model, params);
|
322
323
|
|
323
|
-
const
|
324
|
+
const defaultInput = {
|
325
|
+
n: 1,
|
326
|
+
...(model.includes('dall-e') ? { response_format: 'b64_json' } : {}),
|
327
|
+
};
|
324
328
|
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
|
329
|
+
// 映射参数名称,将 imageUrls 映射为 image
|
330
|
+
const paramsMap = new Map<RuntimeImageGenParamsValue, string>([
|
331
|
+
['imageUrls', 'image'],
|
332
|
+
['imageUrl', 'image'],
|
333
|
+
]);
|
334
|
+
const userInput: Record<string, any> = Object.fromEntries(
|
335
|
+
Object.entries(params).map(([key, value]) => [
|
336
|
+
paramsMap.get(key as RuntimeImageGenParamsValue) ?? key,
|
337
|
+
value,
|
338
|
+
]),
|
339
|
+
);
|
340
|
+
|
341
|
+
const isImageEdit = Array.isArray(userInput.image) && userInput.image.length > 0;
|
342
|
+
// 如果有 imageUrls 参数,将其转换为 File 对象
|
343
|
+
if (isImageEdit) {
|
344
|
+
log('Converting imageUrls to File objects: %O', userInput.image);
|
345
|
+
try {
|
346
|
+
// 转换所有图片 URL 为 File 对象
|
347
|
+
const imageFiles = await Promise.all(
|
348
|
+
userInput.image.map((url: string) => convertImageUrlToFile(url)),
|
329
349
|
);
|
330
|
-
})
|
331
|
-
.map((item) => {
|
332
|
-
if (models?.transformModel) {
|
333
|
-
return models.transformModel(item);
|
334
|
-
}
|
335
350
|
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
|
351
|
+
log('Successfully converted %d images to File objects', imageFiles.length);
|
352
|
+
|
353
|
+
// 根据官方文档,如果有多个图片,传递数组;如果只有一个,传递单个 File
|
354
|
+
userInput.image = imageFiles.length === 1 ? imageFiles[0] : imageFiles;
|
355
|
+
} catch (error) {
|
356
|
+
log('Error converting imageUrls to File objects: %O', error);
|
357
|
+
throw new Error(`Failed to convert image URLs to File objects: ${error}`);
|
358
|
+
}
|
359
|
+
} else {
|
360
|
+
delete userInput.image;
|
361
|
+
}
|
362
|
+
|
363
|
+
if (userInput.size === 'auto') {
|
364
|
+
delete userInput.size;
|
365
|
+
}
|
366
|
+
|
367
|
+
const options = {
|
368
|
+
model,
|
369
|
+
...defaultInput,
|
370
|
+
...userInput,
|
371
|
+
};
|
372
|
+
|
373
|
+
log('options: %O', options);
|
374
|
+
|
375
|
+
// 判断是否为图片编辑操作
|
376
|
+
const img = isImageEdit
|
377
|
+
? await this.client.images.edit(options as any)
|
378
|
+
: await this.client.images.generate(options as any);
|
379
|
+
|
380
|
+
// 检查响应数据的完整性
|
381
|
+
if (!img || !img.data || !Array.isArray(img.data) || img.data.length === 0) {
|
382
|
+
log('Invalid image response: missing data array');
|
383
|
+
throw new Error('Invalid image response: missing or empty data array');
|
384
|
+
}
|
385
|
+
|
386
|
+
const imageData = img.data[0];
|
387
|
+
if (!imageData) {
|
388
|
+
log('Invalid image response: first data item is null/undefined');
|
389
|
+
throw new Error('Invalid image response: first data item is null or undefined');
|
390
|
+
}
|
391
|
+
|
392
|
+
if (!imageData.b64_json) {
|
393
|
+
log('Invalid image response: missing b64_json field');
|
394
|
+
throw new Error('Invalid image response: missing b64_json field');
|
395
|
+
}
|
396
|
+
|
397
|
+
// 确定图片的 MIME 类型,默认为 PNG
|
398
|
+
const mimeType = 'image/png'; // OpenAI 图片生成默认返回 PNG 格式
|
399
|
+
|
400
|
+
// 将 base64 字符串转换为完整的 data URL
|
401
|
+
const dataUrl = `data:${mimeType};base64,${imageData.b64_json}`;
|
402
|
+
|
403
|
+
log('Successfully converted base64 to data URL, length: %d', dataUrl.length);
|
404
|
+
|
405
|
+
return {
|
406
|
+
imageUrl: dataUrl,
|
407
|
+
};
|
408
|
+
}
|
409
|
+
|
410
|
+
async models() {
|
411
|
+
let resultModels: ChatModelCard[] = [];
|
412
|
+
if (typeof models === 'function') {
|
413
|
+
resultModels = await models({ client: this.client });
|
414
|
+
} else {
|
415
|
+
const list = await this.client.models.list();
|
416
|
+
resultModels = list.data
|
417
|
+
.filter((model) => {
|
418
|
+
return CHAT_MODELS_BLOCK_LIST.every(
|
419
|
+
(keyword) => !model.id.toLowerCase().includes(keyword),
|
420
|
+
);
|
421
|
+
})
|
422
|
+
.map((item) => {
|
423
|
+
if (models?.transformModel) {
|
424
|
+
return models.transformModel(item);
|
347
425
|
}
|
348
426
|
|
349
|
-
|
350
|
-
|
351
|
-
|
427
|
+
const toReleasedAt = () => {
|
428
|
+
if (!item.created) return;
|
429
|
+
dayjs.extend(utc);
|
352
430
|
|
353
|
-
|
354
|
-
|
431
|
+
// guarantee item.created in Date String format
|
432
|
+
if (
|
433
|
+
typeof (item.created as any) === 'string' ||
|
434
|
+
// or in milliseconds
|
435
|
+
item.created.toFixed(0).length === 13
|
436
|
+
) {
|
437
|
+
return dayjs.utc(item.created).format('YYYY-MM-DD');
|
438
|
+
}
|
355
439
|
|
356
|
-
|
357
|
-
|
440
|
+
// by default, the created time is in seconds
|
441
|
+
return dayjs.utc(item.created * 1000).format('YYYY-MM-DD');
|
442
|
+
};
|
358
443
|
|
359
|
-
|
360
|
-
|
444
|
+
// TODO: should refactor after remove v1 user/modelList code
|
445
|
+
const knownModel = LOBE_DEFAULT_MODEL_LIST.find((model) => model.id === item.id);
|
361
446
|
|
362
|
-
|
363
|
-
|
447
|
+
if (knownModel) {
|
448
|
+
const releasedAt = knownModel.releasedAt ?? toReleasedAt();
|
449
|
+
|
450
|
+
return { ...knownModel, releasedAt };
|
451
|
+
}
|
364
452
|
|
365
|
-
|
453
|
+
return {
|
454
|
+
id: item.id,
|
455
|
+
releasedAt: toReleasedAt(),
|
456
|
+
};
|
457
|
+
})
|
458
|
+
|
459
|
+
.filter(Boolean) as ChatModelCard[];
|
460
|
+
}
|
461
|
+
|
462
|
+
return resultModels.map((model) => {
|
463
|
+
return {
|
464
|
+
...model,
|
465
|
+
type: model.type || LOBE_DEFAULT_MODEL_LIST.find((m) => m.id === model.id)?.type,
|
466
|
+
};
|
467
|
+
}) as ChatModelCard[];
|
366
468
|
}
|
367
469
|
|
368
470
|
async embeddings(
|
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
4
|
import { imageUrlToBase64 } from '@/utils/imageToBase64';
|
5
5
|
|
6
6
|
import {
|
7
|
+
convertImageUrlToFile,
|
7
8
|
convertMessageContent,
|
8
9
|
convertOpenAIMessages,
|
9
10
|
convertOpenAIResponseInputs,
|
@@ -288,3 +289,153 @@ describe('convertOpenAIResponseInputs', () => {
|
|
288
289
|
]);
|
289
290
|
});
|
290
291
|
});
|
292
|
+
|
293
|
+
describe('convertImageUrlToFile', () => {
|
294
|
+
beforeEach(() => {
|
295
|
+
vi.resetAllMocks();
|
296
|
+
});
|
297
|
+
|
298
|
+
afterEach(() => {
|
299
|
+
vi.restoreAllMocks();
|
300
|
+
});
|
301
|
+
|
302
|
+
describe('Data URL handling', () => {
|
303
|
+
it('should convert PNG data URL to File object correctly', async () => {
|
304
|
+
const base64Data =
|
305
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
|
306
|
+
const dataUrl = `data:image/png;base64,${base64Data}`;
|
307
|
+
|
308
|
+
const result = await convertImageUrlToFile(dataUrl);
|
309
|
+
|
310
|
+
expect(result).toBeDefined();
|
311
|
+
expect(result).toHaveProperty('name', 'image.png');
|
312
|
+
expect(result).toHaveProperty('type', 'image/png');
|
313
|
+
expect(result).toHaveProperty('size');
|
314
|
+
expect(result.size).toBeGreaterThan(0);
|
315
|
+
});
|
316
|
+
|
317
|
+
it('should convert JPEG data URL to File object correctly', async () => {
|
318
|
+
const base64Data =
|
319
|
+
'/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA9BQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==';
|
320
|
+
const dataUrl = `data:image/jpeg;base64,${base64Data}`;
|
321
|
+
|
322
|
+
const result = await convertImageUrlToFile(dataUrl);
|
323
|
+
|
324
|
+
expect(result).toBeDefined();
|
325
|
+
expect(result).toHaveProperty('name', 'image.jpeg');
|
326
|
+
expect(result).toHaveProperty('type', 'image/jpeg');
|
327
|
+
expect(result).toHaveProperty('size');
|
328
|
+
expect(result.size).toBeGreaterThan(0);
|
329
|
+
});
|
330
|
+
|
331
|
+
it('should convert WebP data URL to File object correctly', async () => {
|
332
|
+
const base64Data = 'UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAAAAJaQAA6g=';
|
333
|
+
const dataUrl = `data:image/webp;base64,${base64Data}`;
|
334
|
+
|
335
|
+
const result = await convertImageUrlToFile(dataUrl);
|
336
|
+
|
337
|
+
expect(result).toBeDefined();
|
338
|
+
expect(result).toHaveProperty('name', 'image.webp');
|
339
|
+
expect(result).toHaveProperty('type', 'image/webp');
|
340
|
+
expect(result).toHaveProperty('size');
|
341
|
+
expect(result.size).toBeGreaterThan(0);
|
342
|
+
});
|
343
|
+
});
|
344
|
+
|
345
|
+
describe('HTTP URL handling', () => {
|
346
|
+
const mockFetch = vi.fn();
|
347
|
+
|
348
|
+
beforeEach(() => {
|
349
|
+
// Mock global fetch using vi.stubGlobal for better isolation
|
350
|
+
vi.stubGlobal('fetch', mockFetch);
|
351
|
+
});
|
352
|
+
|
353
|
+
afterEach(() => {
|
354
|
+
vi.unstubAllGlobals();
|
355
|
+
vi.clearAllMocks();
|
356
|
+
});
|
357
|
+
|
358
|
+
it('should convert HTTP URL to File object correctly', async () => {
|
359
|
+
const mockArrayBuffer = new ArrayBuffer(8);
|
360
|
+
const mockHeaders = new Headers();
|
361
|
+
mockHeaders.set('content-type', 'image/jpeg');
|
362
|
+
|
363
|
+
mockFetch.mockResolvedValue({
|
364
|
+
ok: true,
|
365
|
+
arrayBuffer: () => Promise.resolve(mockArrayBuffer),
|
366
|
+
headers: mockHeaders,
|
367
|
+
} satisfies Partial<Response>);
|
368
|
+
|
369
|
+
const result = await convertImageUrlToFile('https://example.com/image.jpg');
|
370
|
+
|
371
|
+
expect(mockFetch).toHaveBeenCalledWith('https://example.com/image.jpg');
|
372
|
+
expect(result).toBeDefined();
|
373
|
+
expect(result).toHaveProperty('name', 'image.jpeg');
|
374
|
+
expect(result).toHaveProperty('type', 'image/jpeg');
|
375
|
+
expect(result).toHaveProperty('size');
|
376
|
+
expect(result.size).toEqual(8);
|
377
|
+
});
|
378
|
+
|
379
|
+
it('should handle different content types from HTTP response headers', async () => {
|
380
|
+
const testCases = [
|
381
|
+
{ contentType: 'image/jpeg', expectedExtension: 'jpeg' },
|
382
|
+
{ contentType: 'image/png', expectedExtension: 'png' },
|
383
|
+
{ contentType: 'image/webp', expectedExtension: 'webp' },
|
384
|
+
{ contentType: null, expectedExtension: 'png' }, // default fallback
|
385
|
+
];
|
386
|
+
|
387
|
+
for (const testCase of testCases) {
|
388
|
+
const mockArrayBuffer = new ArrayBuffer(8);
|
389
|
+
const mockHeaders = new Headers();
|
390
|
+
if (testCase.contentType) {
|
391
|
+
mockHeaders.set('content-type', testCase.contentType);
|
392
|
+
}
|
393
|
+
|
394
|
+
mockFetch.mockResolvedValue({
|
395
|
+
ok: true,
|
396
|
+
arrayBuffer: () => Promise.resolve(mockArrayBuffer),
|
397
|
+
headers: mockHeaders,
|
398
|
+
} satisfies Partial<Response>);
|
399
|
+
|
400
|
+
const result = await convertImageUrlToFile('https://example.com/image.jpg');
|
401
|
+
|
402
|
+
expect(result).toHaveProperty('name', `image.${testCase.expectedExtension}`);
|
403
|
+
expect(result).toHaveProperty('type', testCase.contentType || 'image/png');
|
404
|
+
|
405
|
+
vi.clearAllMocks();
|
406
|
+
}
|
407
|
+
});
|
408
|
+
|
409
|
+
it('should throw error when HTTP request fails', async () => {
|
410
|
+
mockFetch.mockResolvedValue({
|
411
|
+
ok: false,
|
412
|
+
statusText: 'Not Found',
|
413
|
+
} satisfies Partial<Response>);
|
414
|
+
|
415
|
+
await expect(convertImageUrlToFile('https://example.com/nonexistent.jpg')).rejects.toThrow(
|
416
|
+
'Failed to fetch image from https://example.com/nonexistent.jpg: Not Found',
|
417
|
+
);
|
418
|
+
|
419
|
+
expect(mockFetch).toHaveBeenCalledWith('https://example.com/nonexistent.jpg');
|
420
|
+
});
|
421
|
+
|
422
|
+
it('should throw error when network request fails', async () => {
|
423
|
+
mockFetch.mockRejectedValue(new Error('Network error'));
|
424
|
+
|
425
|
+
await expect(convertImageUrlToFile('https://example.com/image.jpg')).rejects.toThrow(
|
426
|
+
'Network error',
|
427
|
+
);
|
428
|
+
|
429
|
+
expect(mockFetch).toHaveBeenCalledWith('https://example.com/image.jpg');
|
430
|
+
});
|
431
|
+
});
|
432
|
+
|
433
|
+
describe('Edge cases', () => {
|
434
|
+
it('should handle malformed data URL gracefully', async () => {
|
435
|
+
const malformedDataUrl = 'data:invalid-format';
|
436
|
+
|
437
|
+
// 这个测试可能会抛出错误,我们需要适当处理
|
438
|
+
await expect(convertImageUrlToFile(malformedDataUrl)).rejects.toThrow();
|
439
|
+
});
|
440
|
+
});
|
441
|
+
});
|
@@ -1,4 +1,4 @@
|
|
1
|
-
import OpenAI from 'openai';
|
1
|
+
import OpenAI, { toFile } from 'openai';
|
2
2
|
|
3
3
|
import { disableStreamModels, systemToUserModels } from '@/const/models';
|
4
4
|
import { ChatStreamPayload, OpenAIChatMessage } from '@/libs/model-runtime';
|
@@ -119,3 +119,28 @@ export const pruneReasoningPayload = (payload: ChatStreamPayload) => {
|
|
119
119
|
top_p: 1,
|
120
120
|
};
|
121
121
|
};
|
122
|
+
|
123
|
+
/**
|
124
|
+
* Convert image URL (data URL or HTTP URL) to File object for OpenAI API
|
125
|
+
*/
|
126
|
+
export const convertImageUrlToFile = async (imageUrl: string) => {
|
127
|
+
let buffer: Buffer;
|
128
|
+
let mimeType: string;
|
129
|
+
|
130
|
+
if (imageUrl.startsWith('data:')) {
|
131
|
+
// a base64 image
|
132
|
+
const [mimeTypePart, base64Data] = imageUrl.split(',');
|
133
|
+
mimeType = mimeTypePart.split(':')[1].split(';')[0];
|
134
|
+
buffer = Buffer.from(base64Data, 'base64');
|
135
|
+
} else {
|
136
|
+
// a http url
|
137
|
+
const response = await fetch(imageUrl);
|
138
|
+
if (!response.ok) {
|
139
|
+
throw new Error(`Failed to fetch image from ${imageUrl}: ${response.statusText}`);
|
140
|
+
}
|
141
|
+
buffer = Buffer.from(await response.arrayBuffer());
|
142
|
+
mimeType = response.headers.get('content-type') || 'image/png';
|
143
|
+
}
|
144
|
+
|
145
|
+
return toFile(buffer, `image.${mimeType.split('/')[1]}`, { type: mimeType });
|
146
|
+
};
|
@@ -7,10 +7,7 @@ export interface XAIModelCard {
|
|
7
7
|
id: string;
|
8
8
|
}
|
9
9
|
|
10
|
-
export const GrokReasoningModels = new Set([
|
11
|
-
'grok-3-mini',
|
12
|
-
'grok-4',
|
13
|
-
]);
|
10
|
+
export const GrokReasoningModels = new Set(['grok-3-mini', 'grok-4']);
|
14
11
|
|
15
12
|
export const isGrokReasoningModel = (model: string) =>
|
16
13
|
Array.from(GrokReasoningModels).some((id) => model.includes(id));
|
@@ -203,6 +203,8 @@ export const createAiProviderSlice: StateCreator<
|
|
203
203
|
onSuccess: async (data) => {
|
204
204
|
if (!data) return;
|
205
205
|
|
206
|
+
const { LOBE_DEFAULT_MODEL_LIST } = await import('@/config/aiModels');
|
207
|
+
|
206
208
|
const getModelListByType = (providerId: string, type: string) => {
|
207
209
|
const models = data.enabledAiModels
|
208
210
|
.filter((model) => model.providerId === providerId && model.type === type)
|
@@ -212,7 +214,9 @@ export const createAiProviderSlice: StateCreator<
|
|
212
214
|
displayName: model.displayName ?? '',
|
213
215
|
id: model.id,
|
214
216
|
...(model.type === 'image' && {
|
215
|
-
parameters:
|
217
|
+
parameters:
|
218
|
+
(model as AIImageModelCard).parameters ||
|
219
|
+
LOBE_DEFAULT_MODEL_LIST.find((m) => m.id === model.id)?.parameters,
|
216
220
|
}),
|
217
221
|
}));
|
218
222
|
|
@@ -231,7 +235,6 @@ export const createAiProviderSlice: StateCreator<
|
|
231
235
|
children: getModelListByType(provider.id, 'image'),
|
232
236
|
name: provider.name || provider.id,
|
233
237
|
}));
|
234
|
-
const { LOBE_DEFAULT_MODEL_LIST } = await import('@/config/aiModels');
|
235
238
|
|
236
239
|
set(
|
237
240
|
{
|
package/src/types/aiModel.ts
CHANGED
package/src/types/llm.ts
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
import { ReactNode } from 'react';
|
2
2
|
|
3
|
-
import { ChatModelPricing } from '@/types/aiModel';
|
3
|
+
import { AiModelType, ChatModelPricing } from '@/types/aiModel';
|
4
4
|
import { AiProviderSettings } from '@/types/aiProvider';
|
5
5
|
|
6
6
|
export type ModelPriceCurrency = 'CNY' | 'USD';
|
@@ -53,6 +53,8 @@ export interface ChatModelCard {
|
|
53
53
|
*/
|
54
54
|
releasedAt?: string;
|
55
55
|
|
56
|
+
type?: AiModelType;
|
57
|
+
|
56
58
|
/**
|
57
59
|
* whether model supports vision
|
58
60
|
*/
|