@lobehub/chat 0.161.9 → 0.161.11

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 (74) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/package.json +2 -1
  3. package/src/app/(main)/_layout/Desktop.tsx +2 -2
  4. package/src/app/(main)/settings/llm/components/ProviderConfig/index.tsx +5 -2
  5. package/src/app/(main)/settings/llm/components/ProviderModelList/CustomModelOption.tsx +6 -7
  6. package/src/app/(main)/settings/llm/components/ProviderModelList/{ModelConfigModal.tsx → ModelConfigModal/Form.tsx} +19 -63
  7. package/src/app/(main)/settings/llm/components/ProviderModelList/ModelConfigModal/index.tsx +78 -0
  8. package/src/app/(main)/settings/llm/components/ProviderModelList/Option.tsx +35 -11
  9. package/src/app/(main)/settings/llm/components/ProviderModelList/index.tsx +15 -18
  10. package/src/app/layout.tsx +2 -0
  11. package/src/components/ModelProviderIcon/index.tsx +2 -2
  12. package/src/components/ModelSelect/index.tsx +5 -14
  13. package/src/const/layoutTokens.ts +1 -0
  14. package/src/features/PWAInstall/index.tsx +22 -0
  15. package/src/features/User/UserPanel/PanelContent.tsx +1 -1
  16. package/src/hooks/usePWAInstall.test.ts +78 -0
  17. package/src/hooks/usePWAInstall.ts +23 -2
  18. package/src/hooks/usePlatform.test.ts +82 -0
  19. package/src/hooks/usePlatform.ts +19 -2
  20. package/src/hooks/useSyncData.ts +3 -1
  21. package/src/layout/GlobalProvider/StoreInitialization.tsx +17 -9
  22. package/src/layout/GlobalProvider/index.tsx +1 -1
  23. package/src/locales/default/components.ts +1 -0
  24. package/src/services/message/client.test.ts +0 -24
  25. package/src/services/message/client.ts +0 -5
  26. package/src/services/message/type.ts +0 -1
  27. package/src/services/user/client.test.ts +100 -0
  28. package/src/services/user/client.ts +16 -14
  29. package/src/services/user/index.ts +0 -2
  30. package/src/services/user/type.ts +2 -4
  31. package/src/store/user/initialState.ts +10 -1
  32. package/src/store/user/selectors.ts +3 -7
  33. package/src/store/user/slices/auth/action.test.ts +5 -87
  34. package/src/store/user/slices/auth/action.ts +3 -58
  35. package/src/store/user/slices/auth/initialState.ts +2 -1
  36. package/src/store/user/slices/common/action.test.ts +196 -20
  37. package/src/store/user/slices/common/action.ts +55 -26
  38. package/src/store/user/slices/common/initialState.ts +9 -0
  39. package/src/store/user/slices/modelList/action.test.ts +363 -0
  40. package/src/store/user/slices/{settings/actions/llm.ts → modelList/action.ts} +66 -60
  41. package/src/store/user/slices/modelList/initialState.ts +15 -0
  42. package/src/store/user/slices/modelList/selectors/index.ts +2 -0
  43. package/src/store/user/slices/{settings → modelList}/selectors/modelConfig.test.ts +3 -2
  44. package/src/store/user/slices/{settings → modelList}/selectors/modelConfig.ts +1 -1
  45. package/src/store/user/slices/{settings → modelList}/selectors/modelProvider.test.ts +7 -7
  46. package/src/store/user/slices/{settings → modelList}/selectors/modelProvider.ts +2 -4
  47. package/src/store/user/slices/preference/action.test.ts +0 -52
  48. package/src/store/user/slices/preference/action.ts +1 -17
  49. package/src/store/user/slices/preference/initialState.ts +0 -5
  50. package/src/store/user/slices/preference/selectors.test.ts +2 -2
  51. package/src/store/user/slices/preference/selectors.ts +1 -1
  52. package/src/store/user/slices/settings/{actions/general.ts → action.ts} +5 -5
  53. package/src/store/user/slices/settings/initialState.ts +0 -12
  54. package/src/store/user/slices/settings/selectors/index.ts +0 -3
  55. package/src/store/user/slices/sync/action.test.ts +19 -5
  56. package/src/store/user/slices/sync/action.ts +9 -6
  57. package/src/store/user/slices/{settings/selectors/sync.ts → sync/selectors.ts} +2 -2
  58. package/src/store/user/store.ts +5 -2
  59. package/src/types/serverConfig.ts +3 -1
  60. package/src/types/user/index.ts +13 -0
  61. package/src/utils/parseModels.test.ts +121 -1
  62. package/src/utils/parseModels.ts +9 -4
  63. package/src/utils/platform.test.ts +83 -0
  64. package/src/utils/platform.ts +33 -2
  65. package/src/hooks/useIsPWA.ts +0 -13
  66. package/src/store/user/slices/settings/actions/index.ts +0 -18
  67. package/src/store/user/slices/settings/actions/llm.test.ts +0 -136
  68. package/src/utils/matchMedia.ts +0 -10
  69. /package/src/app/(main)/settings/llm/components/ProviderModelList/{MaxTokenSlider.tsx → ModelConfigModal/MaxTokenSlider.tsx} +0 -0
  70. /package/src/store/user/slices/{settings → modelList}/reducers/customModelCard.test.ts +0 -0
  71. /package/src/store/user/slices/{settings → modelList}/reducers/customModelCard.ts +0 -0
  72. /package/src/store/user/slices/settings/{actions/general.test.ts → action.test.ts} +0 -0
  73. /package/src/store/user/slices/settings/selectors/__snapshots__/{selectors.test.ts.snap → settings.test.ts.snap} +0 -0
  74. /package/src/store/user/slices/settings/selectors/{selectors.test.ts → settings.test.ts} +0 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,56 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ### [Version 0.161.11](https://github.com/lobehub/lobe-chat/compare/v0.161.10...v0.161.11)
