@lobehub/lobehub 2.0.0-next.205 → 2.0.0-next.207

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 (48) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/changelog/v1.json +18 -0
  3. package/locales/ar/components.json +4 -0
  4. package/locales/ar/models.json +25 -126
  5. package/locales/bg-BG/components.json +4 -0
  6. package/locales/bg-BG/models.json +2 -2
  7. package/locales/de-DE/components.json +4 -0
  8. package/locales/de-DE/models.json +21 -120
  9. package/locales/en-US/components.json +4 -0
  10. package/locales/es-ES/components.json +4 -0
  11. package/locales/es-ES/models.json +24 -180
  12. package/locales/fa-IR/components.json +4 -0
  13. package/locales/fa-IR/models.json +2 -2
  14. package/locales/fr-FR/components.json +4 -0
  15. package/locales/fr-FR/models.json +2 -108
  16. package/locales/it-IT/components.json +4 -0
  17. package/locales/it-IT/models.json +22 -51
  18. package/locales/ja-JP/components.json +4 -0
  19. package/locales/ja-JP/models.json +16 -133
  20. package/locales/ko-KR/components.json +4 -0
  21. package/locales/ko-KR/models.json +26 -148
  22. package/locales/nl-NL/components.json +4 -0
  23. package/locales/nl-NL/models.json +2 -2
  24. package/locales/pl-PL/components.json +4 -0
  25. package/locales/pl-PL/models.json +2 -2
  26. package/locales/pt-BR/components.json +4 -0
  27. package/locales/pt-BR/models.json +49 -125
  28. package/locales/ru-RU/components.json +4 -0
  29. package/locales/ru-RU/models.json +17 -96
  30. package/locales/tr-TR/components.json +4 -0
  31. package/locales/tr-TR/models.json +28 -57
  32. package/locales/vi-VN/components.json +4 -0
  33. package/locales/vi-VN/models.json +1 -92
  34. package/locales/zh-CN/components.json +4 -0
  35. package/locales/zh-CN/models.json +31 -165
  36. package/locales/zh-TW/components.json +4 -0
  37. package/locales/zh-TW/models.json +2 -2
  38. package/package.json +1 -1
  39. package/packages/utils/src/object.test.ts +10 -2
  40. package/src/app/[variants]/(main)/chat/profile/features/EditorCanvas/index.tsx +4 -2
  41. package/src/app/[variants]/(main)/settings/provider/features/ProviderConfig/index.tsx +1 -1
  42. package/src/features/ModelSwitchPanel/index.tsx +393 -42
  43. package/src/locales/default/components.ts +4 -0
  44. package/src/store/aiInfra/slices/aiModel/selectors.test.ts +1 -0
  45. package/src/store/aiInfra/slices/aiProvider/__tests__/selectors.test.ts +34 -11
  46. package/src/store/aiInfra/slices/aiProvider/action.ts +9 -1
  47. package/src/store/aiInfra/slices/aiProvider/initialState.ts +6 -1
  48. package/src/store/aiInfra/slices/aiProvider/selectors.ts +17 -3
@@ -1,7 +1,15 @@
1
- import { ActionIcon, Flexbox, Icon, TooltipGroup } from '@lobehub/ui';
1
+ import { ActionIcon, Flexbox, Icon, Segmented, TooltipGroup } from '@lobehub/ui';
2
+ import { ProviderIcon } from '@lobehub/ui/icons';
2
3
  import { Dropdown } from 'antd';
3
4
  import { createStaticStyles, cssVar, cx } from 'antd-style';
4
- import { LucideArrowRight, LucideBolt } from 'lucide-react';
5
+ import {
6
+ Brain,
7
+ LucideArrowRight,
8
+ LucideBolt,
9
+ LucideCheck,
10
+ LucideChevronRight,
11
+ LucideSettings,
12
+ } from 'lucide-react';
5
13
  import { type AiModelForSelect } from 'model-bank';
6
14
  import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from 'react';
7
15
  import { useTranslation } from 'react-i18next';
@@ -16,21 +24,26 @@ import { agentSelectors } from '@/store/agent/selectors';
16
24
  import { type EnabledProviderWithModels } from '@/types/aiProvider';
17
25
 
18
26
  const STORAGE_KEY = 'MODEL_SWITCH_PANEL_WIDTH';
