@lobehub/chat 0.161.10 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
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
+
5
30
  ### [Version 0.161.10](https://github.com/lobehub/lobe-chat/compare/v0.161.9...v0.161.10)
6
31
 
7
32
  <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.10",
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 (
@@ -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 />}
@@ -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;
@@ -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
+ });
@@ -1,18 +1,39 @@
1
1
  import { pwaInstallHandler } from 'pwa-install-handler';
2
2
  import { useEffect, useState } from 'react';
3
3
 
4
+ import { PWA_INSTALL_ID } from '@/const/layoutTokens';
5
+ import { isOnServerSide } from '@/utils/env';
6
+
7
+ import { usePlatform } from './usePlatform';
8
+
4
9
  export const usePWAInstall = () => {
5
10
  const [canInstall, setCanInstall] = useState(false);
11
+ const { isSupportInstallPWA, isPWA } = usePlatform();
6
12
 
7
13
  useEffect(() => {
14
+ if (isOnServerSide) return;
8
15
  pwaInstallHandler.addListener(setCanInstall);
9
16
  return () => {
10
17
  pwaInstallHandler.removeListener(setCanInstall);
11
18
  };
12
19
  }, []);
13
20
 
21
+ const installCheck = () => {
22
+ // 当在 PWA 中时,不显示安装按钮
23
+ if (isPWA) return false;
24
+ // 其他情况下,根据是否可以安装来显示安装按钮 (如已经安装则不显示)
25
+ if (isSupportInstallPWA) return canInstall;
26
+ // 当在不支持 PWA 的环境中时,安装按钮 (此时为安装教程)
27
+ return true;
28
+ };
29
+
14
30
  return {
15
- canInstall,
16
- install: pwaInstallHandler.install,
31
+ canInstall: installCheck(),
32
+ install: () => {
33
+ const pwa: any = document.querySelector(`#${PWA_INSTALL_ID}`);
34
+ if (!pwa) return;
35
+ pwa.externalPromptEvent = pwaInstallHandler.getEvent();
36
+ pwa?.showDialog(true);
37
+ },
17
38
  };
18
39
  };
@@ -0,0 +1,82 @@
1
+ import { renderHook } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ import * as platformUtils from '@/utils/platform';
5
+
6
+ import { usePlatform } from './usePlatform';
7
+
8
+ // Mocks
9
+ vi.mock('@/utils/platform', () => ({
10
+ getBrowser: vi.fn(),
11
+ getPlatform: vi.fn(),
12
+ isInStandaloneMode: vi.fn(),
13
+ isSonomaOrLaterSafari: vi.fn(),
14
+ }));
15
+
16
+ describe('usePlatform', () => {
17
+ it('should return correct platform info for Mac OS and Chrome', () => {
18
+ vi.mocked(platformUtils.getPlatform).mockReturnValue('Mac OS');
19
+ vi.mocked(platformUtils.getBrowser).mockReturnValue('Chrome');
20
+ vi.mocked(platformUtils.isInStandaloneMode).mockReturnValue(false);
21
+ vi.mocked(platformUtils.isSonomaOrLaterSafari).mockReturnValue(false);
22
+
23
+ const { result } = renderHook(() => usePlatform());
24
+
25
+ expect(result.current).toEqual({
26
+ isApple: true,
27
+ isChrome: true,
28
+ isChromium: true,
29
+ isEdge: false,
30
+ isIOS: false,
31
+ isMacOS: true,
32
+ isPWA: false,
33
+ isSafari: false,
34
+ isSonomaOrLaterSafari: false,
35
+ isSupportInstallPWA: true,
36
+ });
37
+ });
38
+
39
+ it('should return correct platform info for iOS and Safari', () => {
40
+ vi.mocked(platformUtils.getPlatform).mockReturnValue('iOS');
41
+ vi.mocked(platformUtils.getBrowser).mockReturnValue('Safari');
42
+ vi.mocked(platformUtils.isInStandaloneMode).mockReturnValue(true);
43
+ vi.mocked(platformUtils.isSonomaOrLaterSafari).mockReturnValue(true);
44
+
45
+ const { result } = renderHook(() => usePlatform());
46
+
47
+ expect(result.current).toEqual({
48
+ isApple: true,
49
+ isChrome: false,
50
+ isChromium: false,
51
+ isEdge: false,
52
+ isIOS: true,
53
+ isMacOS: false,
54
+ isPWA: true,
55
+ isSafari: true,
56
+ isSonomaOrLaterSafari: true,
57
+ isSupportInstallPWA: false,
58
+ });
59
+ });
60
+
61
+ it('should return correct platform info for Windows and Edge', () => {
62
+ vi.mocked(platformUtils.getPlatform).mockReturnValue('Windows');
63
+ vi.mocked(platformUtils.getBrowser).mockReturnValue('Edge');
64
+ vi.mocked(platformUtils.isInStandaloneMode).mockReturnValue(false);
65
+ vi.mocked(platformUtils.isSonomaOrLaterSafari).mockReturnValue(false);
66
+
67
+ const { result } = renderHook(() => usePlatform());
68
+
69
+ expect(result.current).toEqual({
70
+ isApple: false,
71
+ isChrome: false,
72
+ isChromium: true,
73
+ isEdge: true,
74
+ isIOS: false,
75
+ isMacOS: false,
76
+ isPWA: false,
77
+ isSafari: false,
78
+ isSonomaOrLaterSafari: false,
79
+ isSupportInstallPWA: true,
80
+ });
81
+ });
82
+ });
@@ -1,15 +1,32 @@
1
1
  import { useRef } from 'react';