6
+
7
+ <sup>Released on **2024-05-23**</sup>
8
+
9
+ #### 💄 Styles
10
+
11
+ - **misc**: Improve PWA install guide.
12
+
13
+ <br/>
14
+
15
+ <details>
16
+ <summary><kbd>Improvements and Fixes</kbd></summary>
17
+
18
+ #### Styles
19
+
20
+ - **misc**: Improve PWA install guide, closes [#2617](https://github.com/lobehub/lobe-chat/issues/2617) ([7fee545](https://github.com/lobehub/lobe-chat/commit/7fee545))
21
+
22
+ </details>
23
+
24
+ <div align="right">
25
+
26
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
27
+
28
+ </div>
29
+
30
+ ### [Version 0.161.10](https://github.com/lobehub/lobe-chat/compare/v0.161.9...v0.161.10)
31
+
32
+ <sup>Released on **2024-05-23**</sup>
33
+
34
+ #### 🐛 Bug Fixes
35
+
36
+ - **misc**: Refactor user store and fix custom model list form.
37
+
38
+ <br/>
39
+
40
+ <details>
41
+ <summary><kbd>Improvements and Fixes</kbd></summary>
42
+
43
+ #### What's fixed
44
+
45
+ - **misc**: Refactor user store and fix custom model list form, closes [#2620](https://github.com/lobehub/lobe-chat/issues/2620) ([81ea886](https://github.com/lobehub/lobe-chat/commit/81ea886))
46
+
47
+ </details>
48
+
49
+ <div align="right">
50
+
51
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
52
+
53
+ </div>
54
+
5
55
  ### [Version 0.161.9](https://github.com/lobehub/lobe-chat/compare/v0.161.8...v0.161.9)
6
56
 
7
57
  <sup>Released on **2024-05-23**</sup>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/chat",
3
- "version": "0.161.9",
3
+ "version": "0.161.11",
4
4
  "description": "Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
5
5
  "keywords": [
6
6
  "framework",
@@ -94,6 +94,7 @@
94
94
  "@clerk/themes": "^2.1.6",
95
95
  "@google/generative-ai": "^0.11.3",
96
96
  "@icons-pack/react-simple-icons": "^9.5.0",
97
+ "@khmyznikov/pwa-install": "^0.3.9",
97
98
  "@lobehub/chat-plugin-sdk": "latest",
98
99
  "@lobehub/chat-plugins-gateway": "latest",
99
100
  "@lobehub/icons": "^1.22.0",
@@ -4,12 +4,12 @@ import { useTheme } from 'antd-style';
4
4
  import { memo } from 'react';
5
5
  import { Flexbox } from 'react-layout-kit';
6
6
 
7
- import { useIsPWA } from '@/hooks/useIsPWA';
7
+ import { usePlatform } from '@/hooks/usePlatform';
8
8
 
9
9
  import { LayoutProps } from './type';
10
10
 
11
11
  const Layout = memo<LayoutProps>(({ children, nav }) => {
12
- const isPWA = useIsPWA();
12
+ const { isPWA } = usePlatform();
13
13
  const theme = useTheme();
14
14
 
15
15
  return (
@@ -50,6 +50,8 @@ interface ProviderConfigProps {
50
50
  canDeactivate?: boolean;
51
51
  checkModel?: string;
52
52
  checkerItem?: FormItemProps;
53
+ className?: string;
54
+ hideSwitch?: boolean;
53
55
  modelList?: {
54
56
  azureDeployName?: boolean;
55
57
  notFoundContent?: ReactNode;
@@ -81,11 +83,12 @@ const ProviderConfig = memo<ProviderConfigProps>(
81
83
  checkerItem,
82
84
  modelList,
83
85
  showBrowserRequest,
86
+ className,
84
87
  }) => {
85
88
  const { t } = useTranslation('setting');
86
89
  const { t: modelT } = useTranslation('modelProvider');
87
90
  const [form] = Form.useForm();
88
- const { styles } = useStyles();
91
+ const { cx, styles } = useStyles();
89
92
  const [
90
93
  toggleProviderEnabled,
91
94
  setSettings,
@@ -192,7 +195,7 @@ const ProviderConfig = memo<ProviderConfigProps>(
192
195
 
193
196
  return (
194
197
  <Form
195
- className={styles.form}
198
+ className={cx(styles.form, className)}
196
199
  form={form}
197
200
  items={[model]}
198
201
  onValuesChange={debounce(setSettings, 100)}
@@ -71,18 +71,17 @@ const CustomModelOption = memo<CustomModelOptionProps>(({ id, provider }) => {
71
71
  e.stopPropagation();
72
72
  e.preventDefault();
73
73
 
74
- const isConfirm = await modal.confirm({
74
+ await modal.confirm({
75
75
  centered: true,
76
76
  content: s('llm.customModelCards.confirmDelete'),
77
77
  okButtonProps: { danger: true },
78
+ onOk: async () => {
79
+ // delete model and deactivate id
80
+ await dispatchCustomModelCards(provider, { id, type: 'delete' });
81
+ await removeEnabledModels(provider, id);
82
+ },
78
83
  type: 'warning',
79
84
  });
80
-
81
- // delete model and deactive id
82
- if (isConfirm) {
83
- await dispatchCustomModelCards(provider, { id, type: 'delete' });
84
- await removeEnabledModels(provider, id);
85
- }
86
85
  }}
87
86
  title={t('delete')}
88
87
  />
@@ -1,71 +1,28 @@
1
- import { Modal } from '@lobehub/ui';
2
- import { Button, Checkbox, Form, Input } from 'antd';
3
- import isEqual from 'fast-deep-equal';
4
- import { memo } from 'react';
1
+ import { Checkbox, Form, FormInstance, Input } from 'antd';
2
+ import { memo, useEffect } from 'react';
5
3
  import { useTranslation } from 'react-i18next';
6
4
 
7
- import { useUserStore } from '@/store/user';
8
- import { modelConfigSelectors } from '@/store/user/selectors';
5
+ import { ChatModelCard } from '@/types/llm';
9
6
 
10
7
  import MaxTokenSlider from './MaxTokenSlider';
11
8
 
12
- interface ModelConfigModalProps {
13
- provider?: string;
9
+ interface ModelConfigFormProps {
10
+ initialValues?: ChatModelCard;
11
+ onFormInstanceReady: (instance: FormInstance) => void;
14
12
  showAzureDeployName?: boolean;
15
13
  }
16
- const ModelConfigModal = memo<ModelConfigModalProps>(({ showAzureDeployName, provider }) => {
17
- const [formInstance] = Form.useForm();
18
- const { t } = useTranslation('setting');
19
- const { t: tc } = useTranslation('common');
20
14
 
21
- const [open, id, editingProvider, dispatchCustomModelCards, toggleEditingCustomModelCard] =
22
- useUserStore((s) => [
23
- !!s.editingCustomCardModel && provider === s.editingCustomCardModel?.provider,
24
- s.editingCustomCardModel?.id,
25
- s.editingCustomCardModel?.provider,
26
- s.dispatchCustomModelCards,
27
- s.toggleEditingCustomModelCard,
28
- ]);
15
+ const ModelConfigForm = memo<ModelConfigFormProps>(
16
+ ({ showAzureDeployName, onFormInstanceReady, initialValues }) => {
17
+ const { t } = useTranslation('setting');
29
18
 
30
- const modelCard = useUserStore(
31
- modelConfigSelectors.getCustomModelCard({ id, provider: editingProvider }),
32
- isEqual,
33
- );
19
+ const [formInstance] = Form.useForm();
34
20
 
35
- const closeModal = () => {
36
- toggleEditingCustomModelCard(undefined);
37
- };
21
+ useEffect(() => {
22
+ onFormInstanceReady(formInstance);
23
+ }, []);
38
24
 
39
- return (
40
- <Modal
41
- destroyOnClose
42
- footer={[
43
- <Button key="cancel" onClick={closeModal}>
44
- {tc('cancel')}
45
- </Button>,
46
-
47
- <Button
48
- key="ok"
49
- onClick={() => {
50
- if (!editingProvider || !id) return;
51
- const data = formInstance.getFieldsValue();
52
-
53
- dispatchCustomModelCards(editingProvider as any, { id, type: 'update', value: data });
54
-
55
- closeModal();
56
- }}
57
- style={{ marginInlineStart: '16px' }}
58
- type="primary"
59
- >
60
- {tc('ok')}
61
- </Button>,
62
- ]}
63
- maskClosable
64
- onCancel={closeModal}
65
- open={open}
66
- title={t('llm.customModelCards.modelConfig.modalTitle')}
67
- zIndex={1051} // Select is 1050
68
- >
25
+ return (
69
26
  <div
70
27
  onClick={(e) => {
71
28
  e.stopPropagation();
@@ -77,9 +34,8 @@ const ModelConfigModal = memo<ModelConfigModalProps>(({ showAzureDeployName, pro
77
34
  <Form
78
35
  colon={false}
79
36
  form={formInstance}
80
- initialValues={modelCard}
37
+ initialValues={initialValues}
81
38
  labelCol={{ span: 4 }}
82
- preserve={false}
83
39
  style={{ marginTop: 16 }}
84
40
  wrapperCol={{ offset: 1, span: 18 }}
85
41
  >
@@ -136,7 +92,7 @@ const ModelConfigModal = memo<ModelConfigModalProps>(({ showAzureDeployName, pro
136
92
  </Form.Item>
137
93
  </Form>
138
94
  </div>
139
- </Modal>
140
- );
141
- });
142
- export default ModelConfigModal;
95
+ );
96
+ },
97
+ );
98
+ export default ModelConfigForm;
@@ -0,0 +1,78 @@
1
+ import { Modal } from '@lobehub/ui';
2
+ import { Button, FormInstance } from 'antd';
3
+ import isEqual from 'fast-deep-equal';
4
+ import { memo, useState } from 'react';
5
+ import { useTranslation } from 'react-i18next';
6
+
7
+ import { useUserStore } from '@/store/user';
8
+ import { modelConfigSelectors } from '@/store/user/slices/modelList/selectors';
9
+
10
+ import ModelConfigForm from './Form';
11
+
12
+ interface ModelConfigModalProps {
13
+ provider?: string;
14
+ showAzureDeployName?: boolean;
15
+ }
16
+
17
+ const ModelConfigModal = memo<ModelConfigModalProps>(({ showAzureDeployName, provider }) => {
18
+ const { t } = useTranslation('setting');
19
+ const { t: tc } = useTranslation('common');
20
+ const [formInstance, setFormInstance] = useState<FormInstance>();
21
+
22
+ const [open, id, editingProvider, dispatchCustomModelCards, toggleEditingCustomModelCard] =
23
+ useUserStore((s) => [
24
+ !!s.editingCustomCardModel && provider === s.editingCustomCardModel?.provider,
25
+ s.editingCustomCardModel?.id,
26
+ s.editingCustomCardModel?.provider,
27
+ s.dispatchCustomModelCards,
28
+ s.toggleEditingCustomModelCard,
29
+ ]);
30
+
31
+ const modelCard = useUserStore(
32
+ modelConfigSelectors.getCustomModelCard({ id, provider: editingProvider }),
33
+ isEqual,
34
+ );
35
+
36
+ const closeModal = () => {
37
+ toggleEditingCustomModelCard(undefined);
38
+ };
39
+
40
+ return (
41
+ <Modal
42
+ destroyOnClose
43
+ footer={[
44
+ <Button key="cancel" onClick={closeModal}>
45
+ {tc('cancel')}
46
+ </Button>,
47
+
48
+ <Button
49
+ key="ok"
50
+ onClick={() => {
51
+ if (!editingProvider || !id || !formInstance) return;
52
+ const data = formInstance.getFieldsValue();
53
+
54
+ dispatchCustomModelCards(editingProvider as any, { id, type: 'update', value: data });
55
+
56
+ closeModal();
57
+ }}
58
+ style={{ marginInlineStart: '16px' }}
59
+ type="primary"
60
+ >
61
+ {tc('ok')}
62
+ </Button>,
63
+ ]}
64
+ maskClosable
65
+ onCancel={closeModal}
66
+ open={open}
67
+ title={t('llm.customModelCards.modelConfig.modalTitle')}
68
+ zIndex={1251} // Select is 1150
69
+ >
70
+ <ModelConfigForm
71
+ initialValues={modelCard}
72
+ onFormInstanceReady={setFormInstance}
73
+ showAzureDeployName={showAzureDeployName}
74
+ />
75
+ </Modal>
76
+ );
77
+ });
78
+ export default ModelConfigModal;
@@ -1,6 +1,10 @@
1
+ import { ActionIcon, Tooltip } from '@lobehub/ui';
1
2
  import { Typography } from 'antd';
3
+ import { useTheme } from 'antd-style';
2
4
  import isEqual from 'fast-deep-equal';
5
+ import { Recycle } from 'lucide-react';
3
6
  import { memo } from 'react';
7
+ import { useTranslation } from 'react-i18next';
4
8
  import { Flexbox } from 'react-layout-kit';
5
9
 
6
10
  import ModelIcon from '@/components/ModelIcon';
@@ -16,25 +20,45 @@ interface OptionRenderProps {
16
20
  id: string;
17
21
  isAzure?: boolean;
18
22
  provider: GlobalLLMProviderKey;
23
+ removed?: boolean;
19
24
  }
20
- const OptionRender = memo<OptionRenderProps>(({ displayName, id, provider, isAzure }) => {
25
+ const OptionRender = memo<OptionRenderProps>(({ displayName, id, provider, isAzure, removed }) => {
21
26
  const model = useUserStore((s) => modelProviderSelectors.getModelCardById(id)(s), isEqual);
22
-
27
+ const { t } = useTranslation('components');
28
+ const theme = useTheme();
23
29
  // if there is isCustom, it means it is a user defined custom model
24
30
  if (model?.isCustom || isAzure) return <CustomModelOption id={id} provider={provider} />;
25
31
 
26
32
  return (
27
- <Flexbox align={'center'} gap={8} horizontal>
28
- <ModelIcon model={id} size={32} />
29
- <Flexbox>
30
- <Flexbox align={'center'} gap={8} horizontal>
31
- {displayName}
32
- <ModelInfoTags directionReverse placement={'top'} {...model!} />
33
+ <Flexbox
34
+ align={'center'}
35
+ gap={8}
36
+ horizontal
37
+ justify={'space-between'}
38
+ style={{ paddingInlineEnd: 8 }}
39
+ >
40
+ <Flexbox align={'center'} gap={8} horizontal>
41
+ <ModelIcon model={id} size={32} />
42
+ <Flexbox>
43
+ <Flexbox align={'center'} gap={8} horizontal>
44
+ {displayName}
45
+ <ModelInfoTags directionReverse placement={'top'} {...model!} />
46
+ </Flexbox>
47
+ <Typography.Text style={{ fontSize: 12 }} type={'secondary'}>
48
+ {id}
49
+ </Typography.Text>
33
50
  </Flexbox>
34
- <Typography.Text style={{ fontSize: 12 }} type={'secondary'}>
35
- {id}
36
- </Typography.Text>
37
51
  </Flexbox>
52
+ {removed && (
53
+ <Tooltip
54
+ overlayStyle={{ maxWidth: 300 }}
55
+ placement={'top'}
56
+ style={{ pointerEvents: 'none' }}
57
+ title={t('ModelSelect.removed')}
58
+ >
59
+ <ActionIcon icon={Recycle} style={{ color: theme.colorWarning }} />
60
+ </Tooltip>
61
+ )}
38
62
  </Flexbox>
39
63
  );
40
64
  });
@@ -51,10 +51,9 @@ const ProviderModelListSelect = memo<CustomModelSelectProps>(
51
51
  ({ showModelFetcher = false, provider, showAzureDeployName, notFoundContent, placeholder }) => {
52
52
  const { t } = useTranslation('common');
53
53
  const { t: transSetting } = useTranslation('setting');
54
- const [setModelProviderConfig, dispatchCustomModelCards] = useUserStore((s) => [
54
+ const [setModelProviderConfig, updateEnabledModels] = useUserStore((s) => [
55
55
  s.setModelProviderConfig,
56
- s.dispatchCustomModelCards,
57
- s.useFetchProviderModelList,
56
+ s.updateEnabledModels,
58
57
  ]);
59
58
 
60
59
  const chatModelCards = useUserStore(
@@ -94,21 +93,7 @@ const ProviderModelListSelect = memo<CustomModelSelectProps>(
94
93
  mode="tags"
95
94
  notFoundContent={notFoundContent}
96
95
  onChange={(value, options) => {
97
- setModelProviderConfig(provider, { enabledModels: value.filter(Boolean) });
98
-
99
- // if there is a new model, add it to `customModelCards`
100
- options.forEach((option: { label?: string; value?: string }, index: number) => {
101
- // if is a known model, it should have value
102
- // if is an unknown model, the option will be {}
103
- if (option.value) return;
104
-
105
- const modelId = value[index];
106
-
107
- dispatchCustomModelCards(provider, {
108
- modelCard: { id: modelId },
109
- type: 'add',
110
- });
111
- });
96
+ updateEnabledModels(provider, value, options as any[]);
112
97
  }}
113
98
  optionFilterProp="label"
114
99
  optionRender={({ label, value }) => {
@@ -123,6 +108,18 @@ const ProviderModelListSelect = memo<CustomModelSelectProps>(
123
108
  />
124
109
  );
125
110
 
111
+ if (enabledModels?.some((m) => value === m)) {
112
+ return (
113
+ <OptionRender
114
+ displayName={label as string}
115
+ id={value as string}
116
+ isAzure={showAzureDeployName}
117
+ provider={provider}
118
+ removed
119
+ />
120
+ );
121
+ }
122
+
126
123
  // model is defined by user in client
127
124
  return (
128
125
  <Flexbox align={'center'} gap={8} horizontal>
@@ -6,6 +6,7 @@ import { isRtlLang } from 'rtl-detect';
6
6
 
7
7
  import Analytics from '@/components/Analytics';
8
8
  import { DEFAULT_LANG, LOBE_LOCALE_COOKIE } from '@/const/locale';
9
+ import PWAInstall from '@/features/PWAInstall';
9
10
  import AuthProvider from '@/layout/AuthProvider';
10
11
  import GlobalProvider from '@/layout/GlobalProvider';
11
12
  import { isMobileDevice } from '@/utils/responsive';
@@ -31,6 +32,7 @@ const RootLayout = async ({ children, modal }: RootLayoutProps) => {
31
32
  {children}
32
33
  {modal}
33
34
  </AuthProvider>
35
+ <PWAInstall />
34
36
  </GlobalProvider>
35
37
  <Analytics />
36
38
  {inVercel && <SpeedInsights />}
@@ -5,6 +5,7 @@ import {
5
5
  DeepSeek,
6
6
  Google,
7
7
  Groq,
8
+ LobeHub,
8
9
  Minimax,
9
10
  Mistral,
10
11
  Moonshot,
@@ -16,7 +17,6 @@ import {
16
17
  ZeroOne,
17
18
  Zhipu,
18
19
  } from '@lobehub/icons';
19
- import { Logo } from '@lobehub/ui';
20
20
  import { memo } from 'react';
21
21
  import { Center } from 'react-layout-kit';
22
22
 
@@ -29,7 +29,7 @@ interface ModelProviderIconProps {
29
29
  const ModelProviderIcon = memo<ModelProviderIconProps>(({ provider }) => {
30
30
  switch (provider) {
31
31
  case 'lobehub': {
32
- return <Logo size={20} />;
32
+ return <LobeHub size={20} />;
33
33
  }
34
34
 
35
35
  case ModelProvider.ZhiPu: {
@@ -79,10 +79,10 @@ export const ModelInfoTags = memo<ModelInfoTagsProps>(
79
79
  return (
80
80
  <Flexbox direction={directionReverse ? 'horizontal-reverse' : 'horizontal'} gap={4}>
81
81
  {model.files && (
82
- <Tooltip
82
+ <Tooltip
83
83
  overlayStyle={{ pointerEvents: 'none' }}
84
- placement={placement}
85
- title={t('ModelSelect.featureTag.file')}
84
+ placement={placement}
85
+ title={t('ModelSelect.featureTag.file')}
86
86
  >
87
87
  <div className={cx(styles.tag, styles.tagGreen)} style={{ cursor: 'pointer' }} title="">
88
88
  <Icon icon={LucidePaperclip} />
@@ -90,9 +90,9 @@ export const ModelInfoTags = memo<ModelInfoTagsProps>(
90
90
  </Tooltip>
91
91
  )}
92
92
  {model.vision && (
93
- <Tooltip
93
+ <Tooltip
94
94
  overlayStyle={{ pointerEvents: 'none' }}
95
- placement={placement}
95
+ placement={placement}
96
96
  title={t('ModelSelect.featureTag.vision')}
97
97
  >
98
98
  <div className={cx(styles.tag, styles.tagGreen)} style={{ cursor: 'pointer' }} title="">
@@ -128,15 +128,6 @@ export const ModelInfoTags = memo<ModelInfoTagsProps>(
128
128
  </Center>
129
129
  </Tooltip>
130
130
  )}
131
- {/*{model.isCustom && (*/}
132
- {/* <Tooltip*/}
133
- {/* overlayStyle={{ maxWidth: 300 }}*/}
134
- {/* placement={placement}*/}
135
- {/* title={t('ModelSelect.featureTag.custom')}*/}
136
- {/* >*/}
137
- {/* <Center className={styles.custom}>DIY</Center>*/}
138
- {/* </Tooltip>*/}
139
- {/*)}*/}
140
131
  </Flexbox>
141
132
  );
142
133
  },
@@ -18,3 +18,4 @@ export const MOBILE_HEADER_ICON_SIZE = { blockSize: 36, fontSize: 22 };
18
18
  export const DESKTOP_HEADER_ICON_SIZE = { fontSize: 24 };
19
19
  export const HEADER_ICON_SIZE = (mobile?: boolean) =>
20
20
  mobile ? MOBILE_HEADER_ICON_SIZE : DESKTOP_HEADER_ICON_SIZE;
21
+ export const PWA_INSTALL_ID = 'pwa-install';
@@ -0,0 +1,22 @@
1
+ 'use client';
2
+
3
+ import dynamic from 'next/dynamic';
4
+ import { memo } from 'react';
5
+ import { useTranslation } from 'react-i18next';
6
+
7
+ import { PWA_INSTALL_ID } from '@/const/layoutTokens';
8
+ import { usePlatform } from '@/hooks/usePlatform';
9
+
10
+ // @ts-ignore
11
+ const PWA: any = dynamic(() => import('@khmyznikov/pwa-install/dist/pwa-install.react.js'), {
12
+ ssr: false,
13
+ });
14
+
15
+ const PWAInstall = memo(() => {
16
+ const { t } = useTranslation('metadata');
17
+ const { isPWA } = usePlatform();
18
+ if (isPWA) return null;
19
+ return <PWA description={t('chat.description')} id={PWA_INSTALL_ID} />;
20
+ });
21
+
22
+ export default PWAInstall;
@@ -22,7 +22,7 @@ const PanelContent = memo<{ closePopover: () => void }>(({ closePopover }) => {
22
22
  s.logout,
23
23
  s.openUserProfile,
24
24
  s.enableAuth(),
25
- s.enabledNextAuth(),
25
+ s.enabledNextAuth,
26
26
  ]);
27
27
  const { mainItems, logoutItems } = useMenu();
28
28
 
@@ -0,0 +1,78 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { pwaInstallHandler } from 'pwa-install-handler';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { PWA_INSTALL_ID } from '@/const/layoutTokens';
6
+
7
+ import { usePWAInstall } from './usePWAInstall';
8
+ import { usePlatform } from './usePlatform';
9
+
10
+ // Mocks
11
+ vi.mock('./usePlatform', () => ({
12
+ usePlatform: vi.fn(),
13
+ }));
14
+
15
+ vi.mock('@/utils/env', () => ({
16
+ isOnServerSide: false,
17
+ }));
18
+
19
+ vi.mock('pwa-install-handler', () => ({
20
+ pwaInstallHandler: {
21
+ addListener: vi.fn(),
22
+ removeListener: vi.fn(),
23
+ getEvent: vi.fn(),
24
+ },
25
+ }));
26
+
27
+ describe('usePWAInstall', () => {
28
+ afterEach(() => {
29
+ vi.clearAllMocks();
30
+ });
31
+
32
+ it('should return canInstall as false when in PWA', () => {
33
+ vi.mocked(usePlatform).mockReturnValue({ isSupportInstallPWA: true, isPWA: true } as any);
34
+
35
+ const { result } = renderHook(() => usePWAInstall());
36
+
37
+ expect(result.current.canInstall).toBe(false);
38
+ });
39
+
40
+ it('should return canInstall based on canInstall state when support PWA', () => {
41
+ vi.mocked(usePlatform).mockReturnValue({ isSupportInstallPWA: true, isPWA: false } as any);
42
+
43
+ const { result, rerender } = renderHook(() => usePWAInstall());
44
+
45
+ expect(result.current.canInstall).toBe(false);
46
+
47
+ act(() => {
48
+ vi.mocked(pwaInstallHandler.addListener).mock.calls[0][0](true);
49
+ });
50
+
51
+ rerender();
52
+
53
+ expect(result.current.canInstall).toBe(true);
54
+ });
55
+
56
+ it('should return canInstall as true when not support PWA', () => {
57
+ vi.mocked(usePlatform).mockReturnValue({ isSupportInstallPWA: false, isPWA: false } as any);
58
+
59
+ const { result } = renderHook(() => usePWAInstall());
60
+
61
+ expect(result.current.canInstall).toBe(true);
62
+ });
63
+
64
+ it('should call pwa.showDialog when install is called', () => {
65
+ const mockShowDialog = vi.fn();
66
+ document.body.innerHTML = `<div id="${PWA_INSTALL_ID}"></div>`;
67
+ const pwaElement: any = document.querySelector(`#${PWA_INSTALL_ID}`);
68
+ pwaElement.showDialog = mockShowDialog;
69
+
70
+ const { result } = renderHook(() => usePWAInstall());
71
+
72
+ act(() => {
73
+ result.current.install();
74
+ });
75
+
76
+ expect(mockShowDialog).toHaveBeenCalledWith(true);
77
+ });
78
+ });