Skip to content

Commit

Permalink
✨ feat: import settings from url (lobehub#2226)
Browse files Browse the repository at this point in the history
* ✨ feat: import settings from url

* πŸ› fix: useEffect

* πŸ› fix: setSettings

* βͺ revet: delete useSTT.ts

* 🚚 refactor: rename `shareGPTService` to `shareService`
  • Loading branch information
cy948 committed Apr 30, 2024
1 parent 3c36a18 commit b1f6c20
Show file tree
Hide file tree
Showing 7 changed files with 121 additions and 22 deletions.
1 change: 1 addition & 0 deletions src/const/url.ts
Expand Up @@ -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';

Expand Down
21 changes: 20 additions & 1 deletion 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<ImportResults | undefined>((resolve) => {
Expand All @@ -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 }), []);
};
11 changes: 10 additions & 1 deletion 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';
Expand Down Expand Up @@ -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');
Expand Down
55 changes: 50 additions & 5 deletions 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', () => ({
Expand Down Expand Up @@ -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!');
Expand All @@ -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 () => {
Expand All @@ -65,7 +68,7 @@ describe('ShareGPTService', () => {
});

// Act
await shareGPTService.createShareGPTUrl(conversation);
await shareService.createShareGPTUrl(conversation);

// Assert
expect(parseMarkdown).not.toHaveBeenCalled();
Expand All @@ -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<GlobalSettings> = {
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),
});
});
});
});
31 changes: 29 additions & 2 deletions 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 = [];

Expand All @@ -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<GlobalSettings>) {
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<GlobalSettings> };
} catch (e) {
return { message: JSON.stringify(e) };
}
}
}

export const shareGPTService = new ShareGPTService();
export const shareService = new ShareService();
20 changes: 9 additions & 11 deletions 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');
});

Expand All @@ -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;
Expand All @@ -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 () => {
Expand Down Expand Up @@ -67,7 +65,7 @@ describe('shareSlice actions', () => {
await result.current.shareToShareGPT({});
});

expect(shareGPTServiceSpy).toHaveBeenCalledWith(
expect(shareServiceSpy).toHaveBeenCalledWith(
expect.objectContaining({
avatarUrl: DEFAULT_USER_AVATAR_URL,
}),
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -180,7 +178,7 @@ describe('shareSlice actions', () => {
});
});

expect(shareGPTServiceSpy).toHaveBeenCalledWith(
expect(shareServiceSpy).toHaveBeenCalledWith(
expect.objectContaining({
items: [
expect.objectContaining({ from: 'gpt' }), // Agent meta info
Expand Down
4 changes: 2 additions & 2 deletions src/store/chat/slices/share/action.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -104,7 +104,7 @@ export const chatShare: StateCreator<ChatStore, [['zustand/devtools', never]], [

set({ shareLoading: true });

const res = await shareGPTService.createShareGPTUrl({
const res = await shareService.createShareGPTUrl({
avatarUrl: avatar || DEFAULT_USER_AVATAR_URL,
items: shareMsgs,
});
Expand Down

0 comments on commit b1f6c20

Please sign in to comment.