2
2
 
3
- import { getBrowser, getPlatform } from '@/utils/platform';
3
+ import {
4
+ getBrowser,
5
+ getPlatform,
6
+ isInStandaloneMode,
7
+ isSonomaOrLaterSafari,
8
+ } from '@/utils/platform';
4
9
 
5
10
  export const usePlatform = () => {
6
11
  const platform = useRef(getPlatform());
7
12
  const browser = useRef(getBrowser());
8
- return {
13
+
14
+ const platformInfo = {
9
15
  isApple: platform.current && ['Mac OS', 'iOS'].includes(platform.current),
10
16
  isChrome: browser.current === 'Chrome',
17
+ isChromium: browser.current && ['Chrome', 'Edge', 'Opera', 'Brave'].includes(browser.current),
18
+ isEdge: browser.current === 'Edge',
11
19
  isIOS: platform.current === 'iOS',
12
20
  isMacOS: platform.current === 'Mac OS',
21
+ isPWA: isInStandaloneMode(),
13
22
  isSafari: browser.current === 'Safari',
23
+ isSonomaOrLaterSafari: isSonomaOrLaterSafari(),
24
+ };
25
+
26
+ return {
27
+ ...platformInfo,
28
+ isSupportInstallPWA:
29
+ (platformInfo.isChromium && !platformInfo.isIOS) ||
30
+ (platformInfo.isMacOS && platformInfo.isSonomaOrLaterSafari),
14
31
  };
15
32
  };
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { isSonomaOrLaterSafari } from './platform';
4
+
5
+ describe('isSonomaOrLaterSafari', () => {
6
+ beforeEach(() => {
7
+ // 重置 navigator 对象
8
+ vi.stubGlobal('navigator', {
9
+ userAgent:
10
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15',
11
+ maxTouchPoints: 0,
12
+ });
13
+ });
14
+
15
+ it('should return false when userAgent is not Macintosh', () => {
16
+ vi.stubGlobal('navigator', { userAgent: 'Windows NT 10.0; Win64; x64' });
17
+ expect(isSonomaOrLaterSafari()).toBe(false);
18
+ });
19
+
20
+ it('should return false when navigator.maxTouchPoints > 0', () => {
21
+ Object.defineProperty(navigator, 'maxTouchPoints', { value: 1 });
22
+ expect(isSonomaOrLaterSafari()).toBe(false);
23
+ });
24
+
25
+ it('should return false when Safari version < 17', () => {
26
+ vi.stubGlobal('navigator', {
27
+ userAgent:
28
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15',
29
+ });
30
+ expect(isSonomaOrLaterSafari()).toBe(false);
31
+ });
32
+
33
+ it('should return false when audio codec check fails', () => {
34
+ vi.stubGlobal('navigator', {
35
+ userAgent:
36
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
37
+ });
38
+ vi.spyOn(document, 'createElement').mockReturnValueOnce({
39
+ canPlayType: vi.fn().mockReturnValue(''),
40
+ } as any);
41
+ vi.stubGlobal(
42
+ 'OffscreenCanvas',
43
+ class {
44
+ getContext = vi.fn().mockReturnValueOnce(null);
45
+ },
46
+ );
47
+ expect(isSonomaOrLaterSafari()).toBe(false);
48
+ });
49
+
50
+ it('should return false when WebGL check fails', () => {
51
+ vi.stubGlobal('navigator', {
52
+ userAgent:
53
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
54
+ });
55
+ vi.spyOn(document, 'createElement').mockReturnValueOnce({
56
+ canPlayType: vi.fn().mockReturnValue('maybe'),
57
+ } as any);
58
+ vi.stubGlobal(
59
+ 'OffscreenCanvas',
60
+ class {
61
+ getContext = vi.fn().mockReturnValueOnce(null);
62
+ },
63
+ );
64
+ expect(isSonomaOrLaterSafari()).toBe(false);
65
+ });
66
+
67
+ it('should return true when all checks pass', () => {
68
+ vi.stubGlobal('navigator', {
69
+ userAgent:
70
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
71
+ });
72
+ vi.spyOn(document, 'createElement').mockReturnValueOnce({
73
+ canPlayType: vi.fn().mockReturnValue('maybe'),
74
+ } as any);
75
+ vi.stubGlobal(
76
+ 'OffscreenCanvas',
77
+ class {
78
+ getContext = vi.fn().mockReturnValueOnce({});
79
+ },
80
+ );
81
+ expect(isSonomaOrLaterSafari()).toBe(true);
82
+ });
83
+ });
@@ -1,7 +1,9 @@
1
1
  import UAParser from 'ua-parser-js';
2
2
 
3
- const getParser = () => {
4
- if (typeof window === 'undefined') return new UAParser('Node');
3
+ import { isOnServerSide } from '@/utils/env';
4
+
5
+ export const getParser = () => {
6
+ if (isOnServerSide) return new UAParser('Node');
5
7
 
6
8
  let ua = navigator.userAgent;
7
9
  return new UAParser(ua);
@@ -22,3 +24,32 @@ export const browserInfo = {
22
24
  };
23
25
 
24
26
  export const isMacOS = () => getPlatform() === 'Mac OS';
27
+
28
+ export const isInStandaloneMode = () => {
29
+ if (isOnServerSide) return false;
30
+ return (
31
+ window.matchMedia('(display-mode: standalone)').matches ||
32
+ ('standalone' in navigator && (navigator as any).standalone === true)
33
+ );
34
+ };
35
+
36
+ export const isSonomaOrLaterSafari = () => {
37
+ if (isOnServerSide) return false;
38
+
39
+ // refs: https://github.com/khmyznikov/pwa-install/blob/0904788b9d0e34399846f6cb7dbb5efeabb62c20/src/utils.ts#L24
40
+ const userAgent = navigator.userAgent.toLowerCase();
41
+ if (navigator.maxTouchPoints || !/macintosh/.test(userAgent)) return false;
42
+
43
+ // check safari version >= 17
44
+ const version = /version\/(\d{2})\./.exec(userAgent);
45
+ if (!version || !version[1] || !(parseInt(version[1]) >= 17)) return false;
46
+
47
+ try {
48
+ // hacky way to detect Sonoma
49
+ const audioCheck = document.createElement('audio').canPlayType('audio/wav; codecs="1"');
50
+ const webGLCheck = new OffscreenCanvas(1, 1).getContext('webgl');
51
+ return Boolean(audioCheck) && Boolean(webGLCheck);
52
+ } catch {
53
+ return false;
54
+ }
55
+ };
@@ -1,13 +0,0 @@
1
- import { useEffect, useState } from 'react';
2
-
3
- import { isInStandaloneMode } from '@/utils/matchMedia';
4
-
5
- export const useIsPWA = () => {
6
- const [isPWA, setIsPWA] = useState(false);
7
-
8
- useEffect(() => {
9
- setIsPWA(isInStandaloneMode());
10
- }, []);
11
-
12
- return isPWA;
13
- };
@@ -1,10 +0,0 @@
1
- /**
2
- * check standalone mode in browser
3
- */
4
- export const isInStandaloneMode = () => {
5
- if (typeof window === 'undefined') {
6
- return false;
7
- }
8
-
9
- return window.matchMedia('(display-mode: standalone)').matches;
10
- };