@lobehub/chat 0.109.1 → 0.110.0

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.110.0](https://github.com/lobehub/lobe-chat/compare/v0.109.1...v0.110.0)
6
+
7
+ <sup>Released on **2023-12-07**</sup>
8
+
9
+ #### ✨ Features
10
+
11
+ - **misc**: Local TTS Player.
12
+
13
+ <br/>
14
+
15
+ <details>
16
+ <summary><kbd>Improvements and Fixes</kbd></summary>
17
+
18
+ #### What's improved
19
+
20
+ - **misc**: Local TTS Player, closes [#587](https://github.com/lobehub/lobe-chat/issues/587) ([87b51bd](https://github.com/lobehub/lobe-chat/commit/87b51bd))
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.109.1](https://github.com/lobehub/lobe-chat/compare/v0.109.0...v0.109.1)
6
31
 
7
32
  <sup>Released on **2023-12-07**</sup>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/chat",
3
- "version": "0.109.1",
3
+ "version": "0.110.0",
4
4
  "description": "An open-source, extensible (Function Calling), high-performance chatbot framework. It supports one-click free deployment of your private ChatGPT/LLM web application",
5
5
  "keywords": [
6
6
  "chatbot",
@@ -106,12 +106,12 @@
106
106
  "react-intersection-observer": "^9",
107
107
  "react-layout-kit": "^1",
108
108
  "react-lazy-load": "^4",
109
- "react-wrap-balancer": "^1.1.0",
109
+ "react-wrap-balancer": "^1",
110
110
  "remark": "^14",
111
111
  "remark-gfm": "^3",
112
112
  "remark-html": "^15",
113
113
  "semver": "^7",
114
- "sharp": "^0.33.0",
114
+ "sharp": "^0.33",
115
115
  "swr": "^2",
116
116
  "systemjs": "^6",
117
117
  "ts-md5": "^1",
@@ -126,11 +126,11 @@
126
126
  },
127
127
  "devDependencies": {
128
128
  "@commitlint/cli": "^18",
129
- "@ducanh2912/next-pwa": "^9.7.2",
129
+ "@ducanh2912/next-pwa": "^9",
130
130
  "@lobehub/i18n-cli": "latest",
131
131
  "@lobehub/lint": "latest",
132
- "@next/bundle-analyzer": "^14.0.3",
133
- "@next/eslint-plugin-next": "^14.0.3",
132
+ "@next/bundle-analyzer": "^14",
133
+ "@next/eslint-plugin-next": "^14",
134
134
  "@testing-library/jest-dom": "^6",
135
135
  "@testing-library/react": "^14",
136
136
  "@types/chroma-js": "^2",
@@ -0,0 +1,25 @@
1
+ import { useAudioPlayer } from '@lobehub/tts/react';
2
+ import { memo, useCallback } from 'react';
3
+
4
+ import { useChatStore } from '@/store/chat';
5
+ import { useFileStore } from '@/store/file';
6
+
7
+ import { TTSProps } from './InitPlayer';
8
+ import Player from './Player';
9
+
10
+ const FilePlayer = memo<TTSProps>(({ file, id }) => {
11
+ const useFetchTTSFile = useFileStore((s) => s.useFetchTTSFile);
12
+ const [clearTTS] = useChatStore((s) => [s.clearTTS]);
13
+ const { data, isLoading: isFileLoading } = useFetchTTSFile(file || null);
14
+ const { isLoading, ...audio } = useAudioPlayer({ src: data ? data.url : '' });
15
+
16
+ const handleDelete = useCallback(() => {
17
+ clearTTS(id);
18
+ }, [id]);
19
+
20
+ if (!audio || isFileLoading) return;
21
+
22
+ return <Player audio={audio} isLoading={isLoading} onDelete={handleDelete} />;
23
+ });
24
+
25
+ export default FilePlayer;
@@ -0,0 +1,93 @@
1
+ import { memo, useCallback, useEffect, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+
4
+ import { useTTS } from '@/hooks/useTTS';
5
+ import { useChatStore } from '@/store/chat';
6
+ import { useFileStore } from '@/store/file';
7
+ import { ChatMessageError, ChatTTS } from '@/types/chatMessage';
8
+ import { getMessageError } from '@/utils/fetch';
9
+
10
+ import Player from './Player';
11
+
12
+ export interface TTSProps extends ChatTTS {
13
+ content: string;
14
+ id: string;
15
+ loading?: boolean;
16
+ }
17
+
18
+ const InitPlayer = memo<TTSProps>(({ id, content, contentMd5, file }) => {
19
+ const [isStart, setIsStart] = useState(false);
20
+ const [error, setError] = useState<ChatMessageError>();
21
+ const uploadTTS = useFileStore((s) => s.uploadTTSByArrayBuffers);
22
+ const { t } = useTranslation('chat');
23
+
24
+ const [ttsMessage, clearTTS] = useChatStore((s) => [s.ttsMessage, s.clearTTS]);
25
+
26
+ const setDefaultError = useCallback(
27
+ (err?: any) => {
28
+ setError({ body: err, message: t('tts.responseError', { ns: 'error' }), type: 500 });
29
+ },
30
+ [t],
31
+ );
32
+
33
+ const { isGlobalLoading, audio, start, stop, response } = useTTS(content, {
34
+ onError: (err) => {
35
+ stop();
36
+ setDefaultError(err);
37
+ },
38
+ onErrorRetry: (err) => {
39
+ stop();
40
+ setDefaultError(err);
41
+ },
42
+ onSuccess: async () => {
43
+ if (!response || response.ok) return;
44
+ const message = await getMessageError(response);
45
+ if (message) {
46
+ setError(message);
47
+ } else {
48
+ setDefaultError();
49
+ }
50
+ stop();
51
+ },
52
+ onUpload: async (currentVoice, arrayBuffers) => {
53
+ const fileID = await uploadTTS(id, arrayBuffers);
54
+ ttsMessage(id, { contentMd5, file: fileID, voice: currentVoice });
55
+ },
56
+ });
57
+
58
+ const handleInitStart = useCallback(() => {
59
+ if (isStart) return;
60
+ start();
61
+ setIsStart(true);
62
+ }, [isStart, start]);
63
+
64
+ const handleDelete = useCallback(() => {
65
+ stop();
66
+ clearTTS(id);
67
+ }, [stop, id]);
68
+
69
+ const handleRetry = useCallback(() => {
70
+ setError(undefined);
71
+ start();
72
+ }, [start]);
73
+
74
+ useEffect(() => {
75
+ if (file) return;
76
+ setTimeout(() => {
77
+ handleInitStart();
78
+ }, 100);
79
+ }, [file]);
80
+
81
+ return (
82
+ <Player
83
+ audio={audio}
84
+ error={error}
85
+ isLoading={isGlobalLoading}
86
+ onDelete={handleDelete}
87
+ onInitPlay={handleInitStart}
88
+ onRetry={handleRetry}
89
+ />
90
+ );
91
+ });
92
+
93
+ export default InitPlayer;
@@ -0,0 +1,61 @@
1
+ import { AudioPlayer, AudioPlayerProps } from '@lobehub/tts/react';
2
+ import { ActionIcon, Alert, Highlighter } from '@lobehub/ui';
3
+ import { Button } from 'antd';
4
+ import { TrashIcon } from 'lucide-react';
5
+ import { memo } from 'react';
6
+ import { useTranslation } from 'react-i18next';
7
+ import { Flexbox } from 'react-layout-kit';
8
+
9
+ import { ChatMessageError } from '@/types/chatMessage';
10
+
11
+ interface PlayerProps extends AudioPlayerProps {
12
+ error?: ChatMessageError;
13
+ onDelete: () => void;
14
+ onRetry?: () => void;
15
+ }
16
+
17
+ const Player = memo<PlayerProps>(({ onRetry, error, onDelete, audio, isLoading, onInitPlay }) => {
18
+ const { t } = useTranslation('chat');
19
+
20
+ return (
21
+ <Flexbox align={'center'} horizontal style={{ minWidth: 160, width: '100%' }}>
22
+ {error ? (
23
+ <Alert
24
+ action={
25
+ <Button onClick={onRetry} size={'small'} type={'primary'}>
26
+ {t('retry', { ns: 'common' })}
27
+ </Button>
28
+ }
29
+ closable
30
+ extra={
31
+ error.body && (
32
+ <Highlighter copyButtonSize={'small'} language={'json'} type={'pure'}>
33
+ {JSON.stringify(error.body, null, 2)}
34
+ </Highlighter>
35
+ )
36
+ }
37
+ message={error.message}
38
+ onClose={onDelete}
39
+ style={{ alignItems: 'center', width: '100%' }}
40
+ type="error"
41
+ />
42
+ ) : (
43
+ <>
44
+ <AudioPlayer
45
+ allowPause={false}
46
+ audio={audio}
47
+ buttonSize={'small'}
48
+ isLoading={isLoading}
49
+ onInitPlay={onInitPlay}
50
+ onLoadingStop={stop}
51
+ timeRender={'tag'}
52
+ timeStyle={{ margin: 0 }}
53
+ />
54
+ <ActionIcon icon={TrashIcon} onClick={onDelete} size={'small'} title={t('tts.clear')} />
55
+ </>
56
+ )}
57
+ </Flexbox>
58
+ );
59
+ });
60
+
61
+ export default Player;
@@ -0,0 +1,31 @@
1
+ import { memo, useMemo } from 'react';
2
+ import { Md5 } from 'ts-md5';
3
+
4
+ import { settingsSelectors, useGlobalStore } from '@/store/global';
5
+ import { useSessionStore } from '@/store/session';
6
+ import { agentSelectors } from '@/store/session/slices/agent';
7
+
8
+ import FilePlayer from './FilePlayer';
9
+ import InitPlayer, { TTSProps } from './InitPlayer';
10
+
11
+ const TTS = memo<TTSProps>(
12
+ (props) => {
13
+ const { file, voice, content, contentMd5 } = props;
14
+ const lang = useGlobalStore(settingsSelectors.currentLanguage);
15
+ const currentVoice = useSessionStore(agentSelectors.currentAgentTTSVoice(lang));
16
+
17
+ const md5 = useMemo(() => Md5.hashStr(content).toString(), [content]);
18
+
19
+ const isContentEqual = contentMd5 === md5;
20
+ const isVoiceEqual = currentVoice === voice;
21
+ const isEqual = isVoiceEqual && isContentEqual;
22
+
23
+ const PlayerRender = file && isEqual ? FilePlayer : InitPlayer;
24
+
25
+ return <PlayerRender {...props} contentMd5={md5} />;
26
+ },
27
+ (prevProps, nextProps) => {
28
+ return prevProps.id === nextProps.id && prevProps.content === nextProps.content;
29
+ },
30
+ );
31
+ export default TTS;
@@ -1,14 +1,13 @@
1
- import { VoiceList } from '@lobehub/tts';
2
1
  import {
3
2
  EdgeSpeechOptions,
4
3
  MicrosoftSpeechOptions,
5
4
  OpenAITTSOptions,
5
+ TTSOptions,
6
6
  useEdgeSpeech,
7
7
  useMicrosoftSpeech,
8
8
  useOpenAITTS,
9
9
  } from '@lobehub/tts/react';
10
10
  import isEqual from 'fast-deep-equal';
11
- import { SWRConfiguration } from 'swr';
12
11
 
13
12
  import { createHeaderWithOpenAI } from '@/services/_header';
14
13
  import { OPENAI_URLS, TTS_URL } from '@/services/_url';
@@ -17,7 +16,8 @@ import { useSessionStore } from '@/store/session';
17
16
  import { agentSelectors } from '@/store/session/selectors';
18
17
  import { TTSServer } from '@/types/agent';
19
18
 
20
- interface TTSConfig extends SWRConfiguration {
19
+ interface TTSConfig extends TTSOptions {
20
+ onUpload?: (currentVoice: string, arraybuffers: ArrayBuffer[]) => void;
21
21
  server?: TTSServer;
22
22
  voice?: string;
23
23
  }
@@ -25,8 +25,8 @@ interface TTSConfig extends SWRConfiguration {
25
25
  export const useTTS = (content: string, config?: TTSConfig) => {
26
26
  const ttsSettings = useGlobalStore(settingsSelectors.currentTTS, isEqual);
27
27
  const ttsAgentSettings = useSessionStore(agentSelectors.currentAgentTTS, isEqual);
28
- const voiceList = useGlobalStore((s) => new VoiceList(settingsSelectors.currentLanguage(s)));
29
-
28
+ const lang = useGlobalStore(settingsSelectors.currentLanguage);
29
+ const voice = useSessionStore(agentSelectors.currentAgentTTSVoice(lang));
30
30
  let useSelectedTTS;
31
31
  let options: any = {};
32
32
  switch (config?.server || ttsAgentSettings.ttsService) {
@@ -39,10 +39,7 @@ export const useTTS = (content: string, config?: TTSConfig) => {
39
39
  },
40
40
  options: {
41
41
  model: ttsSettings.openAI.ttsModel,
42
- voice:
43
- config?.voice ||
44
- ttsAgentSettings.voice.openai ||
45
- VoiceList.openaiVoiceOptions?.[0].value,
42
+ voice: config?.voice || voice,
46
43
  },
47
44
  } as OpenAITTSOptions;
48
45
  break;
@@ -57,8 +54,7 @@ export const useTTS = (content: string, config?: TTSConfig) => {
57
54
  */
58
55
  },
59
56
  options: {
60
- voice:
61
- config?.voice || ttsAgentSettings.voice.edge || voiceList.edgeVoiceOptions?.[0].value,
57
+ voice: config?.voice || voice,
62
58
  },
63
59
  } as EdgeSpeechOptions;
64
60
  break;
@@ -70,10 +66,7 @@ export const useTTS = (content: string, config?: TTSConfig) => {
70
66
  serviceUrl: TTS_URL.microsoft,
71
67
  },
72
68
  options: {
73
- voice:
74
- config?.voice ||
75
- ttsAgentSettings.voice.microsoft ||
76
- voiceList.microsoftVoiceOptions?.[0].value,
69
+ voice: config?.voice || voice,
77
70
  },
78
71
  } as MicrosoftSpeechOptions;
79
72
  break;
@@ -83,5 +76,8 @@ export const useTTS = (content: string, config?: TTSConfig) => {
83
76
  return useSelectedTTS(content, {
84
77
  ...config,
85
78
  ...options,
79
+ onFinish: (arraybuffers) => {
80
+ config?.onUpload?.(options.voice || 'alloy', arraybuffers);
81
+ },
86
82
  });
87
83
  };
@@ -0,0 +1,139 @@
1
+ import { act, renderHook, waitFor } from '@testing-library/react';
2
+ import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { chainLangDetect } from '@/chains/langDetect';
5
+ import { chainTranslate } from '@/chains/translate';
6
+ import { chatService } from '@/services/chat';
7
+ import { messageService } from '@/services/message';
8
+
9
+ import { useChatStore } from '../store';
10
+
11
+ // Mock messageService 和 chatService
12
+ vi.mock('@/services/message', () => ({
13
+ messageService: {
14
+ updateMessageTTS: vi.fn(),
15
+ updateMessageTranslate: vi.fn(),
16
+ },
17
+ }));
18
+
19
+ vi.mock('@/services/chat', () => ({
20
+ chatService: {
21
+ fetchPresetTaskResult: vi.fn(),
22
+ },
23
+ }));
24
+
25
+ vi.mock('@/chains/langDetect', () => ({
26
+ chainLangDetect: vi.fn(),
27
+ }));
28
+
29
+ vi.mock('@/chains/translate', () => ({
30
+ chainTranslate: vi.fn(),
31
+ }));
32
+
33
+ // Mock supportLocales
34
+ vi.mock('@/locales/options', () => ({
35
+ supportLocales: ['en-US', 'zh-CN'],
36
+ }));
37
+
38
+ beforeEach(() => {
39
+ vi.clearAllMocks();
40
+ useChatStore.setState(
41
+ {
42
+ // ... 初始状态
43
+ },
44
+ false,
45
+ );
46
+ });
47
+
48
+ afterEach(() => {
49
+ vi.restoreAllMocks();
50
+ });
51
+
52
+ describe('ChatEnhanceAction', () => {
53
+ describe('clearTTS', () => {
54
+ it('should clear TTS for a message and refresh messages', async () => {
55
+ const { result } = renderHook(() => useChatStore());
56
+ const messageId = 'message-id';
57
+
58
+ await act(async () => {
59
+ await result.current.clearTTS(messageId);
60
+ });
61
+
62
+ expect(messageService.updateMessageTTS).toHaveBeenCalledWith(messageId, null);
63
+ });
64
+ });
65
+
66
+ describe('translateMessage', () => {
67
+ it('should translate a message to the target language and refresh messages', async () => {
68
+ const { result } = renderHook(() => useChatStore());
69
+ const messageId = 'message-id';
70
+ const targetLang = 'zh-CN';
71
+ const messageContent = 'Hello World';
72
+ const detectedLang = 'en-US';
73
+
74
+ // 设置初始消息状态
75
+ useChatStore.setState({
76
+ messages: [
77
+ {
78
+ id: messageId,
79
+ content: messageContent,
80
+ createdAt: Date.now(),
81
+ updatedAt: Date.now(),
82
+ role: 'user',
83
+ sessionId: 'test',
84
+ topicId: 'test',
85
+ meta: {},
86
+ },
87
+ ],
88
+ });
89
+
90
+ // 模拟语言检测和翻译结果
91
+ (chatService.fetchPresetTaskResult as Mock).mockImplementation(({ params }) => {
92
+ if (params === chainLangDetect(messageContent)) {
93
+ return Promise.resolve(detectedLang);
94
+ }
95
+ if (params === chainTranslate(messageContent, targetLang)) {
96
+ return Promise.resolve('Hola Mundo');
97
+ }
98
+ return Promise.resolve(undefined);
99
+ });
100
+
101
+ await act(async () => {
102
+ await result.current.translateMessage(messageId, targetLang);
103
+ });
104
+
105
+ expect(messageService.updateMessageTranslate).toHaveBeenCalled();
106
+ });
107
+ });
108
+
109
+ describe('clearTranslate', () => {
110
+ it('should clear translation for a message and refresh messages', async () => {
111
+ const { result } = renderHook(() => useChatStore());
112
+ const messageId = 'message-id';
113
+
114
+ await act(async () => {
115
+ await result.current.clearTranslate(messageId);
116
+ });
117
+
118
+ expect(messageService.updateMessageTranslate).toHaveBeenCalledWith(messageId, null);
119
+ });
120
+ });
121
+
122
+ describe('ttsMessage', () => {
123
+ it('should update TTS state for a message and refresh messages', async () => {
124
+ const { result } = renderHook(() => useChatStore());
125
+ const messageId = 'message-id';
126
+ const ttsState = {
127
+ contentMd5: 'some-md5',
128
+ file: 'path-to-tts-file',
129
+ voice: 'voice-type',
130
+ };
131
+
132
+ await act(async () => {
133
+ await result.current.ttsMessage(messageId, ttsState);
134
+ });
135
+
136
+ expect(messageService.updateMessageTTS).toHaveBeenCalledWith(messageId, ttsState);
137
+ });
138
+ });
139
+ });
@@ -20,7 +20,10 @@ export interface ChatEnhanceAction {
20
20
 
21
21
  clearTranslate: (id: string) => Promise<void>;
22
22
  translateMessage: (id: string, targetLang: string) => Promise<void>;
23
- ttsMessage: (id: string, init?: boolean) => Promise<void>;
23
+ ttsMessage: (
24
+ id: string,
25
+ state?: { contentMd5?: string; file?: string; voice?: string },
26
+ ) => Promise<void>;
24
27
  }
25
28
 
26
29
  export const chatEnhance: StateCreator<
@@ -88,8 +91,8 @@ export const chatEnhance: StateCreator<
88
91
  toggleChatLoading(false);
89
92
  },
90
93
 
91
- ttsMessage: async (id, init) => {
92
- await messageService.updateMessageTTS(id, { init: Boolean(init) });
94
+ ttsMessage: async (id, state = {}) => {
95
+ await messageService.updateMessageTTS(id, state);
93
96
  await get().refreshMessages();
94
97
  },
95
98
  });
@@ -1,34 +1,7 @@
1
- import { FilesStoreState } from './initialState';
2
-
3
- const getImageDetailByList = (list: string[]) => (s: FilesStoreState) =>
4
- list.map((i) => s.imagesMap[i]).filter(Boolean);
5
-
6
- const imageDetailList = (s: FilesStoreState) => getImageDetailByList(s.inputFilesList)(s);
7
-
8
- const getImageUrlOrBase64ById =
9
- (id: string) =>
10
- (s: FilesStoreState): { id: string; url: string } | undefined => {
11
- const preview = s.imagesMap[id];
12
-
13
- if (!preview) return undefined;
14
-
15
- const url = preview.saveMode === 'local' ? (preview.base64Url as string) : preview.url;
16
-
17
- return { id, url: url };
18
- };
19
-
20
- const getImageUrlOrBase64ByList = (idList: string[]) => (s: FilesStoreState) =>
21
- idList.map((i) => getImageUrlOrBase64ById(i)(s)).filter(Boolean) as {
22
- id: string;
23
- url: string;
24
- }[];
25
-
26
- const imageUrlOrBase64List = (s: FilesStoreState) => getImageUrlOrBase64ByList(s.inputFilesList)(s);
1
+ import { filesSelectors as imageFilesSelectors } from './slices/images';
2
+ import { ttsFilesSelectors } from './slices/tts';
27
3
 
28
4
  export const filesSelectors = {
29
- getImageDetailByList,
30
- getImageUrlOrBase64ById,
31
- getImageUrlOrBase64ByList,
32
- imageDetailList,
33
- imageUrlOrBase64List,
5
+ ...imageFilesSelectors,
6
+ ...ttsFilesSelectors,
34
7
  };
@@ -1,3 +1,3 @@
1
1
  export * from './action';
2
2
  export * from './initialState';
3
- // export * from './selectors';
3
+ export * from './selectors';
@@ -0,0 +1,34 @@
1
+ import { FilesStoreState } from '../../initialState';
2
+
3
+ const getImageDetailByList = (list: string[]) => (s: FilesStoreState) =>
4
+ list.map((i) => s.imagesMap[i]).filter(Boolean);
5
+
6
+ const imageDetailList = (s: FilesStoreState) => getImageDetailByList(s.inputFilesList)(s);
7
+
8
+ const getImageUrlOrBase64ById =
9
+ (id: string) =>
10
+ (s: FilesStoreState): { id: string; url: string } | undefined => {
11
+ const preview = s.imagesMap[id];
12
+
13
+ if (!preview) return undefined;
14
+
15
+ const url = preview.saveMode === 'local' ? (preview.base64Url as string) : preview.url;
16
+
17
+ return { id, url: url };
18
+ };
19
+
20
+ const getImageUrlOrBase64ByList = (idList: string[]) => (s: FilesStoreState) =>
21
+ idList.map((i) => getImageUrlOrBase64ById(i)(s)).filter(Boolean) as {
22
+ id: string;
23
+ url: string;
24
+ }[];
25
+
26
+ const imageUrlOrBase64List = (s: FilesStoreState) => getImageUrlOrBase64ByList(s.inputFilesList)(s);
27
+
28
+ export const filesSelectors = {
29
+ getImageDetailByList,
30
+ getImageUrlOrBase64ById,
31
+ getImageUrlOrBase64ByList,
32
+ imageDetailList,
33
+ imageUrlOrBase64List,
34
+ };
@@ -0,0 +1,147 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import useSWR from 'swr';
3
+ import { Mock, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { fileService } from '@/services/file';
6
+
7
+ import { useFileStore as useStore } from '../../store';
8
+
9
+ vi.mock('zustand/traditional');
10
+
11
+ // Mocks for fileService
12
+ vi.mock('@/services/file', () => ({
13
+ fileService: {
14
+ removeFile: vi.fn(),
15
+ uploadFile: vi.fn(),
16
+ getFile: vi.fn(),
17
+ },
18
+ }));
19
+
20
+ // Mock for useSWR
21
+ vi.mock('swr', () => ({
22
+ default: vi.fn(),
23
+ }));
24
+
25
+ // mock the arrayBuffer
26
+ beforeAll(() => {
27
+ Object.defineProperty(File.prototype, 'arrayBuffer', {
28
+ writable: true,
29
+ value: function () {
30
+ return new Promise((resolve) => {
31
+ const reader = new FileReader();
32
+ reader.onloadend = () => {
33
+ resolve(reader.result);
34
+ };
35
+ reader.readAsArrayBuffer(this);
36
+ });
37
+ },
38
+ });
39
+ });
40
+
41
+ beforeEach(() => {
42
+ // Reset all mocks before each test
43
+ vi.resetAllMocks();
44
+ });
45
+
46
+ describe('TTSFileAction', () => {
47
+ // Test for removeTTSFile
48
+ it('removeTTSFile should call fileService.removeFile', async () => {
49
+ const fileId = 'tts-file-id';
50
+
51
+ // Mock the fileService.removeFile to resolve
52
+ (fileService.removeFile as Mock).mockResolvedValue(undefined);
53
+
54
+ await act(async () => {
55
+ await useStore.getState().removeTTSFile(fileId);
56
+ });
57
+
58
+ expect(fileService.removeFile).toHaveBeenCalledWith(fileId);
59
+ });
60
+
61
+ // Test for uploadTTSFile
62
+ it('uploadTTSFile should upload the file and return the file id', async () => {
63
+ const testFile = new File(['content'], 'test.mp3', { type: 'audio/mp3' });
64
+ const uploadedFileData = {
65
+ id: 'new-tts-file-id',
66
+ createdAt: testFile.lastModified,
67
+ data: await testFile.arrayBuffer(),
68
+ fileType: testFile.type,
69
+ name: testFile.name,
70
+ saveMode: 'local',
71
+ size: testFile.size,
72
+ };
73
+
74
+ // Mock the fileService.uploadFile to resolve with uploadedFileData
75
+ (fileService.uploadFile as Mock).mockResolvedValue(uploadedFileData);
76
+
77
+ let fileId;
78
+ await act(async () => {
79
+ fileId = await useStore.getState().uploadTTSFile(testFile);
80
+ });
81
+
82
+ expect(fileService.uploadFile).toHaveBeenCalledWith({
83
+ createdAt: testFile.lastModified,
84
+ data: await testFile.arrayBuffer(),
85
+ fileType: testFile.type,
86
+ name: testFile.name,
87
+ saveMode: 'local',
88
+ size: testFile.size,
89
+ });
90
+ expect(fileId).toBe(uploadedFileData.id);
91
+ });
92
+
93
+ // Test for uploadTTSByArrayBuffers
94
+ it('uploadTTSByArrayBuffers should create a file and call uploadTTSFile', async () => {
95
+ const messageId = 'message-id';
96
+ const arrayBuffers = [new ArrayBuffer(10)];
97
+ const fileType = 'audio/mp3';
98
+ const fileName = `${messageId}.mp3`;
99
+
100
+ // Spy on uploadTTSFile to simulate a successful upload
101
+ const uploadTTSFileSpy = vi
102
+ .spyOn(useStore.getState(), 'uploadTTSFile')
103
+ .mockResolvedValue('new-tts-file-id');
104
+
105
+ let fileId;
106
+ await act(async () => {
107
+ fileId = await useStore.getState().uploadTTSByArrayBuffers(messageId, arrayBuffers);
108
+ });
109
+
110
+ expect(uploadTTSFileSpy).toHaveBeenCalled();
111
+ expect(fileId).toBe('new-tts-file-id');
112
+
113
+ // Cleanup spy
114
+ uploadTTSFileSpy.mockRestore();
115
+ });
116
+
117
+ // Test for useFetchTTSFile
118
+ it('useFetchTTSFile should call useSWR and return file data', async () => {
119
+ const fileId = 'tts-file-id';
120
+ const fileData = {
121
+ id: fileId,
122
+ name: 'test',
123
+ url: 'blob:test',
124
+ fileType: 'audio/mp3',
125
+ base64Url: '',
126
+ saveMode: 'local',
127
+ };
128
+
129
+ // Mock the fileService.getFile to resolve with fileData
130
+ (fileService.getFile as Mock).mockResolvedValue(fileData);
131
+
132
+ // Mock useSWR to call the fetcher function immediately
133
+ const useSWRMock = vi.mocked(useSWR);
134
+ useSWRMock.mockImplementation(((key: string, fetcher: any) => {
135
+ const data = fetcher(key);
136
+ return { data, error: undefined, isValidating: false, mutate: vi.fn() };
137
+ }) as any);
138
+
139
+ const { result } = renderHook(() => useStore.getState().useFetchTTSFile(fileId));
140
+
141
+ await act(async () => {
142
+ await result.current.data;
143
+ });
144
+
145
+ expect(fileService.getFile).toHaveBeenCalledWith(fileId);
146
+ });
147
+ });
@@ -0,0 +1,60 @@
1
+ import useSWR, { SWRResponse } from 'swr';
2
+ import { StateCreator } from 'zustand/vanilla';
3
+
4
+ import { fileService } from '@/services/file';
5
+ import { FilePreview } from '@/types/files';
6
+
7
+ import { FileStore } from '../../store';
8
+
9
+ export interface TTSFileAction {
10
+ removeTTSFile: (id: string) => Promise<void>;
11
+
12
+ uploadTTSByArrayBuffers: (
13
+ messageId: string,
14
+ arrayBuffers: ArrayBuffer[],
15
+ ) => Promise<string | undefined>;
16
+
17
+ uploadTTSFile: (file: File) => Promise<string | undefined>;
18
+
19
+ useFetchTTSFile: (id: string | null) => SWRResponse<FilePreview>;
20
+ }
21
+
22
+ export const createTTSFileSlice: StateCreator<
23
+ FileStore,
24
+ [['zustand/devtools', never]],
25
+ [],
26
+ TTSFileAction
27
+ > = (_, get) => ({
28
+ removeTTSFile: async (id) => {
29
+ await fileService.removeFile(id);
30
+ },
31
+ uploadTTSByArrayBuffers: async (messageId, arrayBuffers) => {
32
+ const fileType = 'audio/mp3';
33
+ const blob = new Blob(arrayBuffers, { type: fileType });
34
+ const fileName = `${messageId}.mp3`;
35
+ const fileOptions = {
36
+ lastModified: Date.now(),
37
+ type: fileType,
38
+ };
39
+ const file = new File([blob], fileName, fileOptions);
40
+ return get().uploadTTSFile(file);
41
+ },
42
+ uploadTTSFile: async (file) => {
43
+ try {
44
+ const data = await fileService.uploadFile({
45
+ createdAt: file.lastModified,
46
+ data: await file.arrayBuffer(),
47
+ fileType: file.type,
48
+ name: file.name,
49
+ saveMode: 'local',
50
+ size: file.size,
51
+ });
52
+
53
+ return data.id;
54
+ } catch (error) {
55
+ // 提示用户上传失败
56
+ console.error('upload error:', error);
57
+ }
58
+ },
59
+ useFetchTTSFile: (id) => useSWR(id, fileService.getFile),
60
+ });
@@ -0,0 +1,2 @@
1
+ export * from './action';
2
+ export * from './selectors';
@@ -0,0 +1,3 @@
1
+ // import { FileStore } from '../../store';
2
+
3
+ export const ttsFilesSelectors = {};
@@ -7,14 +7,16 @@ import { isDev } from '@/utils/env';
7
7
 
8
8
  import { FilesStoreState, initialState } from './initialState';
9
9
  import { FileAction, createFileSlice } from './slices/images';
10
+ import { TTSFileAction, createTTSFileSlice } from './slices/tts';
10
11
 
11
12
  // =============== 聚合 createStoreFn ============ //
12
13
 
13
- export type FileStore = FilesStoreState & FileAction;
14
+ export type FileStore = FilesStoreState & FileAction & TTSFileAction;
14
15
 
15
16
  const createStore: StateCreator<FileStore, [['zustand/devtools', never]]> = (...parameters) => ({
16
17
  ...initialState,
17
18
  ...createFileSlice(...parameters),
19
+ ...createTTSFileSlice(...parameters),
18
20
  });
19
21
 
20
22
  // =============== 实装 useStore ============ //
@@ -0,0 +1,228 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { DEFAULT_AVATAR } from '@/const/meta';
4
+ import { DEFAULT_AGENT_CONFIG, DEFAUTT_AGENT_TTS_CONFIG } from '@/const/settings';
5
+ import { SessionStore } from '@/store/session';
6
+ import { MetaData } from '@/types/meta';
7
+ import { LobeAgentSession, LobeSessionType } from '@/types/session';
8
+
9
+ import { agentSelectors } from '../agent';
10
+
11
+ vi.mock('i18next', () => ({
12
+ t: vi.fn((key) => key), // Simplified mock return value
13
+ }));
14
+
15
+ const mockSessionStore = {
16
+ activeId: '1',
17
+ sessions: [
18
+ {
19
+ id: '1',
20
+ config: DEFAULT_AGENT_CONFIG,
21
+ meta: {
22
+ title: 'title1',
23
+ description: 'description1',
24
+ },
25
+ type: LobeSessionType.Agent,
26
+ } as LobeAgentSession,
27
+ {
28
+ id: '2',
29
+ meta: {
30
+ title: 'title2',
31
+ description: 'description2',
32
+ },
33
+ config: DEFAULT_AGENT_CONFIG,
34
+ type: LobeSessionType.Agent,
35
+ } as LobeAgentSession,
36
+ ],
37
+ } as unknown as SessionStore;
38
+
39
+ describe('agentSelectors', () => {
40
+ describe('currentAgentConfig', () => {
41
+ it('should return the merged default and session-specific agent config', () => {
42
+ const config = agentSelectors.currentAgentConfig(mockSessionStore);
43
+ expect(config).toEqual(expect.objectContaining(mockSessionStore.sessions[0].config));
44
+ });
45
+ });
46
+
47
+ describe('currentAgentModel', () => {
48
+ it('should return the model from the agent config', () => {
49
+ const model = agentSelectors.currentAgentModel(mockSessionStore);
50
+ expect(model).toBe(mockSessionStore.sessions[0].config.model);
51
+ });
52
+ });
53
+
54
+ describe('currentAgentMeta', () => {
55
+ it('should return the merged default and session-specific meta data', () => {
56
+ const meta = agentSelectors.currentAgentMeta(mockSessionStore);
57
+ expect(meta).toEqual(expect.objectContaining(mockSessionStore.sessions[0].meta));
58
+ });
59
+
60
+ it('should return inbox defaults if it is an inbox session', () => {
61
+ // Assume sessionSelectors.isInboxSession() is mocked to return true for this test
62
+ const meta = agentSelectors.currentAgentMeta(mockSessionStore);
63
+ expect(meta.avatar).toBe(DEFAULT_AVATAR);
64
+ });
65
+ });
66
+
67
+ describe('currentAgentTitle', () => {
68
+ it('should return the title from the session meta data', () => {
69
+ const title = agentSelectors.currentAgentTitle(mockSessionStore);
70
+ expect(title).toBe(mockSessionStore.sessions[0].meta.title);
71
+ });
72
+ });
73
+
74
+ describe('currentAgentDescription', () => {
75
+ it('should return the description from the session meta data', () => {
76
+ const description = agentSelectors.currentAgentDescription(mockSessionStore);
77
+ expect(description).toBe(mockSessionStore.sessions[0].meta.description);
78
+ });
79
+ });
80
+
81
+ // ... More tests for other selectors
82
+
83
+ describe('hasSystemRole', () => {
84
+ it('should return true if the system role is defined in the agent config', () => {
85
+ const hasRole = agentSelectors.hasSystemRole(mockSessionStore);
86
+ expect(hasRole).toBe(false);
87
+ });
88
+
89
+ it('should return false if the system role is not defined in the agent config', () => {
90
+ const modifiedSessionStore = {
91
+ ...mockSessionStore,
92
+ sessions: [
93
+ {
94
+ ...mockSessionStore.sessions[0],
95
+ config: {
96
+ ...mockSessionStore.sessions[0].config,
97
+ systemRole: 'test',
98
+ },
99
+ },
100
+ ],
101
+ };
102
+ const hasRole = agentSelectors.hasSystemRole(modifiedSessionStore);
103
+ expect(hasRole).toBe(true);
104
+ });
105
+ });
106
+
107
+ describe('currentAgentTTS', () => {
108
+ it('should return the TTS config from the agent config', () => {
109
+ const ttsConfig = agentSelectors.currentAgentTTS(mockSessionStore);
110
+ expect(ttsConfig).toEqual(mockSessionStore.sessions[0].config.tts);
111
+ });
112
+
113
+ it('should return the default TTS config if none is defined in the agent config', () => {
114
+ const modifiedSessionStore = {
115
+ ...mockSessionStore,
116
+ sessions: [
117
+ {
118
+ ...mockSessionStore.sessions[0],
119
+ config: {
120
+ ...mockSessionStore.sessions[0].config,
121
+ tts: DEFAUTT_AGENT_TTS_CONFIG,
122
+ },
123
+ },
124
+ ],
125
+ };
126
+ const ttsConfig = agentSelectors.currentAgentTTS(modifiedSessionStore);
127
+ expect(ttsConfig).toEqual(DEFAUTT_AGENT_TTS_CONFIG);
128
+ });
129
+ });
130
+
131
+ describe('currentAgentTTSVoice', () => {
132
+ it('should return the appropriate TTS voice based on the service and language', () => {
133
+ const lang = 'en';
134
+ const ttsVoice = agentSelectors.currentAgentTTSVoice(lang)(mockSessionStore);
135
+ expect(ttsVoice).toBe(mockSessionStore.sessions[0].config.tts.voice.openai);
136
+ });
137
+ });
138
+
139
+ describe('getAvatar', () => {
140
+ it('should return the avatar from the meta data', () => {
141
+ const meta: MetaData = { avatar: 'custom-avatar.png' };
142
+ const avatar = agentSelectors.getAvatar(meta);
143
+ expect(avatar).toBe(meta.avatar);
144
+ });
145
+
146
+ it('should return the default avatar if none is defined in the meta data', () => {
147
+ const meta: MetaData = {};
148
+ const avatar = agentSelectors.getAvatar(meta);
149
+ expect(avatar).toBe(DEFAULT_AVATAR);
150
+ });
151
+ });
152
+
153
+ describe('getTitle', () => {
154
+ it('should return the title from the meta data', () => {
155
+ const meta: MetaData = { title: 'Custom Title' };
156
+ const title = agentSelectors.getTitle(meta);
157
+ expect(title).toBe(meta.title);
158
+ });
159
+
160
+ it('should return the default title if none is defined in the meta data', () => {
161
+ const meta: MetaData = {};
162
+ const title = agentSelectors.getTitle(meta);
163
+ expect(title).toBe('defaultSession'); // Assuming translation returns this key
164
+ });
165
+ });
166
+
167
+ describe('getDescription', () => {
168
+ it('should return the description from the meta data', () => {
169
+ const meta: MetaData = { description: 'Custom Description' };
170
+ const description = agentSelectors.getDescription(meta);
171
+ expect(description).toBe(meta.description);
172
+ });
173
+
174
+ it('should return the default description if none is defined in the meta data', () => {
175
+ const meta: MetaData = {};
176
+ const description = agentSelectors.getDescription(meta);
177
+ expect(description).toBe('noDescription'); // Assuming translation returns this key
178
+ });
179
+ });
180
+
181
+ describe('showTokenTag', () => {
182
+ it('should return true if the model is in the list of models that show tokens', () => {
183
+ const show = agentSelectors.showTokenTag(mockSessionStore);
184
+ expect(show).toBe(true);
185
+ });
186
+
187
+ it('should return false if the model is not in the list of models that show tokens', () => {
188
+ const modifiedSessionStore = {
189
+ ...mockSessionStore,
190
+ sessions: [
191
+ {
192
+ ...mockSessionStore.sessions[0],
193
+ config: {
194
+ ...mockSessionStore.sessions[0].config,
195
+ model: 'some-other-model',
196
+ },
197
+ },
198
+ ],
199
+ };
200
+ const show = agentSelectors.showTokenTag(modifiedSessionStore);
201
+ expect(show).toBe(false);
202
+ });
203
+ });
204
+
205
+ describe('modelHasVisionAbility', () => {
206
+ it('should return true if the model has vision ability', () => {
207
+ const hasAbility = agentSelectors.modelHasVisionAbility(mockSessionStore);
208
+ expect(hasAbility).toBe(false);
209
+ });
210
+
211
+ it('should return false if the model does not have vision ability', () => {
212
+ const modifiedSessionStore = {
213
+ ...mockSessionStore,
214
+ sessions: [
215
+ {
216
+ ...mockSessionStore.sessions[0],
217
+ config: {
218
+ ...mockSessionStore.sessions[0].config,
219
+ model: 'some-other-model',
220
+ },
221
+ },
222
+ ],
223
+ };
224
+ const hasAbility = agentSelectors.modelHasVisionAbility(modifiedSessionStore);
225
+ expect(hasAbility).toBe(false);
226
+ });
227
+ });
228
+ });
@@ -1,3 +1,4 @@
1
+ import { VoiceList } from '@lobehub/tts';
1
2
  import { t } from 'i18next';
2
3
 
3
4
  import { DEFAULT_OPENAI_MODEL_LIST, VISION_MODEL_WHITE_LIST } from '@/const/llm';
@@ -46,6 +47,29 @@ const currentAgentTTS = (s: SessionStore): LobeAgentTTSConfig => {
46
47
  return config?.tts || DEFAUTT_AGENT_TTS_CONFIG;
47
48
  };
48
49
 
50
+ const currentAgentTTSVoice =
51
+ (lang: string) =>
52
+ (s: SessionStore): string => {
53
+ const { voice, ttsService } = currentAgentTTS(s);
54
+ const voiceList = new VoiceList(lang);
55
+ let currentVoice;
56
+ switch (ttsService) {
57
+ case 'openai': {
58
+ currentVoice = voice.openai || (VoiceList.openaiVoiceOptions?.[0].value as string);
59
+ break;
60
+ }
61
+ case 'edge': {
62
+ currentVoice = voice.edge || (voiceList.edgeVoiceOptions?.[0].value as string);
63
+ break;
64
+ }
65
+ case 'microsoft': {
66
+ currentVoice = voice.microsoft || (voiceList.microsoftVoiceOptions?.[0].value as string);
67
+ break;
68
+ }
69
+ }
70
+ return currentVoice || 'alloy';
71
+ };
72
+
49
73
  // ========== Meta ============== //
50
74
  const currentAgentMeta = (s: SessionStore): MetaData => {
51
75
  const isInbox = sessionSelectors.isInboxSession(s);
@@ -97,6 +121,7 @@ export const agentSelectors = {
97
121
  currentAgentPlugins,
98
122
  currentAgentSystemRole,
99
123
  currentAgentTTS,
124
+ currentAgentTTSVoice,
100
125
  currentAgentTitle,
101
126
  getAvatar,
102
127
  getDescription,
@@ -18,7 +18,9 @@ export interface ChatTranslate extends Translate {
18
18
  }
19
19
 
20
20
  export interface ChatTTS {
21
- init?: boolean;
21
+ contentMd5?: string;
22
+ file?: string;
23
+ voice?: string;
22
24
  }
23
25
 
24
26
  export interface ChatPluginPayload {
package/vitest.config.ts CHANGED
@@ -2,6 +2,10 @@ import { resolve } from 'node:path';
2
2
  import { defineConfig } from 'vitest/config';
3
3
 
4
4
  export default defineConfig({
5
+ optimizeDeps: {
6
+ exclude: ['crypto', 'util', 'tty'],
7
+ include: ['@lobehub/tts'],
8
+ },
5
9
  test: {
6
10
  alias: {
7
11
  '@': resolve(__dirname, './src'),
@@ -1,122 +0,0 @@
1
- import { AudioPlayer } from '@lobehub/tts/react';
2
- import { ActionIcon, Alert, Highlighter } from '@lobehub/ui';
3
- import { Button } from 'antd';
4
- import { TrashIcon } from 'lucide-react';
5
- import { memo, useCallback, useEffect, useState } from 'react';
6
- import { useTranslation } from 'react-i18next';
7
- import { Flexbox } from 'react-layout-kit';
8
-
9
- import { useTTS } from '@/hooks/useTTS';
10
- import { useChatStore } from '@/store/chat';
11
- import { ChatMessageError, ChatTTS } from '@/types/chatMessage';
12
- import { getMessageError } from '@/utils/fetch';
13
-
14
- interface TTSProps extends ChatTTS {
15
- content: string;
16
- id: string;
17
- loading?: boolean;
18
- }
19
-
20
- const TTS = memo<TTSProps>(({ id, init, content }) => {
21
- const [isStart, setIsStart] = useState(false);
22
- const [error, setError] = useState<ChatMessageError>();
23
- const { t } = useTranslation('chat');
24
-
25
- const [ttsMessage, clearTTS] = useChatStore((s) => [s.ttsMessage, s.clearTTS]);
26
-
27
- const setDefaultError = useCallback(
28
- (err?: any) => {
29
- setError({ body: err, message: t('tts.responseError', { ns: 'error' }), type: 500 });
30
- },
31
- [t],
32
- );
33
-
34
- const { isGlobalLoading, audio, start, stop, response } = useTTS(content, {
35
- onError: (err) => {
36
- stop();
37
- setDefaultError(err);
38
- },
39
- onErrorRetry: (err) => {
40
- stop();
41
- setDefaultError(err);
42
- },
43
- onSuccess: async () => {
44
- if (!response) return;
45
- if (response.status === 200) return ttsMessage(id, true);
46
- const message = await getMessageError(response);
47
- if (message) {
48
- setError(message);
49
- } else {
50
- setDefaultError();
51
- }
52
- stop();
53
- },
54
- });
55
-
56
- const handleInitStart = useCallback(() => {
57
- if (isStart) return;
58
- start();
59
- setIsStart(true);
60
- }, [isStart]);
61
-
62
- const handleDelete = useCallback(() => {
63
- stop();
64
- clearTTS(id);
65
- }, [stop, id]);
66
-
67
- const handleRetry = useCallback(() => {
68
- setError(undefined);
69
- start();
70
- }, [start]);
71
-
72
- useEffect(() => {
73
- if (init) return;
74
- handleInitStart();
75
- }, [init]);
76
-
77
- return (
78
- <Flexbox align={'center'} horizontal style={{ minWidth: 160, width: '100%' }}>
79
- {error ? (
80
- <Alert
81
- action={
82
- <Button onClick={handleRetry} size={'small'} type={'primary'}>
83
- {t('retry', { ns: 'common' })}
84
- </Button>
85
- }
86
- closable
87
- extra={
88
- error.body && (
89
- <Highlighter copyButtonSize={'small'} language={'json'} type={'pure'}>
90
- {JSON.stringify(error.body, null, 2)}
91
- </Highlighter>
92
- )
93
- }
94
- message={error.message}
95
- onClose={handleDelete}
96
- style={{ alignItems: 'center', width: '100%' }}
97
- type="error"
98
- />
99
- ) : (
100
- <>
101
- <AudioPlayer
102
- audio={audio}
103
- buttonSize={'small'}
104
- isLoading={isGlobalLoading}
105
- onInitPlay={handleInitStart}
106
- onLoadingStop={stop}
107
- timeRender={'tag'}
108
- timeStyle={{ margin: 0 }}
109
- />
110
- <ActionIcon
111
- icon={TrashIcon}
112
- onClick={handleDelete}
113
- size={'small'}
114
- title={t('tts.clear')}
115
- />
116
- </>
117
- )}
118
- </Flexbox>
119
- );
120
- });
121
-
122
- export default TTS;