@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.
Files changed (86) hide show
  1. package/.cursor/rules/project-structure.mdc +54 -42
  2. package/.cursor/rules/testing-guide/testing-guide.mdc +28 -17
  3. package/.env.development +122 -0
  4. package/.vscode/settings.json +0 -1
  5. package/CHANGELOG.md +50 -0
  6. package/CLAUDE.md +3 -4
  7. package/changelog/v1.json +18 -0
  8. package/docker-compose/local/init_data.json +981 -1024
  9. package/docker-compose.development.yml +40 -0
  10. package/docs/development/basic/work-with-server-side-database.mdx +77 -0
  11. package/docs/development/basic/work-with-server-side-database.zh-CN.mdx +77 -0
  12. package/docs/self-hosting/advanced/s3/cloudflare-r2.mdx +1 -1
  13. package/docs/self-hosting/advanced/s3/cloudflare-r2.zh-CN.mdx +2 -2
  14. package/locales/ar/chat.json +2 -1
  15. package/locales/ar/models.json +0 -9
  16. package/locales/ar/providers.json +3 -0
  17. package/locales/bg-BG/chat.json +2 -1
  18. package/locales/bg-BG/models.json +0 -9
  19. package/locales/bg-BG/providers.json +3 -0
  20. package/locales/de-DE/chat.json +2 -1
  21. package/locales/de-DE/models.json +0 -9
  22. package/locales/de-DE/providers.json +3 -0
  23. package/locales/en-US/chat.json +2 -1
  24. package/locales/en-US/models.json +0 -9
  25. package/locales/en-US/providers.json +3 -0
  26. package/locales/es-ES/chat.json +2 -1
  27. package/locales/es-ES/models.json +0 -9
  28. package/locales/es-ES/providers.json +3 -0
  29. package/locales/fa-IR/chat.json +2 -1
  30. package/locales/fa-IR/models.json +0 -9
  31. package/locales/fa-IR/providers.json +3 -0
  32. package/locales/fr-FR/chat.json +2 -1
  33. package/locales/fr-FR/models.json +0 -9
  34. package/locales/fr-FR/providers.json +3 -0
  35. package/locales/it-IT/chat.json +2 -1
  36. package/locales/it-IT/models.json +0 -9
  37. package/locales/it-IT/providers.json +3 -0
  38. package/locales/ja-JP/chat.json +2 -1
  39. package/locales/ja-JP/models.json +0 -9
  40. package/locales/ja-JP/providers.json +3 -0
  41. package/locales/ko-KR/chat.json +2 -1
  42. package/locales/ko-KR/models.json +0 -9
  43. package/locales/ko-KR/providers.json +3 -0
  44. package/locales/nl-NL/chat.json +2 -1
  45. package/locales/nl-NL/models.json +0 -9
  46. package/locales/nl-NL/providers.json +3 -0
  47. package/locales/pl-PL/chat.json +2 -1
  48. package/locales/pl-PL/models.json +0 -9
  49. package/locales/pl-PL/providers.json +3 -0
  50. package/locales/pt-BR/chat.json +2 -1
  51. package/locales/pt-BR/models.json +0 -9
  52. package/locales/pt-BR/providers.json +3 -0
  53. package/locales/ru-RU/chat.json +2 -1
  54. package/locales/ru-RU/models.json +0 -9
  55. package/locales/ru-RU/providers.json +3 -0
  56. package/locales/tr-TR/chat.json +2 -1
  57. package/locales/tr-TR/models.json +0 -9
  58. package/locales/tr-TR/providers.json +3 -0
  59. package/locales/vi-VN/chat.json +2 -1
  60. package/locales/vi-VN/models.json +0 -9
  61. package/locales/vi-VN/providers.json +3 -0
  62. package/locales/zh-CN/chat.json +2 -1
  63. package/locales/zh-CN/common.json +7 -0
  64. package/locales/zh-CN/models.json +0 -9
  65. package/locales/zh-CN/providers.json +3 -0
  66. package/locales/zh-TW/chat.json +2 -1
  67. package/locales/zh-TW/models.json +0 -9
  68. package/locales/zh-TW/providers.json +3 -0
  69. package/package.json +2 -1
  70. package/packages/database/src/repositories/aiInfra/index.ts +3 -1
  71. package/packages/model-runtime/src/RouterRuntime/createRuntime.test.ts +6 -91
  72. package/packages/model-runtime/src/RouterRuntime/createRuntime.ts +6 -28
  73. package/packages/model-runtime/src/openrouter/index.ts +15 -12
  74. package/packages/model-runtime/src/openrouter/type.ts +10 -0
  75. package/packages/model-runtime/src/utils/modelParse.test.ts +66 -0
  76. package/packages/model-runtime/src/utils/modelParse.ts +15 -3
  77. package/packages/model-runtime/src/utils/postProcessModelList.ts +1 -0
  78. package/packages/utils/src/detectChinese.test.ts +37 -0
  79. package/packages/utils/src/detectChinese.ts +12 -0
  80. package/packages/utils/src/index.ts +1 -0
  81. package/src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatInput/Desktop/TextArea.test.tsx +33 -18
  82. package/src/app/[variants]/(main)/image/features/PromptInput/index.tsx +12 -0
  83. package/src/features/ChatInput/useSend.ts +14 -2
  84. package/src/hooks/useGeminiChineseWarning.tsx +91 -0
  85. package/src/locales/default/common.ts +7 -0
  86. 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['getModels']({
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 and cache asynchronous models function', async () => {
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
- // First call
135
- const models1 = await runtime['getModels'](runtimeItem);
136
- expect(models1).toEqual(['async-model-1', 'async-model-2']);
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['getModels']({
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 with caching
147
- private async getModels(runtimeItem: RuntimeItem): Promise<string[]> {
148
- const cacheKey = runtimeItem.id;
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
- // Check cache
156
- if (this._modelCache.has(cacheKey)) {
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.getModels(runtimeItem);
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: model.endpoint?.supports_tool_parameters || false,
83
- id: model.slug,
87
+ functionCall: endpoint?.supports_tool_parameters || false,
88
+ id: endpoint?.model_variant_slug || model.slug,
84
89
  maxOutput:
85
- typeof model.endpoint?.max_completion_tokens === 'number'
86
- ? model.endpoint.max_completion_tokens
90
+ typeof endpoint?.max_completion_tokens === 'number'
91
+ ? endpoint.max_completion_tokens
87
92
  : undefined,
88
93
  pricing: {
89
- input: formatPrice(model.endpoint?.pricing?.prompt),
90
- output: formatPrice(model.endpoint?.pricing?.completion),
94
+ input: formatPrice(endpoint?.pricing?.prompt),
95
+ output: formatPrice(endpoint?.pricing?.completion),
91
96
  },
92
- reasoning: model.endpoint?.supports_reasoning || false,
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
+ };
@@ -1,4 +1,5 @@
1
1
  export * from './client/cookie';
2
+ export * from './detectChinese';
2
3
  export * from './format';
3
4
  export * from './imageToBase64';
4
5
  export * from './parseModels';
@@ -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'], sendMessage: sendMessageMock });
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
- expect(sendMessageMock).toHaveBeenCalled();
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
- expect(sendMessageMock).toHaveBeenCalled();
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
- expect(sendMessageMock).toHaveBeenCalled();
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'));