From b1f6c20c4130cdf30c4d1dffb756ed97e5a018d2 Mon Sep 17 00:00:00 2001 From: cy948 <67412196+cy948@users.noreply.github.com> Date: Tue, 30 Apr 2024 23:43:40 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20import=20settings=20from=20?= =?UTF-8?q?url=20(#2226)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: import settings from url * 🐛 fix: useEffect * :bug: fix: setSettings * :rewind: revet: delete useSTT.ts * :truck: refactor: rename `shareGPTService` to `shareService` --- src/const/url.ts | 1 + src/hooks/useImportConfig.ts | 21 ++++++- .../GlobalProvider/StoreInitialization.tsx | 11 +++- src/services/__tests__/share.test.ts | 55 +++++++++++++++++-- src/services/share.ts | 31 ++++++++++- src/store/chat/slices/share/action.test.ts | 20 +++---- src/store/chat/slices/share/action.ts | 4 +- 7 files changed, 121 insertions(+), 22 deletions(-) diff --git a/src/const/url.ts b/src/const/url.ts index d53db231ab0..5745f82ab9d 100644 --- a/src/const/url.ts +++ b/src/const/url.ts @@ -43,6 +43,7 @@ export const SESSION_CHAT_URL = (id: string = INBOX_SESSION_ID, mobile?: boolean export const imageUrl = (filename: string) => withBasePath(`/images/${filename}`); +export const LOBE_URL_IMPORT_NAME = 'settings'; export const EMAIL_SUPPORT = 'support@lobehub.com'; export const EMAIL_BUSINESS = 'hello@lobehub.com'; diff --git a/src/hooks/useImportConfig.ts b/src/hooks/useImportConfig.ts index 23de824210e..3a188ca2fde 100644 --- a/src/hooks/useImportConfig.ts +++ b/src/hooks/useImportConfig.ts @@ -1,13 +1,16 @@ import { useMemo } from 'react'; import { ImportResults, configService } from '@/services/config'; +import { shareService } from '@/services/share'; import { useChatStore } from '@/store/chat'; import { useSessionStore } from '@/store/session'; +import { useUserStore } from '@/store/user'; import { importConfigFile } from '@/utils/config'; export const useImportConfig = () => { const refreshSessions = useSessionStore((s) => s.refreshSessions); const [refreshMessages, refreshTopics] = useChatStore((s) => [s.refreshMessages, s.refreshTopic]); + const [setSettings] = useUserStore((s) => [s.setSettings]); const importConfig = async (file: File) => new Promise((resolve) => { @@ -22,5 +25,21 @@ export const useImportConfig = () => { }); }); - return useMemo(() => ({ importConfig }), []); + /** + * Import settings from a string in json format + * @param settingsParams + * @returns + */ + const importSettings = (settingsParams: string | null) => { + if (settingsParams) { + const importSettings = shareService.decodeShareSettings(settingsParams); + if (importSettings?.message || !importSettings?.data) { + // handle some error + return; + } + setSettings(importSettings.data); + } + }; + + return useMemo(() => ({ importConfig, importSettings }), []); }; diff --git a/src/layout/GlobalProvider/StoreInitialization.tsx b/src/layout/GlobalProvider/StoreInitialization.tsx index 87ea868d123..b84631abc1d 100644 --- a/src/layout/GlobalProvider/StoreInitialization.tsx +++ b/src/layout/GlobalProvider/StoreInitialization.tsx @@ -1,9 +1,11 @@ 'use client'; -import { useRouter } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; import { memo, useEffect } from 'react'; import { createStoreUpdater } from 'zustand-utils'; +import { LOBE_URL_IMPORT_NAME } from '@/const/url'; +import { useImportConfig } from '@/hooks/useImportConfig'; import { useIsMobile } from '@/hooks/useIsMobile'; import { useEnabledDataSync } from '@/hooks/useSyncData'; import { useAgentStore } from '@/store/agent'; @@ -38,6 +40,13 @@ const StoreInitialization = memo(() => { useStoreUpdater('isMobile', mobile); useStoreUpdater('router', router); + // Import settings from the url + const { importSettings } = useImportConfig(); + const searchParam = useSearchParams().get(LOBE_URL_IMPORT_NAME); + useEffect(() => { + importSettings(searchParam); + }, [searchParam]); + useEffect(() => { router.prefetch('/chat'); router.prefetch('/chat/settings'); diff --git a/src/services/__tests__/share.test.ts b/src/services/__tests__/share.test.ts index 1208254a6f0..de2192ab41f 100644 --- a/src/services/__tests__/share.test.ts +++ b/src/services/__tests__/share.test.ts @@ -1,9 +1,12 @@ +import { DeepPartial } from 'utility-types'; import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { LOBE_URL_IMPORT_NAME } from '@/const/url'; +import { GlobalSettings } from '@/types/settings'; import { ShareGPTConversation } from '@/types/share'; import { parseMarkdown } from '@/utils/parseMarkdown'; -import { SHARE_GPT_URL, shareGPTService } from '../share'; +import { SHARE_GPT_URL, shareService } from '../share'; // Mock dependencies vi.mock('@/utils/parseMarkdown', () => ({ @@ -32,7 +35,7 @@ describe('ShareGPTService', () => { }); // Act - const url = await shareGPTService.createShareGPTUrl(conversation); + const url = await shareService.createShareGPTUrl(conversation); // Assert expect(parseMarkdown).toHaveBeenCalledWith('Hi there!'); @@ -48,7 +51,7 @@ describe('ShareGPTService', () => { (fetch as Mock).mockRejectedValue(new Error('Network error')); // Act & Assert - await expect(shareGPTService.createShareGPTUrl(conversation)).rejects.toThrow('Network error'); + await expect(shareService.createShareGPTUrl(conversation)).rejects.toThrow('Network error'); }); it('should not parse markdown for items not from gpt', async () => { @@ -65,7 +68,7 @@ describe('ShareGPTService', () => { }); // Act - await shareGPTService.createShareGPTUrl(conversation); + await shareService.createShareGPTUrl(conversation); // Assert expect(parseMarkdown).not.toHaveBeenCalled(); @@ -81,6 +84,48 @@ describe('ShareGPTService', () => { }); // Act & Assert - await expect(shareGPTService.createShareGPTUrl(conversation)).rejects.toThrow(); + await expect(shareService.createShareGPTUrl(conversation)).rejects.toThrow(); + }); +}); + +describe('ShareViaUrl', () => { + describe('createShareSettingsUrl', () => { + it('should create a share settings URL with the provided settings', () => { + const settings: DeepPartial = { + languageModel: { + openai: { + apiKey: 'user-key', + }, + }, + }; + const url = shareService.createShareSettingsUrl(settings); + expect(url).toBe( + `/?${LOBE_URL_IMPORT_NAME}=%7B%22languageModel%22:%7B%22openai%22:%7B%22apiKey%22:%22user-key%22%7D%7D%7D`, + ); + }); + }); + + describe('decodeShareSettings', () => { + it('should decode share settings from search params', () => { + const settings = '{"languageModel":{"openai":{"apiKey":"user-key"}}}'; + const decodedSettings = shareService.decodeShareSettings(settings); + expect(decodedSettings).toEqual({ + data: { + languageModel: { + openai: { + apiKey: 'user-key', + }, + }, + }, + }); + }); + + it('should return an error message if decoding fails', () => { + const settings = '%7B%22theme%22%3A%22dark%22%2C%22fontSize%22%3A16%'; + const decodedSettings = shareService.decodeShareSettings(settings); + expect(decodedSettings).toEqual({ + message: expect.any(String), + }); + }); }); }); diff --git a/src/services/share.ts b/src/services/share.ts index 54394c530ef..25d8ba213ba 100644 --- a/src/services/share.ts +++ b/src/services/share.ts @@ -1,9 +1,14 @@ +import { DeepPartial } from 'utility-types'; + +import { LOBE_URL_IMPORT_NAME } from '@/const/url'; +import { GlobalSettings } from '@/types/settings'; import { ShareGPTConversation } from '@/types/share'; +import { withBasePath } from '@/utils/basePath'; import { parseMarkdown } from '@/utils/parseMarkdown'; export const SHARE_GPT_URL = 'https://sharegpt.com/api/conversations'; -class ShareGPTService { +class ShareService { public async createShareGPTUrl(conversation: ShareGPTConversation) { const items = []; @@ -29,6 +34,28 @@ class ShareGPTService { // short link to the ShareGPT post return `https://shareg.pt/${id}`; } + + /** + * Creates a share settings URL with the provided settings. + * @param settings - The settings object to be encoded in the URL. + * @returns The share settings URL. + */ + public createShareSettingsUrl(settings: DeepPartial) { + return withBasePath(`/?${LOBE_URL_IMPORT_NAME}=${encodeURI(JSON.stringify(settings))}`); + } + + /** + * Decode share settings from search params + * @param settings + * @returns + */ + public decodeShareSettings(settings: string) { + try { + return { data: JSON.parse(settings) as DeepPartial }; + } catch (e) { + return { message: JSON.stringify(e) }; + } + } } -export const shareGPTService = new ShareGPTService(); +export const shareService = new ShareService(); diff --git a/src/store/chat/slices/share/action.test.ts b/src/store/chat/slices/share/action.test.ts index 32573782ed1..6e1caa6bccf 100644 --- a/src/store/chat/slices/share/action.test.ts +++ b/src/store/chat/slices/share/action.test.ts @@ -1,18 +1,16 @@ import { act, renderHook } from '@testing-library/react'; import { DEFAULT_USER_AVATAR_URL } from '@/const/meta'; -import { shareGPTService } from '@/services/share'; +import { shareService } from '@/services/share'; import { useChatStore } from '@/store/chat'; import { ChatMessage } from '@/types/message'; describe('shareSlice actions', () => { - let shareGPTServiceSpy: any; + let shareServiceSpy: any; let windowOpenSpy; beforeEach(() => { - shareGPTServiceSpy = vi - .spyOn(shareGPTService, 'createShareGPTUrl') - .mockResolvedValue('test-url'); + shareServiceSpy = vi.spyOn(shareService, 'createShareGPTUrl').mockResolvedValue('test-url'); windowOpenSpy = vi.spyOn(window, 'open'); }); @@ -23,7 +21,7 @@ describe('shareSlice actions', () => { describe('shareToShareGPT', () => { it('should share to ShareGPT and open a new window', async () => { const { result } = renderHook(() => useChatStore()); - const shareGPTServiceSpy = vi.spyOn(shareGPTService, 'createShareGPTUrl'); + const shareServiceSpy = vi.spyOn(shareService, 'createShareGPTUrl'); const windowOpenSpy = vi.spyOn(window, 'open'); const avatar = 'avatar-url'; const withPluginInfo = true; @@ -33,7 +31,7 @@ describe('shareSlice actions', () => { await result.current.shareToShareGPT({ avatar, withPluginInfo, withSystemRole }); }); - expect(shareGPTServiceSpy).toHaveBeenCalled(); + expect(shareServiceSpy).toHaveBeenCalled(); expect(windowOpenSpy).toHaveBeenCalled(); }); it('should handle messages from different roles correctly', async () => { @@ -67,7 +65,7 @@ describe('shareSlice actions', () => { await result.current.shareToShareGPT({}); }); - expect(shareGPTServiceSpy).toHaveBeenCalledWith( + expect(shareServiceSpy).toHaveBeenCalledWith( expect.objectContaining({ avatarUrl: DEFAULT_USER_AVATAR_URL, }), @@ -106,7 +104,7 @@ describe('shareSlice actions', () => { await act(async () => { result.current.shareToShareGPT({ withPluginInfo: true }); }); - expect(shareGPTServiceSpy).toHaveBeenCalledWith( + expect(shareServiceSpy).toHaveBeenCalledWith( expect.objectContaining({ items: expect.arrayContaining([ expect.objectContaining({ @@ -139,7 +137,7 @@ describe('shareSlice actions', () => { await act(async () => { result.current.shareToShareGPT({ withPluginInfo: false }); }); - expect(shareGPTServiceSpy).toHaveBeenCalledWith( + expect(shareServiceSpy).toHaveBeenCalledWith( expect.objectContaining({ items: expect.not.arrayContaining([ expect.objectContaining({ @@ -180,7 +178,7 @@ describe('shareSlice actions', () => { }); }); - expect(shareGPTServiceSpy).toHaveBeenCalledWith( + expect(shareServiceSpy).toHaveBeenCalledWith( expect.objectContaining({ items: [ expect.objectContaining({ from: 'gpt' }), // Agent meta info diff --git a/src/store/chat/slices/share/action.ts b/src/store/chat/slices/share/action.ts index b5879852b40..5922bbaf20a 100644 --- a/src/store/chat/slices/share/action.ts +++ b/src/store/chat/slices/share/action.ts @@ -3,7 +3,7 @@ import { produce } from 'immer'; import { StateCreator } from 'zustand/vanilla'; import { DEFAULT_USER_AVATAR_URL } from '@/const/meta'; -import { shareGPTService } from '@/services/share'; +import { shareService } from '@/services/share'; import { useAgentStore } from '@/store/agent'; import { agentSelectors } from '@/store/agent/selectors'; import { useSessionStore } from '@/store/session'; @@ -104,7 +104,7 @@ export const chatShare: StateCreator