@lobehub/chat 1.120.6 → 1.121.0
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-structure.mdc +54 -42
- package/.cursor/rules/testing-guide/testing-guide.mdc +28 -17
- package/.env.development +122 -0
- package/.vscode/settings.json +0 -1
- package/CHANGELOG.md +50 -0
- package/CLAUDE.md +3 -4
- package/changelog/v1.json +18 -0
- package/docker-compose/local/init_data.json +981 -1024
- package/docker-compose.development.yml +40 -0
- package/docs/development/basic/work-with-server-side-database.mdx +77 -0
- package/docs/development/basic/work-with-server-side-database.zh-CN.mdx +77 -0
- package/docs/self-hosting/advanced/s3/cloudflare-r2.mdx +1 -1
- package/docs/self-hosting/advanced/s3/cloudflare-r2.zh-CN.mdx +2 -2
- package/locales/ar/chat.json +2 -1
- package/locales/ar/models.json +0 -9
- package/locales/ar/providers.json +3 -0
- package/locales/bg-BG/chat.json +2 -1
- package/locales/bg-BG/models.json +0 -9
- package/locales/bg-BG/providers.json +3 -0
- package/locales/de-DE/chat.json +2 -1
- package/locales/de-DE/models.json +0 -9
- package/locales/de-DE/providers.json +3 -0
- package/locales/en-US/chat.json +2 -1
- package/locales/en-US/models.json +0 -9
- package/locales/en-US/providers.json +3 -0
- package/locales/es-ES/chat.json +2 -1
- package/locales/es-ES/models.json +0 -9
- package/locales/es-ES/providers.json +3 -0
- package/locales/fa-IR/chat.json +2 -1
- package/locales/fa-IR/models.json +0 -9
- package/locales/fa-IR/providers.json +3 -0
- package/locales/fr-FR/chat.json +2 -1
- package/locales/fr-FR/models.json +0 -9
- package/locales/fr-FR/providers.json +3 -0
- package/locales/it-IT/chat.json +2 -1
- package/locales/it-IT/models.json +0 -9
- package/locales/it-IT/providers.json +3 -0
- package/locales/ja-JP/chat.json +2 -1
- package/locales/ja-JP/models.json +0 -9
- package/locales/ja-JP/providers.json +3 -0
- package/locales/ko-KR/chat.json +2 -1
- package/locales/ko-KR/models.json +0 -9
- package/locales/ko-KR/providers.json +3 -0
- package/locales/nl-NL/chat.json +2 -1
- package/locales/nl-NL/models.json +0 -9
- package/locales/nl-NL/providers.json +3 -0
- package/locales/pl-PL/chat.json +2 -1
- package/locales/pl-PL/models.json +0 -9
- package/locales/pl-PL/providers.json +3 -0
- package/locales/pt-BR/chat.json +2 -1
- package/locales/pt-BR/models.json +0 -9
- package/locales/pt-BR/providers.json +3 -0
- package/locales/ru-RU/chat.json +2 -1
- package/locales/ru-RU/models.json +0 -9
- package/locales/ru-RU/providers.json +3 -0
- package/locales/tr-TR/chat.json +2 -1
- package/locales/tr-TR/models.json +0 -9
- package/locales/tr-TR/providers.json +3 -0
- package/locales/vi-VN/chat.json +2 -1
- package/locales/vi-VN/models.json +0 -9
- package/locales/vi-VN/providers.json +3 -0
- package/locales/zh-CN/chat.json +2 -1
- package/locales/zh-CN/common.json +7 -0
- package/locales/zh-CN/models.json +0 -9
- package/locales/zh-CN/providers.json +3 -0
- package/locales/zh-TW/chat.json +2 -1
- package/locales/zh-TW/models.json +0 -9
- package/locales/zh-TW/providers.json +3 -0
- package/package.json +2 -1
- package/packages/database/src/repositories/aiInfra/index.ts +3 -1
- package/packages/model-runtime/src/RouterRuntime/createRuntime.test.ts +6 -91
- package/packages/model-runtime/src/RouterRuntime/createRuntime.ts +6 -28
- package/packages/model-runtime/src/openrouter/index.ts +15 -12
- package/packages/model-runtime/src/openrouter/type.ts +10 -0
- package/packages/model-runtime/src/utils/modelParse.test.ts +66 -0
- package/packages/model-runtime/src/utils/modelParse.ts +15 -3
- package/packages/model-runtime/src/utils/postProcessModelList.ts +1 -0
- package/packages/utils/src/detectChinese.test.ts +37 -0
- package/packages/utils/src/detectChinese.ts +12 -0
- package/packages/utils/src/index.ts +1 -0
- package/src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatInput/Desktop/TextArea.test.tsx +33 -18
- package/src/app/[variants]/(main)/image/features/PromptInput/index.tsx +12 -0
- package/src/features/ChatInput/useSend.ts +14 -2
- package/src/hooks/useGeminiChineseWarning.tsx +91 -0
- package/src/locales/default/common.ts +7 -0
- package/src/store/global/initialState.ts +2 -0
@@ -96,7 +96,7 @@ describe('createRouterRuntime', () => {
|
|
96
96
|
});
|
97
97
|
|
98
98
|
const runtime = new Runtime();
|
99
|
-
const models = await runtime['
|
99
|
+
const models = await runtime['getRouterMatchModels']({
|
100
100
|
id: 'test',
|
101
101
|
models: ['model-1', 'model-2'],
|
102
102
|
runtime: mockRuntime,
|
@@ -105,7 +105,7 @@ describe('createRouterRuntime', () => {
|
|
105
105
|
expect(models).toEqual(['model-1', 'model-2']);
|
106
106
|
});
|
107
107
|
|
108
|
-
it('should call
|
108
|
+
it('should call asynchronous models function', async () => {
|
109
109
|
const mockRuntime = {
|
110
110
|
chat: vi.fn(),
|
111
111
|
} as unknown as LobeRuntimeAI;
|
@@ -131,14 +131,9 @@ describe('createRouterRuntime', () => {
|
|
131
131
|
runtime: mockRuntime,
|
132
132
|
};
|
133
133
|
|
134
|
-
//
|
135
|
-
const
|
136
|
-
expect(
|
137
|
-
expect(mockModelsFunction).toHaveBeenCalledTimes(1);
|
138
|
-
|
139
|
-
// Second call should use cache
|
140
|
-
const models2 = await runtime['getModels'](runtimeItem);
|
141
|
-
expect(models2).toEqual(['async-model-1', 'async-model-2']);
|
134
|
+
// Call the function
|
135
|
+
const models = await runtime['getRouterMatchModels'](runtimeItem);
|
136
|
+
expect(models).toEqual(['async-model-1', 'async-model-2']);
|
142
137
|
expect(mockModelsFunction).toHaveBeenCalledTimes(1);
|
143
138
|
});
|
144
139
|
|
@@ -159,7 +154,7 @@ describe('createRouterRuntime', () => {
|
|
159
154
|
});
|
160
155
|
|
161
156
|
const runtime = new Runtime();
|
162
|
-
const models = await runtime['
|
157
|
+
const models = await runtime['getRouterMatchModels']({
|
163
158
|
id: 'test',
|
164
159
|
runtime: mockRuntime,
|
165
160
|
});
|
@@ -455,84 +450,4 @@ describe('createRouterRuntime', () => {
|
|
455
450
|
expect(mockTextToSpeech).toHaveBeenCalledWith(payload, options);
|
456
451
|
});
|
457
452
|
});
|
458
|
-
|
459
|
-
describe('clearModelCache method', () => {
|
460
|
-
it('should clear specific runtime cache when runtimeId provided', async () => {
|
461
|
-
const mockModelsFunction = vi.fn().mockResolvedValue(['model-1']);
|
462
|
-
|
463
|
-
const Runtime = createRouterRuntime({
|
464
|
-
id: 'test-runtime',
|
465
|
-
routers: [
|
466
|
-
{
|
467
|
-
apiType: 'openai',
|
468
|
-
options: {},
|
469
|
-
runtime: vi.fn() as any,
|
470
|
-
models: mockModelsFunction,
|
471
|
-
},
|
472
|
-
],
|
473
|
-
});
|
474
|
-
|
475
|
-
const runtime = new Runtime();
|
476
|
-
const runtimeItem = {
|
477
|
-
id: 'test-id',
|
478
|
-
models: mockModelsFunction,
|
479
|
-
runtime: {} as any,
|
480
|
-
};
|
481
|
-
|
482
|
-
// Build cache
|
483
|
-
await runtime['getModels'](runtimeItem);
|
484
|
-
expect(mockModelsFunction).toHaveBeenCalledTimes(1);
|
485
|
-
|
486
|
-
// Clear specific cache
|
487
|
-
runtime.clearModelCache('test-id');
|
488
|
-
|
489
|
-
// Should call function again
|
490
|
-
await runtime['getModels'](runtimeItem);
|
491
|
-
expect(mockModelsFunction).toHaveBeenCalledTimes(2);
|
492
|
-
});
|
493
|
-
|
494
|
-
it('should clear all cache when no runtimeId provided', async () => {
|
495
|
-
const mockModelsFunction1 = vi.fn().mockResolvedValue(['model-1']);
|
496
|
-
const mockModelsFunction2 = vi.fn().mockResolvedValue(['model-2']);
|
497
|
-
|
498
|
-
const Runtime = createRouterRuntime({
|
499
|
-
id: 'test-runtime',
|
500
|
-
routers: [
|
501
|
-
{
|
502
|
-
apiType: 'openai',
|
503
|
-
options: {},
|
504
|
-
runtime: vi.fn() as any,
|
505
|
-
models: mockModelsFunction1,
|
506
|
-
},
|
507
|
-
],
|
508
|
-
});
|
509
|
-
|
510
|
-
const runtime = new Runtime();
|
511
|
-
const runtimeItem1 = {
|
512
|
-
id: 'test-id-1',
|
513
|
-
models: mockModelsFunction1,
|
514
|
-
runtime: {} as any,
|
515
|
-
};
|
516
|
-
const runtimeItem2 = {
|
517
|
-
id: 'test-id-2',
|
518
|
-
models: mockModelsFunction2,
|
519
|
-
runtime: {} as any,
|
520
|
-
};
|
521
|
-
|
522
|
-
// Build cache for both items
|
523
|
-
await runtime['getModels'](runtimeItem1);
|
524
|
-
await runtime['getModels'](runtimeItem2);
|
525
|
-
expect(mockModelsFunction1).toHaveBeenCalledTimes(1);
|
526
|
-
expect(mockModelsFunction2).toHaveBeenCalledTimes(1);
|
527
|
-
|
528
|
-
// Clear all cache
|
529
|
-
runtime.clearModelCache();
|
530
|
-
|
531
|
-
// Should call functions again
|
532
|
-
await runtime['getModels'](runtimeItem1);
|
533
|
-
await runtime['getModels'](runtimeItem2);
|
534
|
-
expect(mockModelsFunction1).toHaveBeenCalledTimes(2);
|
535
|
-
expect(mockModelsFunction2).toHaveBeenCalledTimes(2);
|
536
|
-
});
|
537
|
-
});
|
538
453
|
});
|
@@ -117,7 +117,6 @@ export const createRouterRuntime = ({
|
|
117
117
|
return class UniformRuntime implements LobeRuntimeAI {
|
118
118
|
private _runtimes: RuntimeItem[];
|
119
119
|
private _options: ClientOptions & Record<string, any>;
|
120
|
-
private _modelCache = new Map<string, string[]>();
|
121
120
|
|
122
121
|
constructor(options: ClientOptions & Record<string, any> = {}) {
|
123
122
|
const _options = {
|
@@ -143,30 +142,21 @@ export const createRouterRuntime = ({
|
|
143
142
|
this._options = _options;
|
144
143
|
}
|
145
144
|
|
146
|
-
// Get runtime's models list, supporting both synchronous arrays and asynchronous functions
|
147
|
-
private async
|
148
|
-
|
149
|
-
|
150
|
-
// If it's a synchronous array, return directly without caching
|
145
|
+
// Get runtime's models list, supporting both synchronous arrays and asynchronous functions
|
146
|
+
private async getRouterMatchModels(runtimeItem: RuntimeItem): Promise<string[]> {
|
147
|
+
// If it's a synchronous array, return directly
|
151
148
|
if (typeof runtimeItem.models !== 'function') {
|
152
149
|
return runtimeItem.models || [];
|
153
150
|
}
|
154
151
|
|
155
|
-
//
|
156
|
-
|
157
|
-
return this._modelCache.get(cacheKey)!;
|
158
|
-
}
|
159
|
-
|
160
|
-
// Get model list and cache result
|
161
|
-
const models = await runtimeItem.models();
|
162
|
-
this._modelCache.set(cacheKey, models);
|
163
|
-
return models;
|
152
|
+
// Get model list
|
153
|
+
return await runtimeItem.models();
|
164
154
|
}
|
165
155
|
|
166
156
|
// Check if it can match a specific model, otherwise default to using the last runtime
|
167
157
|
async getRuntimeByModel(model: string) {
|
168
158
|
for (const runtimeItem of this._runtimes) {
|
169
|
-
const models = await this.
|
159
|
+
const models = await this.getRouterMatchModels(runtimeItem);
|
170
160
|
if (models.includes(model)) {
|
171
161
|
return runtimeItem.runtime;
|
172
162
|
}
|
@@ -226,17 +216,5 @@ export const createRouterRuntime = ({
|
|
226
216
|
|
227
217
|
return runtime.textToSpeech!(payload, options);
|
228
218
|
}
|
229
|
-
|
230
|
-
/**
|
231
|
-
* Clear model list cache, forcing reload on next access
|
232
|
-
* @param runtimeId - Optional, specify to clear cache for a specific runtime, omit to clear all caches
|
233
|
-
*/
|
234
|
-
clearModelCache(runtimeId?: string) {
|
235
|
-
if (runtimeId) {
|
236
|
-
this._modelCache.delete(runtimeId);
|
237
|
-
} else {
|
238
|
-
this._modelCache.clear();
|
239
|
-
}
|
240
|
-
}
|
241
219
|
};
|
242
220
|
};
|
@@ -71,29 +71,32 @@ export const LobeOpenRouterAI = createOpenAICompatibleRuntime({
|
|
71
71
|
|
72
72
|
// 处理前端获取的模型信息,转换为标准格式
|
73
73
|
const formattedModels = modelList.map((model) => {
|
74
|
+
const { endpoint } = model;
|
75
|
+
const endpointModel = endpoint?.model;
|
76
|
+
|
74
77
|
const displayName = model.slug?.toLowerCase().includes('deepseek')
|
75
78
|
? (model.name ?? model.slug)
|
76
79
|
: (model.short_name ?? model.name ?? model.slug);
|
77
80
|
|
81
|
+
const inputModalities = endpointModel?.input_modalities || model.input_modalities;
|
82
|
+
|
78
83
|
return {
|
79
|
-
contextWindowTokens: model.context_length,
|
80
|
-
description: model.description,
|
84
|
+
contextWindowTokens: endpoint?.context_length || model.context_length,
|
85
|
+
description: endpointModel?.description || model.description,
|
81
86
|
displayName,
|
82
|
-
functionCall:
|
83
|
-
id: model.slug,
|
87
|
+
functionCall: endpoint?.supports_tool_parameters || false,
|
88
|
+
id: endpoint?.model_variant_slug || model.slug,
|
84
89
|
maxOutput:
|
85
|
-
typeof
|
86
|
-
?
|
90
|
+
typeof endpoint?.max_completion_tokens === 'number'
|
91
|
+
? endpoint.max_completion_tokens
|
87
92
|
: undefined,
|
88
93
|
pricing: {
|
89
|
-
input: formatPrice(
|
90
|
-
output: formatPrice(
|
94
|
+
input: formatPrice(endpoint?.pricing?.prompt),
|
95
|
+
output: formatPrice(endpoint?.pricing?.completion),
|
91
96
|
},
|
92
|
-
reasoning:
|
97
|
+
reasoning: endpoint?.supports_reasoning || false,
|
93
98
|
releasedAt: new Date(model.created_at).toISOString().split('T')[0],
|
94
|
-
vision:
|
95
|
-
(Array.isArray(model.input_modalities) && model.input_modalities.includes('image')) ||
|
96
|
-
false,
|
99
|
+
vision: Array.isArray(inputModalities) && inputModalities.includes('image'),
|
97
100
|
};
|
98
101
|
});
|
99
102
|
|
@@ -19,11 +19,21 @@ export interface OpenRouterModelCard {
|
|
19
19
|
}
|
20
20
|
|
21
21
|
interface OpenRouterModelEndpoint {
|
22
|
+
context_length?: number;
|
22
23
|
max_completion_tokens: number | null;
|
24
|
+
model?: {
|
25
|
+
description?: string;
|
26
|
+
input_modalities?: string[];
|
27
|
+
name?: string;
|
28
|
+
short_name?: string;
|
29
|
+
slug: string;
|
30
|
+
};
|
31
|
+
model_variant_slug?: string;
|
23
32
|
pricing: ModelPricing;
|
24
33
|
supported_parameters: string[];
|
25
34
|
supports_reasoning?: boolean;
|
26
35
|
supports_tool_parameters?: boolean;
|
36
|
+
variant?: 'free' | 'standard' | 'unknown';
|
27
37
|
}
|
28
38
|
|
29
39
|
interface OpenRouterOpenAIReasoning {
|
@@ -758,4 +758,70 @@ describe('modelParse', () => {
|
|
758
758
|
expect(modelConfigKeys.sort()).toEqual(providerDetectionKeys.sort());
|
759
759
|
});
|
760
760
|
});
|
761
|
+
|
762
|
+
describe('displayName processing', () => {
|
763
|
+
it('should replace "Gemini 2.5 Flash Image Preview" with "Nano Banana"', async () => {
|
764
|
+
const modelList = [
|
765
|
+
{
|
766
|
+
id: 'gemini-2.5-flash-image-preview',
|
767
|
+
displayName: 'Gemini 2.5 Flash Image Preview',
|
768
|
+
},
|
769
|
+
{
|
770
|
+
id: 'some-other-model',
|
771
|
+
displayName: 'Some Other Model',
|
772
|
+
},
|
773
|
+
{
|
774
|
+
id: 'partial-gemini-model',
|
775
|
+
displayName: 'Custom Gemini 2.5 Flash Image Preview Enhanced',
|
776
|
+
},
|
777
|
+
{
|
778
|
+
id: 'gemini-free-model',
|
779
|
+
displayName: 'Gemini 2.5 Flash Image Preview (free)',
|
780
|
+
},
|
781
|
+
];
|
782
|
+
|
783
|
+
const result = await processModelList(modelList, MODEL_LIST_CONFIGS.google);
|
784
|
+
|
785
|
+
expect(result).toHaveLength(4);
|
786
|
+
|
787
|
+
// First model should have "Nano Banana" as displayName
|
788
|
+
const geminiModel = result.find((m) => m.id === 'gemini-2.5-flash-image-preview');
|
789
|
+
expect(geminiModel?.displayName).toBe('Nano Banana');
|
790
|
+
|
791
|
+
// Second model should keep original displayName
|
792
|
+
const otherModel = result.find((m) => m.id === 'some-other-model');
|
793
|
+
expect(otherModel?.displayName).toBe('Some Other Model');
|
794
|
+
|
795
|
+
// Third model (partial match) should replace only the matching part
|
796
|
+
const partialModel = result.find((m) => m.id === 'partial-gemini-model');
|
797
|
+
expect(partialModel?.displayName).toBe('Custom Nano Banana Enhanced');
|
798
|
+
|
799
|
+
// Fourth model should preserve the (free) suffix
|
800
|
+
const freeModel = result.find((m) => m.id === 'gemini-free-model');
|
801
|
+
expect(freeModel?.displayName).toBe('Nano Banana (free)');
|
802
|
+
});
|
803
|
+
|
804
|
+
it('should keep original displayName when not matching Gemini 2.5 Flash Image Preview', async () => {
|
805
|
+
const modelList = [
|
806
|
+
{
|
807
|
+
id: 'gpt-4',
|
808
|
+
displayName: 'GPT-4',
|
809
|
+
},
|
810
|
+
{
|
811
|
+
id: 'gemini-pro',
|
812
|
+
displayName: 'Gemini Pro',
|
813
|
+
},
|
814
|
+
];
|
815
|
+
|
816
|
+
const result = await processModelList(modelList, MODEL_LIST_CONFIGS.google);
|
817
|
+
|
818
|
+
expect(result).toHaveLength(2);
|
819
|
+
|
820
|
+
const gptModel = result.find((m) => m.id === 'gpt-4');
|
821
|
+
expect(gptModel?.displayName).toBe('GPT-4');
|
822
|
+
|
823
|
+
const geminiProModel = result.find((m) => m.id === 'gemini-pro');
|
824
|
+
expect(geminiProModel?.displayName).toBe('Gemini Pro');
|
825
|
+
});
|
826
|
+
});
|
761
827
|
});
|
@@ -264,6 +264,20 @@ const processReleasedAt = (model: any, knownModel?: any): string | undefined =>
|
|
264
264
|
return model.releasedAt ?? knownModel?.releasedAt ?? undefined;
|
265
265
|
};
|
266
266
|
|
267
|
+
/**
|
268
|
+
* 处理模型显示名称
|
269
|
+
* @param displayName 原始显示名称
|
270
|
+
* @returns 处理后的显示名称
|
271
|
+
*/
|
272
|
+
const processDisplayName = (displayName: string): string => {
|
273
|
+
// 如果包含 "Gemini 2.5 Flash Image Preview",替换对应部分为 "Nano Banana"
|
274
|
+
if (displayName.includes('Gemini 2.5 Flash Image Preview')) {
|
275
|
+
return displayName.replace('Gemini 2.5 Flash Image Preview', 'Nano Banana');
|
276
|
+
}
|
277
|
+
|
278
|
+
return displayName;
|
279
|
+
};
|
280
|
+
|
267
281
|
/**
|
268
282
|
* 处理模型卡片的通用逻辑
|
269
283
|
*/
|
@@ -331,9 +345,7 @@ const processModelCard = (
|
|
331
345
|
return {
|
332
346
|
contextWindowTokens: model.contextWindowTokens ?? knownModel?.contextWindowTokens ?? undefined,
|
333
347
|
description: model.description ?? knownModel?.description ?? '',
|
334
|
-
displayName: (model.displayName ?? knownModel?.displayName ?? model.id)
|
335
|
-
.replaceAll(/\s*[((][^))]*[))]\s*/g, '')
|
336
|
-
.trim(), // 去除括号内容
|
348
|
+
displayName: processDisplayName(model.displayName ?? knownModel?.displayName ?? model.id),
|
337
349
|
enabled: model?.enabled || false,
|
338
350
|
functionCall:
|
339
351
|
model.functionCall ??
|
@@ -5,6 +5,7 @@ import type { ChatModelCard } from '@/types/llm';
|
|
5
5
|
// Whitelist for automatic image model generation
|
6
6
|
export const IMAGE_GENERATION_MODEL_WHITELIST = [
|
7
7
|
'gemini-2.5-flash-image-preview',
|
8
|
+
'gemini-2.5-flash-image-preview:free',
|
8
9
|
// More models can be added in the future
|
9
10
|
] as const;
|
10
11
|
|
@@ -0,0 +1,37 @@
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
2
|
+
|
3
|
+
import { containsChinese } from './detectChinese';
|
4
|
+
|
5
|
+
describe('containsChinese', () => {
|
6
|
+
it('should return true for text containing Chinese characters', () => {
|
7
|
+
expect(containsChinese('你好世界')).toBe(true);
|
8
|
+
expect(containsChinese('Hello 世界')).toBe(true);
|
9
|
+
expect(containsChinese('测试 test')).toBe(true);
|
10
|
+
expect(containsChinese('这是一个测试')).toBe(true);
|
11
|
+
});
|
12
|
+
|
13
|
+
it('should return false for text without Chinese characters', () => {
|
14
|
+
expect(containsChinese('Hello World')).toBe(false);
|
15
|
+
expect(containsChinese('123456')).toBe(false);
|
16
|
+
expect(containsChinese('!@#$%^&*()')).toBe(false);
|
17
|
+
expect(containsChinese('')).toBe(false);
|
18
|
+
expect(containsChinese('English only text')).toBe(false);
|
19
|
+
});
|
20
|
+
|
21
|
+
it('should handle mixed content correctly', () => {
|
22
|
+
expect(containsChinese('Hello 中国')).toBe(true);
|
23
|
+
expect(containsChinese('English and 数字 123')).toBe(true);
|
24
|
+
expect(containsChinese('Japanese こんにちは and English')).toBe(false);
|
25
|
+
expect(containsChinese('Korean 안녕하세요 and English')).toBe(false);
|
26
|
+
});
|
27
|
+
|
28
|
+
it('should detect extended Chinese character ranges', () => {
|
29
|
+
// Test CJK Unified Ideographs Extension A (U+3400-U+4DBF)
|
30
|
+
expect(containsChinese('㐀㑇㒯')).toBe(true);
|
31
|
+
// Test CJK Compatibility Ideographs (U+F900-U+FAFF)
|
32
|
+
expect(containsChinese('豈更車')).toBe(true);
|
33
|
+
// Test traditional Chinese characters
|
34
|
+
expect(containsChinese('繁體中文')).toBe(true);
|
35
|
+
expect(containsChinese('學習語言')).toBe(true);
|
36
|
+
});
|
37
|
+
});
|
@@ -0,0 +1,12 @@
|
|
1
|
+
/**
|
2
|
+
* Detect if text contains Chinese characters
|
3
|
+
* @param text - The text to check
|
4
|
+
* @returns true if text contains Chinese characters, false otherwise
|
5
|
+
*/
|
6
|
+
export const containsChinese = (text: string): boolean => {
|
7
|
+
// Enhanced regex to cover more Chinese character ranges:
|
8
|
+
// \u4e00-\u9fa5: CJK Unified Ideographs (basic)
|
9
|
+
// \u3400-\u4dbf: CJK Unified Ideographs Extension A
|
10
|
+
// \uf900-\ufaff: CJK Compatibility Ideographs
|
11
|
+
return /[\u3400-\u4DBF\u4E00-\u9FA5\uF900-\uFAFF]/.test(text);
|
12
|
+
};
|
@@ -7,10 +7,27 @@ import { useUserStore } from '@/store/user';
|
|
7
7
|
|
8
8
|
import InputArea from './TextArea';
|
9
9
|
|
10
|
+
let sendMessageMock: () => Promise<void>;
|
11
|
+
|
12
|
+
// Mock the useSendMessage hook to return our mock function
|
13
|
+
vi.mock('@/features/ChatInput/useSend', () => ({
|
14
|
+
useSendMessage: () => ({
|
15
|
+
send: sendMessageMock,
|
16
|
+
canSend: true,
|
17
|
+
}),
|
18
|
+
}));
|
19
|
+
|
20
|
+
// Mock the Chinese warning hook to always allow sending
|
21
|
+
vi.mock('@/hooks/useGeminiChineseWarning', () => ({
|
22
|
+
useGeminiChineseWarning: () => () => Promise.resolve(true),
|
23
|
+
}));
|
24
|
+
|
10
25
|
let onSendMock: () => void;
|
11
26
|
|
12
27
|
beforeEach(() => {
|
13
28
|
onSendMock = vi.fn();
|
29
|
+
sendMessageMock = vi.fn().mockResolvedValue(undefined);
|
30
|
+
vi.clearAllMocks();
|
14
31
|
});
|
15
32
|
|
16
33
|
describe('<InputArea />', () => {
|
@@ -194,9 +211,8 @@ describe('<InputArea />', () => {
|
|
194
211
|
|
195
212
|
describe('message sending behavior', () => {
|
196
213
|
it('does not send message when loading or shift key is pressed', () => {
|
197
|
-
const sendMessageMock = vi.fn();
|
198
214
|
act(() => {
|
199
|
-
useChatStore.setState({ chatLoadingIds: ['123']
|
215
|
+
useChatStore.setState({ chatLoadingIds: ['123'] });
|
200
216
|
});
|
201
217
|
|
202
218
|
render(<InputArea onSend={onSendMock} />);
|
@@ -206,13 +222,11 @@ describe('<InputArea />', () => {
|
|
206
222
|
expect(sendMessageMock).not.toHaveBeenCalled();
|
207
223
|
});
|
208
224
|
|
209
|
-
it('sends message on Enter press when not loading and no shift key', () => {
|
210
|
-
const sendMessageMock = vi.fn();
|
225
|
+
it('sends message on Enter press when not loading and no shift key', async () => {
|
211
226
|
act(() => {
|
212
227
|
useChatStore.setState({
|
213
228
|
chatLoadingIds: [],
|
214
229
|
inputMessage: 'abc',
|
215
|
-
sendMessage: sendMessageMock,
|
216
230
|
});
|
217
231
|
});
|
218
232
|
|
@@ -221,17 +235,18 @@ describe('<InputArea />', () => {
|
|
221
235
|
fireEvent.change(textArea, { target: { value: 'Test message' } });
|
222
236
|
|
223
237
|
fireEvent.keyDown(textArea, { code: 'Enter', key: 'Enter' });
|
224
|
-
|
238
|
+
|
239
|
+
await vi.waitFor(() => {
|
240
|
+
expect(sendMessageMock).toHaveBeenCalled();
|
241
|
+
});
|
225
242
|
});
|
226
243
|
|
227
244
|
describe('metaKey behavior for sending messages', () => {
|
228
|
-
it('windows: sends message on ctrl + enter when useCmdEnterToSend is true', () => {
|
229
|
-
const sendMessageMock = vi.fn();
|
245
|
+
it('windows: sends message on ctrl + enter when useCmdEnterToSend is true', async () => {
|
230
246
|
act(() => {
|
231
247
|
useChatStore.setState({
|
232
248
|
chatLoadingIds: [],
|
233
249
|
inputMessage: '123',
|
234
|
-
sendMessage: sendMessageMock,
|
235
250
|
});
|
236
251
|
useUserStore.getState().updatePreference({ useCmdEnterToSend: true });
|
237
252
|
});
|
@@ -240,17 +255,18 @@ describe('<InputArea />', () => {
|
|
240
255
|
const textArea = screen.getByRole('textbox');
|
241
256
|
|
242
257
|
fireEvent.keyDown(textArea, { code: 'Enter', ctrlKey: true, key: 'Enter' });
|
243
|
-
|
258
|
+
|
259
|
+
await vi.waitFor(() => {
|
260
|
+
expect(sendMessageMock).toHaveBeenCalled();
|
261
|
+
});
|
244
262
|
});
|
245
263
|
|
246
264
|
it('windows: inserts a new line on ctrl + enter when useCmdEnterToSend is false', () => {
|
247
|
-
const sendMessageMock = vi.fn();
|
248
265
|
const updateInputMessageMock = vi.fn();
|
249
266
|
act(() => {
|
250
267
|
useChatStore.setState({
|
251
268
|
chatLoadingIds: [],
|
252
269
|
inputMessage: 'Test',
|
253
|
-
sendMessage: sendMessageMock,
|
254
270
|
updateInputMessage: updateInputMessageMock,
|
255
271
|
});
|
256
272
|
useUserStore.getState().updatePreference({ useCmdEnterToSend: false });
|
@@ -264,17 +280,15 @@ describe('<InputArea />', () => {
|
|
264
280
|
expect(sendMessageMock).not.toHaveBeenCalled(); // sendMessage should not be called
|
265
281
|
});
|
266
282
|
|
267
|
-
it('macOS: sends message on cmd + enter when useCmdEnterToSend is true', () => {
|
283
|
+
it('macOS: sends message on cmd + enter when useCmdEnterToSend is true', async () => {
|
268
284
|
vi.stubGlobal('navigator', {
|
269
285
|
userAgent:
|
270
286
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
271
287
|
});
|
272
|
-
const sendMessageMock = vi.fn();
|
273
288
|
act(() => {
|
274
289
|
useChatStore.setState({
|
275
290
|
chatLoadingIds: [],
|
276
291
|
inputMessage: '123',
|
277
|
-
sendMessage: sendMessageMock,
|
278
292
|
});
|
279
293
|
useUserStore.getState().updatePreference({ useCmdEnterToSend: true });
|
280
294
|
});
|
@@ -283,7 +297,10 @@ describe('<InputArea />', () => {
|
|
283
297
|
const textArea = screen.getByRole('textbox');
|
284
298
|
|
285
299
|
fireEvent.keyDown(textArea, { code: 'Enter', key: 'Enter', metaKey: true });
|
286
|
-
|
300
|
+
|
301
|
+
await vi.waitFor(() => {
|
302
|
+
expect(sendMessageMock).toHaveBeenCalled();
|
303
|
+
});
|
287
304
|
vi.restoreAllMocks();
|
288
305
|
});
|
289
306
|
|
@@ -292,13 +309,11 @@ describe('<InputArea />', () => {
|
|
292
309
|
userAgent:
|
293
310
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
294
311
|
});
|
295
|
-
const sendMessageMock = vi.fn();
|
296
312
|
const updateInputMessageMock = vi.fn();
|
297
313
|
act(() => {
|
298
314
|
useChatStore.setState({
|
299
315
|
chatLoadingIds: [],
|
300
316
|
inputMessage: 'Test',
|
301
|
-
sendMessage: sendMessageMock,
|
302
317
|
updateInputMessage: updateInputMessageMock,
|
303
318
|
});
|
304
319
|
useUserStore.getState().updatePreference({ useCmdEnterToSend: false });
|
@@ -8,9 +8,11 @@ import { useTranslation } from 'react-i18next';
|
|
8
8
|
import { Flexbox } from 'react-layout-kit';
|
9
9
|
|
10
10
|
import { loginRequired } from '@/components/Error/loginRequiredNotification';
|
11
|
+
import { useGeminiChineseWarning } from '@/hooks/useGeminiChineseWarning';
|
11
12
|
import { useImageStore } from '@/store/image';
|
12
13
|
import { createImageSelectors } from '@/store/image/selectors';
|
13
14
|
import { useGenerationConfigParam } from '@/store/image/slices/generationConfig/hooks';
|
15
|
+
import { imageGenerationConfigSelectors } from '@/store/image/slices/generationConfig/selectors';
|
14
16
|
import { useUserStore } from '@/store/user';
|
15
17
|
import { authSelectors } from '@/store/user/slices/auth/selectors';
|
16
18
|
|
@@ -49,13 +51,23 @@ const PromptInput = ({ showTitle = false }: PromptInputProps) => {
|
|
49
51
|
const { value, setValue } = useGenerationConfigParam('prompt');
|
50
52
|
const isCreating = useImageStore(createImageSelectors.isCreating);
|
51
53
|
const createImage = useImageStore((s) => s.createImage);
|
54
|
+
const currentModel = useImageStore(imageGenerationConfigSelectors.model);
|
52
55
|
const isLogin = useUserStore(authSelectors.isLogin);
|
56
|
+
const checkGeminiChineseWarning = useGeminiChineseWarning();
|
53
57
|
|
54
58
|
const handleGenerate = async () => {
|
55
59
|
if (!isLogin) {
|
56
60
|
loginRequired.redirect({ timeout: 2000 });
|
57
61
|
return;
|
58
62
|
}
|
63
|
+
// Check for Chinese text warning with Gemini model
|
64
|
+
const shouldContinue = await checkGeminiChineseWarning({
|
65
|
+
model: currentModel,
|
66
|
+
prompt: value,
|
67
|
+
scenario: 'image',
|
68
|
+
});
|
69
|
+
|
70
|
+
if (!shouldContinue) return;
|
59
71
|
|
60
72
|
await createImage();
|
61
73
|
};
|
@@ -1,6 +1,7 @@
|
|
1
1
|
import { useAnalytics } from '@lobehub/analytics/react';
|
2
2
|
import { useCallback, useMemo } from 'react';
|
3
3
|
|
4
|
+
import { useGeminiChineseWarning } from '@/hooks/useGeminiChineseWarning';
|
4
5
|
import { getAgentStoreState } from '@/store/agent';
|
5
6
|
import { agentSelectors } from '@/store/agent/selectors';
|
6
7
|
import { useChatStore } from '@/store/chat';
|
@@ -20,6 +21,7 @@ export const useSendMessage = () => {
|
|
20
21
|
s.updateInputMessage,
|
21
22
|
]);
|
22
23
|
const { analytics } = useAnalytics();
|
24
|
+
const checkGeminiChineseWarning = useGeminiChineseWarning();
|
23
25
|
|
24
26
|
const clearChatUploadFileList = useFileStore((s) => s.clearChatUploadFileList);
|
25
27
|
|
@@ -28,7 +30,7 @@ export const useSendMessage = () => {
|
|
28
30
|
|
29
31
|
const canSend = !isUploadingFiles && !isSendButtonDisabledByMessage;
|
30
32
|
|
31
|
-
const send = useCallback((params: UseSendMessageParams = {}) => {
|
33
|
+
const send = useCallback(async (params: UseSendMessageParams = {}) => {
|
32
34
|
const store = useChatStore.getState();
|
33
35
|
if (chatSelectors.isAIGenerating(store)) return;
|
34
36
|
|
@@ -45,6 +47,17 @@ export const useSendMessage = () => {
|
|
45
47
|
// if there is no message and no image, then we should not send the message
|
46
48
|
if (!store.inputMessage && fileList.length === 0) return;
|
47
49
|
|
50
|
+
// Check for Chinese text warning with Gemini model
|
51
|
+
const agentStore = getAgentStoreState();
|
52
|
+
const currentModel = agentSelectors.currentAgentModel(agentStore);
|
53
|
+
const shouldContinue = await checkGeminiChineseWarning({
|
54
|
+
model: currentModel,
|
55
|
+
prompt: store.inputMessage,
|
56
|
+
scenario: 'chat',
|
57
|
+
});
|
58
|
+
|
59
|
+
if (!shouldContinue) return;
|
60
|
+
|
48
61
|
sendMessage({
|
49
62
|
files: fileList,
|
50
63
|
message: store.inputMessage,
|
@@ -56,7 +69,6 @@ export const useSendMessage = () => {
|
|
56
69
|
|
57
70
|
// 获取分析数据
|
58
71
|
const userStore = getUserStoreState();
|
59
|
-
const agentStore = getAgentStoreState();
|
60
72
|
|
61
73
|
// 直接使用现有数据结构判断消息类型
|
62
74
|
const hasImages = fileList.some((file) => file.file?.type?.startsWith('image'));
|