19
- const DEFAULT_WIDTH = 320;
27
+ const STORAGE_KEY_MODE = 'MODEL_SWITCH_PANEL_MODE';
28
+ const DEFAULT_WIDTH = 430;
20
29
  const MIN_WIDTH = 280;
21
30
  const MAX_WIDTH = 600;
22
- const MAX_PANEL_HEIGHT = 550;
31
+ const MAX_PANEL_HEIGHT = 460;
32
+ const TOOLBAR_HEIGHT = 40;
33
+ const FOOTER_HEIGHT = 48;
23
34
 
24
35
  const INITIAL_RENDER_COUNT = 15;
25
36
  const RENDER_ALL_DELAY_MS = 500;
26
37
 
27
38
  const ITEM_HEIGHT = {
28
- 'empty-model': 38,
29
- 'group-header': 40,
30
- 'model-item': 38,
31
- 'no-provider': 38,
39
+ 'empty-model': 32,
40
+ 'group-header': 32,
41
+ 'model-item': 32,
42
+ 'no-provider': 32,
32
43
  } as const;
33
44
 
45
+ type GroupMode = 'byModel' | 'byProvider';
46
+
34
47
  const ENABLE_RESIZING = {
35
48
  bottom: false,
36
49
  bottomLeft: false,
@@ -54,9 +67,44 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
54
67
  background: ${cssVar.colorBgElevated};
55
68
  box-shadow: ${cssVar.boxShadowSecondary};
56
69
  `,
70
+ footer: css`
71
+ position: sticky;
72
+ z-index: 10;
73
+ inset-block-end: 0;
74
+
75
+ padding-block: 6px;
76
+ padding-inline: 0;
77
+ border-block-start: 1px solid ${cssVar.colorBorderSecondary};
78
+
79
+ background: ${cssVar.colorBgElevated};
80
+ `,
81
+ footerButton: css`
82
+ cursor: pointer;
83
+
84
+ display: flex;
85
+ gap: 8px;
86
+ align-items: center;
87
+ justify-content: space-between;
88
+
89
+ box-sizing: border-box;
90
+ margin-inline: 8px;
91
+ padding-block: 6px;
92
+ padding-inline: 8px;
93
+ border-radius: ${cssVar.borderRadiusSM};
94
+
95
+ color: ${cssVar.colorTextSecondary};
96
+
97
+ transition: all 0.2s;
98
+
99
+ &:hover {
100
+ color: ${cssVar.colorText};
101
+ background: ${cssVar.colorFillTertiary};
102
+ }
103
+ `,
57
104
  groupHeader: css`
58
- padding-block: 8px;
59
- padding-inline: 12px;
105
+ margin-inline: 8px;
106
+ padding-block: 6px;
107
+ padding-inline: 8px;
60
108
  color: ${cssVar.colorTextSecondary};
61
109
  `,
62
110
  menuItem: css`
@@ -67,26 +115,84 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
67
115
  align-items: center;
68
116
 
69
117
  box-sizing: border-box;
70
- padding-block: 8px;
71
- padding-inline: 12px;
118
+ margin-inline: 8px;
119
+ padding-block: 6px;
120
+ padding-inline: 8px;
121
+ border-radius: ${cssVar.borderRadiusSM};
72
122
 
73
123
  white-space: nowrap;
74
124
 
75
125
  &:hover {
76
126
  background: ${cssVar.colorFillTertiary};
127
+
128
+ .settings-icon {
129
+ opacity: 1;
130
+ }
77
131
  }
78
132
  `,
79
133
  menuItemActive: css`
80
134
  background: ${cssVar.colorFillTertiary};
81
135
  `,
136
+ settingsIcon: css`
137
+ opacity: 0;
138
+ `,
139
+ submenu: css`
140
+ .ant-dropdown-menu {
141
+ padding: 4px;
142
+ }
143
+
144
+ .ant-dropdown-menu-item {
145
+ margin-inline: 0;
146
+ padding-block: 6px;
147
+ padding-inline: 8px;
148
+ border-radius: ${cssVar.borderRadiusSM};
149
+ }
150
+
151
+ .ant-dropdown-menu-item-group-title {
152
+ padding-block: 6px;
153
+ padding-inline: 8px;
154
+ font-size: 12px;
155
+ color: ${cssVar.colorTextSecondary};
156
+ }
157
+ `,
82
158
  tag: css`
83
159
  cursor: pointer;
84
160
  `,
161
+ toolbar: css`
162
+ position: sticky;
163
+ z-index: 10;
164
+ inset-block-start: 0;
165
+
166
+ display: flex;
167
+ align-items: center;
168
+ justify-content: flex-end;
169
+
170
+ padding-block: 6px;
171
+ padding-inline: 8px;
172
+ border-block-end: 1px solid ${cssVar.colorBorderSecondary};
173
+
174
+ background: ${cssVar.colorBgElevated};
175
+ `,
85
176
  }));
86
177
 
87
178
  const menuKey = (provider: string, model: string) => `${provider}-${model}`;
88
179
 
180
+ interface ModelWithProviders {
181
+ displayName: string;
182
+ model: AiModelForSelect;
183
+ providers: Array<{
184
+ id: string;
185
+ logo?: string;
186
+ name: string;
187
+ source?: EnabledProviderWithModels['source'];
188
+ }>;
189
+ }
190
+
89
191
  type VirtualItem =
192
+ | {
193
+ data: ModelWithProviders;
194
+ type: 'model-item';
195
+ }
90
196
  | {
91
197
  provider: EnabledProviderWithModels;
92
198
  type: 'group-header';
@@ -94,7 +200,7 @@ type VirtualItem =
94
200
  | {
95
201
  model: AiModelForSelect;
96
202
  provider: EnabledProviderWithModels;
97
- type: 'model-item';
203
+ type: 'provider-model-item';
98
204
  }
99
205
  | {
100
206
  provider: EnabledProviderWithModels;
@@ -148,6 +254,12 @@ const ModelSwitchPanel = memo<ModelSwitchPanelProps>(
148
254
  return stored ? Number(stored) : DEFAULT_WIDTH;
149
255
  });
150
256
 
257
+ const [groupMode, setGroupMode] = useState<GroupMode>(() => {
258
+ if (typeof window === 'undefined') return 'byModel';
259
+ const stored = localStorage.getItem(STORAGE_KEY_MODE);
260
+ return (stored as GroupMode) || 'byModel';
261
+ });
262
+
151
263
  const [renderAll, setRenderAll] = useState(false);
152
264
  const [internalOpen, setInternalOpen] = useState(false);
153
265
 
@@ -198,41 +310,74 @@ const ModelSwitchPanel = memo<ModelSwitchPanelProps>(
198
310
  [onModelChange, updateAgentConfig],
199
311
  );
200
312
 
201
- // Flatten the provider/model tree into a flat list for virtual scrolling
202
- const { virtualItems, panelHeight } = useMemo(() => {
313
+ // Build virtual items based on group mode
314
+ const virtualItems = useMemo(() => {
203
315
  if (enabledList.length === 0) {
204
- return {
205
- panelHeight: ITEM_HEIGHT['no-provider'],
206
- virtualItems: [{ type: 'no-provider' }] as VirtualItem[],
207
- };
316
+ return [{ type: 'no-provider' }] as VirtualItem[];
208
317
  }
209
318
 
210
- const items: VirtualItem[] = [];
211
- let totalHeight = 0;
212
-
213
- for (const providerItem of enabledList) {
214
- // Add provider group header
215
- items.push({ provider: providerItem, type: 'group-header' });
216
- totalHeight += ITEM_HEIGHT['group-header'];
319
+ if (groupMode === 'byModel') {
320
+ // Group models by display name
321
+ const modelMap = new Map<string, ModelWithProviders>();
217
322
 
218
- if (providerItem.children.length === 0) {
219
- // Add empty model placeholder
220
- items.push({ provider: providerItem, type: 'empty-model' });
221
- totalHeight += ITEM_HEIGHT['empty-model'];
222
- } else {
223
- // Add each model item
323
+ for (const providerItem of enabledList) {
224
324
  for (const modelItem of providerItem.children) {
225
- items.push({ model: modelItem, provider: providerItem, type: 'model-item' });
226
- totalHeight += ITEM_HEIGHT['model-item'];
325
+ const displayName = modelItem.displayName || modelItem.id;
326
+
327
+ if (!modelMap.has(displayName)) {
328
+ modelMap.set(displayName, {
329
+ displayName,
330
+ model: modelItem,
331
+ providers: [],
332
+ });
333
+ }
334
+
335
+ const entry = modelMap.get(displayName)!;
336
+ entry.providers.push({
337
+ id: providerItem.id,
338
+ logo: providerItem.logo,
339
+ name: providerItem.name,
340
+ source: providerItem.source,
341
+ });
342
+ }
343
+ }
344
+
345
+ // Convert to array and sort by display name
346
+ return Array.from(modelMap.values())
347
+ .sort((a, b) => a.displayName.localeCompare(b.displayName))
348
+ .map((data) => ({ data, type: 'model-item' as const }));
349
+ } else {
350
+ // Group by provider (original structure)
351
+ const items: VirtualItem[] = [];
352
+
353
+ for (const providerItem of enabledList) {
354
+ // Add provider group header
355
+ items.push({ provider: providerItem, type: 'group-header' });
356
+
357
+ if (providerItem.children.length === 0) {
358
+ // Add empty model placeholder
359
+ items.push({ provider: providerItem, type: 'empty-model' });
360
+ } else {
361
+ // Add each model item
362
+ for (const modelItem of providerItem.children) {
363
+ items.push({
364
+ model: modelItem,
365
+ provider: providerItem,
366
+ type: 'provider-model-item',
367
+ });
368
+ }
227
369
  }
228
370
  }
371
+
372
+ return items;
229
373
  }
374
+ }, [enabledList, groupMode]);
230
375
 
231
- return {
232
- panelHeight: Math.min(totalHeight, MAX_PANEL_HEIGHT),
233
- virtualItems: items,
234
- };
235
- }, [enabledList]);
376
+ // Use a fixed panel height to prevent shifting when switching modes
377
+ const panelHeight =
378
+ enabledList.length === 0
379
+ ? TOOLBAR_HEIGHT + ITEM_HEIGHT['no-provider'] + FOOTER_HEIGHT
380
+ : MAX_PANEL_HEIGHT;
236
381
 
237
382
  const activeKey = menuKey(provider, model);
238
383
 
@@ -299,7 +444,7 @@ const ModelSwitchPanel = memo<ModelSwitchPanelProps>(
299
444
  );
300
445
  }
301
446
 
302
- case 'model-item': {
447
+ case 'provider-model-item': {
303
448
  const key = menuKey(item.provider.id, item.model.id);
304
449
  const isActive = key === activeKey;
305
450
 
@@ -315,7 +460,7 @@ const ModelSwitchPanel = memo<ModelSwitchPanelProps>(
315
460
  <ModelItemRender
316
461
  {...item.model}
317
462
  {...item.model.abilities}
318
- infoTagTooltipOnHover
463
+ infoTagTooltip={false}
319
464
  newBadgeLabel={newLabel}
320
465
  showInfoTag
321
466
  />
@@ -323,6 +468,167 @@ const ModelSwitchPanel = memo<ModelSwitchPanelProps>(
323
468
  );
324
469
  }
325
470
 
471
+ case 'model-item': {
472
+ const { data } = item;
473
+ const hasSingleProvider = data.providers.length === 1;
474
+
475
+ // Check if this model is currently active and find active provider
476
+ const activeProvider = data.providers.find(
477
+ (p) => menuKey(p.id, data.model.id) === activeKey,
478
+ );
479
+ const isActive = !!activeProvider;
480
+ // Use active provider if found, otherwise use first provider for settings link
481
+ const settingsProvider = activeProvider || data.providers[0];
482
+
483
+ // Single provider - direct click without submenu
484
+ if (hasSingleProvider) {
485
+ const singleProvider = data.providers[0];
486
+ const key = menuKey(singleProvider.id, data.model.id);
487
+
488
+ return (
489
+ <div className={cx(styles.menuItem, isActive && styles.menuItemActive)} key={key}>
490
+ <Flexbox
491
+ align={'center'}
492
+ gap={8}
493
+ horizontal
494
+ justify={'space-between'}
495
+ onClick={async () => {
496
+ await handleModelChange(data.model.id, singleProvider.id);
497
+ handleOpenChange(false);
498
+ }}
499
+ style={{ flex: 1, minWidth: 0 }}
500
+ >
501
+ <ModelItemRender
502
+ {...data.model}
503
+ {...data.model.abilities}
504
+ infoTagTooltip={false}
505
+ newBadgeLabel={newLabel}
506
+ showInfoTag={false}
507
+ />
508
+ </Flexbox>
509
+ <div className={cx(styles.settingsIcon, 'settings-icon')}>
510
+ <ActionIcon
511
+ icon={LucideBolt}
512
+ onClick={(e) => {
513
+ e.preventDefault();
514
+ e.stopPropagation();
515
+ const url = urlJoin('/settings/provider', settingsProvider.id || 'all');
516
+ if (e.ctrlKey || e.metaKey) {
517
+ window.open(url, '_blank');
518
+ } else {
519
+ navigate(url);
520
+ }
521
+ }}
522
+ size={'small'}
523
+ title={t('ModelSwitchPanel.goToSettings')}
524
+ />
525
+ </div>
526
+ </div>
527
+ );
528
+ }
529
+
530
+ // Multiple providers - show submenu on hover
531
+ return (
532
+ <Dropdown
533
+ align={{ offset: [4, 0] }}
534
+ arrow={false}
535
+ dropdownRender={(menu) => (
536
+ <div className={styles.submenu} style={{ minWidth: 240 }}>
537
+ {menu}
538
+ </div>
539
+ )}
540
+ key={data.displayName}
541
+ menu={{
542
+ items: [
543
+ {
544
+ key: 'header',
545
+ label: t('ModelSwitchPanel.useModelFrom'),
546
+ type: 'group',
547
+ },
548
+ ...data.providers.map((p) => {
549
+ const isCurrentProvider = menuKey(p.id, data.model.id) === activeKey;
550
+ return {
551
+ key: menuKey(p.id, data.model.id),
552
+ label: (
553
+ <Flexbox
554
+ align={'center'}
555
+ gap={8}
556
+ horizontal
557
+ justify={'space-between'}
558
+ style={{ minWidth: 0 }}
559
+ >
560
+ <Flexbox align={'center'} gap={8} horizontal style={{ minWidth: 0 }}>
561
+ <div style={{ flexShrink: 0, width: 16 }}>
562
+ {isCurrentProvider && (
563
+ <Icon
564
+ icon={LucideCheck}
565
+ size={16}
566
+ style={{ color: cssVar.colorPrimary }}
567
+ />
568
+ )}
569
+ </div>
570
+ <ProviderItemRender
571
+ logo={p.logo}
572
+ name={p.name}
573
+ provider={p.id}
574
+ source={p.source}
575
+ />
576
+ </Flexbox>
577
+ <ActionIcon
578
+ icon={LucideBolt}
579
+ onClick={(e) => {
580
+ e.preventDefault();
581
+ e.stopPropagation();
582
+ const url = urlJoin('/settings/provider', p.id || 'all');
583
+ if (e.ctrlKey || e.metaKey) {
584
+ window.open(url, '_blank');
585
+ } else {
586
+ navigate(url);
587
+ }
588
+ }}
589
+ size={'small'}
590
+ title={t('ModelSwitchPanel.goToSettings')}
591
+ />
592
+ </Flexbox>
593
+ ),
594
+ onClick: async () => {
595
+ await handleModelChange(data.model.id, p.id);
596
+ handleOpenChange(false);
597
+ },
598
+ };
599
+ }),
600
+ ],
601
+ }}
602
+ // @ts-ignore
603
+ placement="rightTop"
604
+ trigger={['hover']}
605
+ >
606
+ <div className={cx(styles.menuItem, isActive && styles.menuItemActive)}>
607
+ <Flexbox
608
+ align={'center'}
609
+ gap={8}
610
+ horizontal
611
+ justify={'space-between'}
612
+ style={{ width: '100%' }}
613
+ >
614
+ <ModelItemRender
615
+ {...data.model}
616
+ {...data.model.abilities}
617
+ infoTagTooltip={false}
618
+ newBadgeLabel={newLabel}
619
+ showInfoTag={false}
620
+ />
621
+ <Icon
622
+ icon={LucideChevronRight}
623
+ size={16}
624
+ style={{ color: cssVar.colorTextSecondary, flexShrink: 0 }}
625
+ />
626
+ </Flexbox>
627
+ </div>
628
+ </Dropdown>
629
+ );
630
+ }
631
+
326
632
  default: {
327
633
  return null;
328
634
  }
@@ -354,11 +660,56 @@ const ModelSwitchPanel = memo<ModelSwitchPanelProps>(
354
660
  size={{ height: panelHeight, width: panelWidth }}
355
661
  style={{ position: 'relative' }}
356
662
  >
357
- <div style={{ height: panelHeight, overflow: 'auto', width: '100%' }}>
663
+ <div className={styles.toolbar}>
664
+ <Segmented
665
+ onChange={(value) => {
666
+ const mode = value as GroupMode;
667
+ setGroupMode(mode);
668
+ localStorage.setItem(STORAGE_KEY_MODE, mode);
669
+ }}
670
+ options={[
671
+ {
672
+ icon: <Icon icon={Brain} />,
673
+ title: t('ModelSwitchPanel.byModel'),
674
+ value: 'byModel',
675
+ },
676
+ {
677
+ icon: <Icon icon={ProviderIcon} />,
678
+ title: t('ModelSwitchPanel.byProvider'),
679
+ value: 'byProvider',
680
+ },
681
+ ]}
682
+ size="small"
683
+ value={groupMode}
684
+ />
685
+ </div>
686
+ <div
687
+ style={{
688
+ height: panelHeight - TOOLBAR_HEIGHT - FOOTER_HEIGHT,
689
+ overflow: 'auto',
690
+ paddingBlock: groupMode === 'byModel' ? 8 : 0,
691
+ width: '100%',
692
+ }}
693
+ >
358
694
  {(renderAll ? virtualItems : virtualItems.slice(0, INITIAL_RENDER_COUNT)).map(
359
695
  renderVirtualItem,
360
696
  )}
361
697
  </div>
698
+ <div className={styles.footer}>
699
+ <div
700
+ className={styles.footerButton}
701
+ onClick={() => {
702
+ navigate('/settings/provider/all');
703
+ handleOpenChange(false);
704
+ }}
705
+ >
706
+ <Flexbox align={'center'} gap={8} horizontal style={{ flex: 1 }}>
707
+ <Icon icon={LucideSettings} size={16} />
708
+ {t('ModelSwitchPanel.manageProvider')}
709
+ </Flexbox>
710
+ <Icon icon={LucideArrowRight} size={16} />
711
+ </div>
712
+ </div>
362
713
  </Rnd>
363
714
  )}
364
715
  >
@@ -107,11 +107,15 @@ export default {
107
107
  'ModelSelect.featureTag.vision': 'This model supports visual recognition.',
108
108
  'ModelSelect.removed':
109
109
  'The model is not in the list. It will be automatically removed if deselected.',
110
+ 'ModelSwitchPanel.byModel': 'By Model',
111
+ 'ModelSwitchPanel.byProvider': 'By Provider',
110
112
  'ModelSwitchPanel.emptyModel': 'No enabled model. Please go to settings to enable.',
111
113
  'ModelSwitchPanel.emptyProvider': 'No enabled providers. Please go to settings to enable one.',
112
114
  'ModelSwitchPanel.goToSettings': 'Go to settings',
115
+ 'ModelSwitchPanel.manageProvider': 'Manage Provider',
113
116
  'ModelSwitchPanel.provider': 'Provider',
114
117
  'ModelSwitchPanel.title': 'Model',
118
+ 'ModelSwitchPanel.useModelFrom': 'Use this model from:',
115
119
  'MultiImagesUpload.actions.uploadMore': 'Click or drag to upload more',
116
120
  'MultiImagesUpload.modal.complete': 'Done',
117
121
  'MultiImagesUpload.modal.newFileIndicator': 'New',
@@ -62,6 +62,7 @@ describe('aiModelSelectors', () => {
62
62
  ],
63
63
  activeProviderModelList: [],
64
64
  aiProviderConfigUpdatingIds: [],
65
+ aiProviderDetailMap: {},
65
66
  aiProviderList: [],
66
67
  aiProviderLoadingIds: [],
67
68
  providerSearchKeyword: '',
@@ -10,11 +10,13 @@ describe('aiProviderSelectors', () => {
10
10
  { id: 'provider3', enabled: true, sort: 0, source: 'builtin' },
11
11
  { id: 'custom1', enabled: false, sort: 3, source: 'custom' },
12
12
  ],
13
- aiProviderDetail: {
14
- id: 'provider1',
15
- keyVaults: {
16
- baseURL: 'https://api.example.com',
17
- apiKey: 'test-key',
13
+ aiProviderDetailMap: {
14
+ provider1: {
15
+ id: 'provider1',
16
+ keyVaults: {
17
+ baseURL: 'https://api.example.com',
18
+ apiKey: 'test-key',
19
+ },
18
20
  },
19
21
  },
20
22
  aiProviderLoadingIds: ['loading-provider'],
@@ -97,20 +99,37 @@ describe('aiProviderSelectors', () => {
97
99
  });
98
100
  });
99
101
 
102
+ describe('providerDetailById', () => {
103
+ it('should return provider detail by id', () => {
104
+ expect(aiProviderSelectors.providerDetailById('provider1')(mockState)).toEqual(
105
+ mockState.aiProviderDetailMap.provider1,
106
+ );
107
+ });
108
+
109
+ it('should return undefined for non-existing provider', () => {
110
+ expect(aiProviderSelectors.providerDetailById('non-existing')(mockState)).toBeUndefined();
111
+ });
112
+ });
113
+
100
114
  describe('activeProviderConfig', () => {
101
- it('should return active provider config', () => {
115
+ it('should return active provider config from map', () => {
102
116
  expect(aiProviderSelectors.activeProviderConfig(mockState)).toEqual(
103
- mockState.aiProviderDetail,
117
+ mockState.aiProviderDetailMap.provider1,
104
118
  );
105
119
  });
120
+
121
+ it('should return undefined when no active provider', () => {
122
+ const stateWithoutActive = { ...mockState, activeAiProvider: undefined };
123
+ expect(aiProviderSelectors.activeProviderConfig(stateWithoutActive)).toBeUndefined();
124
+ });
106
125
  });
107
126
 
108
127
  describe('isAiProviderConfigLoading', () => {
109
- it('should return true if provider id does not match active provider', () => {
128
+ it('should return true if provider is not in detail map (not loaded)', () => {
110
129
  expect(aiProviderSelectors.isAiProviderConfigLoading('provider2')(mockState)).toBe(true);
111
130
  });
112
131
 
113
- it('should return false if provider id matches active provider', () => {
132
+ it('should return false if provider is in detail map (loaded)', () => {
114
133
  expect(aiProviderSelectors.isAiProviderConfigLoading('provider1')(mockState)).toBe(false);
115
134
  });
116
135
  });
@@ -123,7 +142,9 @@ describe('aiProviderSelectors', () => {
123
142
  it('should return false when no endpoint info exists', () => {
124
143
  const stateWithoutEndpoint = {
125
144
  ...mockState,
126
- aiProviderDetail: { keyVaults: {} },
145
+ aiProviderDetailMap: {
146
+ provider1: { id: 'provider1', keyVaults: {} },
147
+ },
127
148
  };
128
149
  expect(aiProviderSelectors.isActiveProviderEndpointNotEmpty(stateWithoutEndpoint)).toBe(
129
150
  false,
@@ -139,7 +160,9 @@ describe('aiProviderSelectors', () => {
139
160
  it('should return false when no api key exists', () => {
140
161
  const stateWithoutApiKey = {
141
162
  ...mockState,
142
- aiProviderDetail: { keyVaults: {} },
163
+ aiProviderDetailMap: {
164
+ provider1: { id: 'provider1', keyVaults: {} },
165
+ },
143
166
  };
144
167
  expect(aiProviderSelectors.isActiveProviderApiKeyNotEmpty(stateWithoutApiKey)).toBe(false);
145
168
  });
@@ -29,6 +29,7 @@ import {
29
29
  type UpdateAiProviderParams,
30
30
  } from '@/types/aiProvider';
31
31
 
32
+
32
33
  export type ProviderModelListItem = {
33
34
  abilities: ModelAbilities;
34
35
  approximatePricePerImage?: number;
@@ -293,7 +294,14 @@ export const createAiProviderSlice: StateCreator<
293
294
  onSuccess: (data) => {
294
295
  if (!data) return;
295
296
 
296
- set({ activeAiProvider: id, aiProviderDetail: data }, false, 'useFetchAiProviderItem');
297
+ set(
298
+ (state) => ({
299
+ activeAiProvider: id,
300
+ aiProviderDetailMap: { ...state.aiProviderDetailMap, [id]: data },
301
+ }),
302
+ false,
303
+ 'useFetchAiProviderItem',
304
+ );
297
305
  },
298
306
  },
299
307
  ),