@rimori/react-client 0.3.0-next.6 → 0.3.0-next.8

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.
@@ -26,6 +26,12 @@ export function AssistantChat({ avatarImageUrl, voiceId, onComplete, autoStartCo
26
26
  sender.handleNewText(autoStartConversation.assistantMessage, isLoading);
27
27
  }
28
28
  }, []);
29
+ // Cleanup: stop audio playback and clean up resources on unmount
30
+ useEffect(() => {
31
+ return () => {
32
+ sender.cleanup();
33
+ };
34
+ }, [sender]);
29
35
  useEffect(() => {
30
36
  var _a;
31
37
  let message = lastAssistantMessage;
@@ -38,6 +38,12 @@ export function Avatar({ avatarImageUrl, voiceId, agentTools, autoStartConversat
38
38
  append([{ role: 'user', content: autoStartConversation.userMessage, id: messages.length.toString() }]);
39
39
  }
40
40
  }, [autoStartConversation, voiceId]);
41
+ // Cleanup: stop audio playback and clean up resources on unmount
42
+ useEffect(() => {
43
+ return () => {
44
+ sender.cleanup();
45
+ };
46
+ }, [sender]);
41
47
  useEffect(() => {
42
48
  if ((lastMessage === null || lastMessage === void 0 ? void 0 : lastMessage.role) === 'assistant') {
43
49
  sender.handleNewText(lastMessage.content, isLoading);
@@ -11,6 +11,7 @@ export declare class MessageSender {
11
11
  private generateSpeech;
12
12
  play(): void;
13
13
  stop(): void;
14
+ cleanup(): void;
14
15
  private reset;
15
16
  setVolume(volume: number): void;
16
17
  setOnLoudnessChange(callback: (value: number) => void): void;
@@ -72,6 +72,9 @@ export class MessageSender {
72
72
  stop() {
73
73
  this.player.stopPlayback();
74
74
  }
75
+ cleanup() {
76
+ this.player.cleanup();
77
+ }
75
78
  reset() {
76
79
  this.stop();
77
80
  this.fetchedSentences.clear();
@@ -20,6 +20,7 @@ export declare class ChunkedAudioPlayer {
20
20
  addChunk(chunk: ArrayBuffer, position: number): Promise<void>;
21
21
  private playChunks;
22
22
  stopPlayback(): void;
23
+ cleanup(): void;
23
24
  private playChunk;
24
25
  playAgain(): Promise<void>;
25
26
  private monitorLoudness;
@@ -89,6 +89,16 @@ export class ChunkedAudioPlayer {
89
89
  this.shouldMonitorLoudness = false;
90
90
  cancelAnimationFrame(this.handle);
91
91
  }
92
+ cleanup() {
93
+ // Stop playback first
94
+ this.stopPlayback();
95
+ // Close AudioContext to free resources
96
+ if (this.audioContext && this.audioContext.state !== 'closed') {
97
+ this.audioContext.close().catch((e) => {
98
+ console.warn('Error closing AudioContext:', e);
99
+ });
100
+ }
101
+ }
92
102
  playChunk(chunk) {
93
103
  // console.log({queue: this.chunkQueue})
94
104
  if (!chunk) {
@@ -8,7 +8,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
8
8
  });
9
9
  };
10
10
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
11
- import { useState, useEffect } from 'react';
11
+ import { useState, useEffect, useRef } from 'react';
12
12
  import { FaPlayCircle, FaStopCircle } from 'react-icons/fa';
13
13
  import { useRimori } from '../../providers/PluginProvider';
14
14
  import { EventBus } from '@rimori/client';
@@ -20,6 +20,8 @@ export const AudioPlayer = ({ text, voice, language, hide, playListenerEvent, in
20
20
  const [isPlaying, setIsPlaying] = useState(false);
21
21
  const [isLoading, setIsLoading] = useState(false);
22
22
  const { ai } = useRimori();
23
+ const audioRef = useRef(null);
24
+ const eventBusListenerRef = useRef(null);
23
25
  useEffect(() => {
24
26
  if (audioUrl)
25
27
  setAudioUrl(null);
@@ -37,9 +39,22 @@ export const AudioPlayer = ({ text, voice, language, hide, playListenerEvent, in
37
39
  });
38
40
  // Effect to play audio when audioUrl changes and play state is true
39
41
  useEffect(() => {
40
- if (!audioUrl || !isPlaying)
42
+ if (!audioUrl || !isPlaying) {
43
+ // Stop any existing audio when not playing
44
+ if (audioRef.current) {
45
+ audioRef.current.pause();
46
+ audioRef.current.currentTime = 0;
47
+ audioRef.current = null;
48
+ }
41
49
  return;
50
+ }
51
+ // Clean up previous audio instance if it exists
52
+ if (audioRef.current) {
53
+ audioRef.current.pause();
54
+ audioRef.current.currentTime = 0;
55
+ }
42
56
  const audio = new Audio(audioUrl);
57
+ audioRef.current = audio;
43
58
  audio.playbackRate = speed;
44
59
  audio
45
60
  .play()
@@ -47,16 +62,41 @@ export const AudioPlayer = ({ text, voice, language, hide, playListenerEvent, in
47
62
  audio.onended = () => {
48
63
  setIsPlaying(false);
49
64
  isFetchingAudio = false;
65
+ audioRef.current = null;
50
66
  };
51
67
  })
52
68
  .catch((e) => {
53
69
  console.warn('Error playing audio:', e);
54
70
  setIsPlaying(false);
71
+ audioRef.current = null;
55
72
  });
56
73
  return () => {
57
- audio.pause();
74
+ if (audioRef.current) {
75
+ audioRef.current.pause();
76
+ audioRef.current.currentTime = 0;
77
+ audioRef.current = null;
78
+ }
58
79
  };
59
80
  }, [audioUrl, isPlaying, speed]);
81
+ // Cleanup on unmount - stop audio and revoke object URL
82
+ useEffect(() => {
83
+ return () => {
84
+ if (audioRef.current) {
85
+ audioRef.current.pause();
86
+ audioRef.current.currentTime = 0;
87
+ audioRef.current = null;
88
+ }
89
+ setIsPlaying(false);
90
+ };
91
+ }, []);
92
+ // Cleanup audioUrl on unmount
93
+ useEffect(() => {
94
+ return () => {
95
+ if (audioUrl) {
96
+ URL.revokeObjectURL(audioUrl);
97
+ }
98
+ };
99
+ }, [audioUrl]);
60
100
  const togglePlayback = () => {
61
101
  if (!isPlaying && !audioUrl) {
62
102
  generateAudio().then(() => setIsPlaying(true));
@@ -68,7 +108,15 @@ export const AudioPlayer = ({ text, voice, language, hide, playListenerEvent, in
68
108
  useEffect(() => {
69
109
  if (!playListenerEvent)
70
110
  return;
71
- EventBus.on(playListenerEvent, () => togglePlayback());
111
+ const handler = () => togglePlayback();
112
+ const listener = EventBus.on(playListenerEvent, handler);
113
+ eventBusListenerRef.current = listener;
114
+ return () => {
115
+ if (eventBusListenerRef.current) {
116
+ eventBusListenerRef.current.off();
117
+ eventBusListenerRef.current = null;
118
+ }
119
+ };
72
120
  }, [playListenerEvent]);
73
121
  useEffect(() => {
74
122
  if (!playOnMount || isFetchingAudio)
@@ -14,7 +14,7 @@ import ContextMenu from '../components/ContextMenu';
14
14
  import { useTheme } from '../hooks/ThemeSetter';
15
15
  const PluginContext = createContext(null);
16
16
  export const PluginProvider = ({ children, pluginId, settings }) => {
17
- const [plugin, setPlugin] = useState(null);
17
+ const [client, setClient] = useState(null);
18
18
  const [standaloneClient, setStandaloneClient] = useState(false);
19
19
  const [applicationMode, setApplicationMode] = useState(null);
20
20
  const [theme, setTheme] = useState(null);
@@ -30,9 +30,9 @@ export const PluginProvider = ({ children, pluginId, settings }) => {
30
30
  void client.needsLogin().then((needLogin) => setStandaloneClient(needLogin ? client : true));
31
31
  });
32
32
  }
33
- if ((!standaloneDetected && !plugin) || (standaloneDetected && standaloneClient === true)) {
33
+ if ((!standaloneDetected && !client) || (standaloneDetected && standaloneClient === true)) {
34
34
  void RimoriClient.getInstance(pluginId).then((client) => {
35
- setPlugin(client);
35
+ setClient(client);
36
36
  // Get applicationMode and theme from MessageChannel query params
37
37
  if (!standaloneDetected) {
38
38
  const mode = client.getQueryParam('applicationMode');
@@ -43,36 +43,30 @@ export const PluginProvider = ({ children, pluginId, settings }) => {
43
43
  }
44
44
  });
45
45
  }
46
- }, [pluginId, standaloneClient, plugin]);
47
- //route change
46
+ }, [pluginId, standaloneClient, client]);
48
47
  useEffect(() => {
49
- if (!plugin)
48
+ if (!client)
50
49
  return;
51
- //sidebar pages should not report url changes
52
50
  if (isSidebar)
53
- return;
54
- let lastHash = window.location.hash;
55
- const emitUrlChange = (url) => plugin.event.emit('session.triggerUrlChange', { url });
56
- const interval = setInterval(() => {
57
- if (lastHash === window.location.hash)
58
- return;
59
- lastHash = window.location.hash;
60
- // console.log('url changed:', lastHash);
61
- emitUrlChange(lastHash);
62
- }, 1000);
63
- emitUrlChange(lastHash);
64
- return () => clearInterval(interval);
65
- }, [plugin, isSidebar]);
51
+ return; //sidebar pages should not report url changes
52
+ // react router overwrites native pushstate so it gets wrapped to detect url changes
53
+ const originalPushState = history.pushState;
54
+ history.pushState = (...args) => {
55
+ const result = originalPushState.apply(history, args);
56
+ client.event.emit('session.triggerUrlChange', { url: location.hash });
57
+ return result;
58
+ };
59
+ }, [client, isSidebar]);
66
60
  if (standaloneClient instanceof StandaloneClient) {
67
61
  return (_jsx(StandaloneAuth, { onLogin: (email, password) => __awaiter(void 0, void 0, void 0, function* () {
68
62
  if (yield standaloneClient.login(email, password))
69
63
  setStandaloneClient(true);
70
64
  }) }));
71
65
  }
72
- if (!plugin) {
66
+ if (!client) {
73
67
  return '';
74
68
  }
75
- return (_jsxs(PluginContext.Provider, { value: plugin, children: [!(settings === null || settings === void 0 ? void 0 : settings.disableContextMenu) && !isSidebar && !isSettings && _jsx(ContextMenu, { client: plugin }), children] }));
69
+ return (_jsxs(PluginContext.Provider, { value: client, children: [!(settings === null || settings === void 0 ? void 0 : settings.disableContextMenu) && !isSidebar && !isSettings && _jsx(ContextMenu, { client: client }), children] }));
76
70
  };
77
71
  export const useRimori = () => {
78
72
  const context = useContext(PluginContext);
@@ -118,7 +112,7 @@ function StandaloneAuth({ onLogin }) {
118
112
  display: 'flex',
119
113
  alignItems: 'center',
120
114
  justifyContent: 'center',
121
- }, children: [_jsx("p", { style: { fontSize: '2rem', fontWeight: 'bold', marginBottom: '1rem', textAlign: 'center' }, children: "Rimori Login" }), _jsx("p", { style: { marginBottom: '1rem', textAlign: 'center' }, children: "Please login with your Rimori developer account for this plugin to be able to access the Rimori platform the same it will operate in the Rimori platform." }), _jsx("input", { style: {
115
+ }, children: [_jsx("p", { style: { fontSize: '2rem', fontWeight: 'bold', marginBottom: '1rem', textAlign: 'center' }, children: "Rimori Login Required" }), _jsx("p", { style: { marginBottom: '1rem', textAlign: 'center' }, children: "Please login with your Rimori developer account for this plugin to be able to access the Rimori platform the same way it would when being deployed." }), _jsx("input", { style: {
122
116
  marginBottom: '1rem',
123
117
  width: '100%',
124
118
  padding: '0.5rem',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rimori/react-client",
3
- "version": "0.3.0-next.6",
3
+ "version": "0.3.0-next.8",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -23,7 +23,7 @@
23
23
  "format": "prettier --write ."
24
24
  },
25
25
  "peerDependencies": {
26
- "@rimori/client": "2.2.0-next.3",
26
+ "@rimori/client": "2.3.0-next.3",
27
27
  "react": "^18.1.0",
28
28
  "react-dom": "^18.1.0"
29
29
  },
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "devDependencies": {
36
36
  "@eslint/js": "^9.37.0",
37
- "@rimori/client": "2.2.0-next.3",
37
+ "@rimori/client": "2.3.0-next.3",
38
38
  "@types/react": "^18.3.21",
39
39
  "eslint-config-prettier": "^10.1.8",
40
40
  "eslint-plugin-prettier": "^5.5.4",
@@ -38,6 +38,13 @@ export function AssistantChat({ avatarImageUrl, voiceId, onComplete, autoStartCo
38
38
  }
39
39
  }, []);
40
40
 
41
+ // Cleanup: stop audio playback and clean up resources on unmount
42
+ useEffect(() => {
43
+ return () => {
44
+ sender.cleanup();
45
+ };
46
+ }, [sender]);
47
+
41
48
  useEffect(() => {
42
49
  let message = lastAssistantMessage;
43
50
  if (message !== messages[messages.length - 1]?.content) {
@@ -61,6 +61,13 @@ export function Avatar({
61
61
  }
62
62
  }, [autoStartConversation, voiceId]);
63
63
 
64
+ // Cleanup: stop audio playback and clean up resources on unmount
65
+ useEffect(() => {
66
+ return () => {
67
+ sender.cleanup();
68
+ };
69
+ }, [sender]);
70
+
64
71
  useEffect(() => {
65
72
  if (lastMessage?.role === 'assistant') {
66
73
  sender.handleNewText(lastMessage.content, isLoading);
@@ -74,6 +74,10 @@ export class MessageSender {
74
74
  this.player.stopPlayback();
75
75
  }
76
76
 
77
+ public cleanup() {
78
+ this.player.cleanup();
79
+ }
80
+
77
81
  private reset() {
78
82
  this.stop();
79
83
  this.fetchedSentences.clear();
@@ -88,6 +88,17 @@ export class ChunkedAudioPlayer {
88
88
  cancelAnimationFrame(this.handle);
89
89
  }
90
90
 
91
+ public cleanup(): void {
92
+ // Stop playback first
93
+ this.stopPlayback();
94
+ // Close AudioContext to free resources
95
+ if (this.audioContext && this.audioContext.state !== 'closed') {
96
+ this.audioContext.close().catch((e) => {
97
+ console.warn('Error closing AudioContext:', e);
98
+ });
99
+ }
100
+ }
101
+
91
102
  private playChunk(chunk: ArrayBuffer): Promise<void> {
92
103
  // console.log({queue: this.chunkQueue})
93
104
  if (!chunk) {
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect } from 'react';
1
+ import React, { useState, useEffect, useRef } from 'react';
2
2
  import { FaPlayCircle, FaStopCircle } from 'react-icons/fa';
3
3
  import { useRimori } from '../../providers/PluginProvider';
4
4
  import { EventBus } from '@rimori/client';
@@ -34,6 +34,8 @@ export const AudioPlayer: React.FC<AudioPlayerProps> = ({
34
34
  const [isPlaying, setIsPlaying] = useState(false);
35
35
  const [isLoading, setIsLoading] = useState(false);
36
36
  const { ai } = useRimori();
37
+ const audioRef = useRef<HTMLAudioElement | null>(null);
38
+ const eventBusListenerRef = useRef<{ off: () => void } | null>(null);
37
39
 
38
40
  useEffect(() => {
39
41
  if (audioUrl) setAudioUrl(null);
@@ -53,8 +55,24 @@ export const AudioPlayer: React.FC<AudioPlayerProps> = ({
53
55
 
54
56
  // Effect to play audio when audioUrl changes and play state is true
55
57
  useEffect(() => {
56
- if (!audioUrl || !isPlaying) return;
58
+ if (!audioUrl || !isPlaying) {
59
+ // Stop any existing audio when not playing
60
+ if (audioRef.current) {
61
+ audioRef.current.pause();
62
+ audioRef.current.currentTime = 0;
63
+ audioRef.current = null;
64
+ }
65
+ return;
66
+ }
67
+
68
+ // Clean up previous audio instance if it exists
69
+ if (audioRef.current) {
70
+ audioRef.current.pause();
71
+ audioRef.current.currentTime = 0;
72
+ }
73
+
57
74
  const audio = new Audio(audioUrl);
75
+ audioRef.current = audio;
58
76
  audio.playbackRate = speed;
59
77
  audio
60
78
  .play()
@@ -62,18 +80,45 @@ export const AudioPlayer: React.FC<AudioPlayerProps> = ({
62
80
  audio.onended = () => {
63
81
  setIsPlaying(false);
64
82
  isFetchingAudio = false;
83
+ audioRef.current = null;
65
84
  };
66
85
  })
67
86
  .catch((e) => {
68
87
  console.warn('Error playing audio:', e);
69
88
  setIsPlaying(false);
89
+ audioRef.current = null;
70
90
  });
71
91
 
72
92
  return () => {
73
- audio.pause();
93
+ if (audioRef.current) {
94
+ audioRef.current.pause();
95
+ audioRef.current.currentTime = 0;
96
+ audioRef.current = null;
97
+ }
74
98
  };
75
99
  }, [audioUrl, isPlaying, speed]);
76
100
 
101
+ // Cleanup on unmount - stop audio and revoke object URL
102
+ useEffect(() => {
103
+ return () => {
104
+ if (audioRef.current) {
105
+ audioRef.current.pause();
106
+ audioRef.current.currentTime = 0;
107
+ audioRef.current = null;
108
+ }
109
+ setIsPlaying(false);
110
+ };
111
+ }, []);
112
+
113
+ // Cleanup audioUrl on unmount
114
+ useEffect(() => {
115
+ return () => {
116
+ if (audioUrl) {
117
+ URL.revokeObjectURL(audioUrl);
118
+ }
119
+ };
120
+ }, [audioUrl]);
121
+
77
122
  const togglePlayback = () => {
78
123
  if (!isPlaying && !audioUrl) {
79
124
  generateAudio().then(() => setIsPlaying(true));
@@ -84,7 +129,16 @@ export const AudioPlayer: React.FC<AudioPlayerProps> = ({
84
129
 
85
130
  useEffect(() => {
86
131
  if (!playListenerEvent) return;
87
- EventBus.on(playListenerEvent, () => togglePlayback());
132
+ const handler = () => togglePlayback();
133
+ const listener = EventBus.on(playListenerEvent, handler);
134
+ eventBusListenerRef.current = listener;
135
+
136
+ return () => {
137
+ if (eventBusListenerRef.current) {
138
+ eventBusListenerRef.current.off();
139
+ eventBusListenerRef.current = null;
140
+ }
141
+ };
88
142
  }, [playListenerEvent]);
89
143
 
90
144
  useEffect(() => {
@@ -14,7 +14,7 @@ interface PluginProviderProps {
14
14
  const PluginContext = createContext<RimoriClient | null>(null);
15
15
 
16
16
  export const PluginProvider: React.FC<PluginProviderProps> = ({ children, pluginId, settings }) => {
17
- const [plugin, setPlugin] = useState<RimoriClient | null>(null);
17
+ const [client, setClient] = useState<RimoriClient | null>(null);
18
18
  const [standaloneClient, setStandaloneClient] = useState<StandaloneClient | boolean>(false);
19
19
  const [applicationMode, setApplicationMode] = useState<string | null>(null);
20
20
  const [theme, setTheme] = useState<string | null>(null);
@@ -36,9 +36,9 @@ export const PluginProvider: React.FC<PluginProviderProps> = ({ children, plugin
36
36
  });
37
37
  }
38
38
 
39
- if ((!standaloneDetected && !plugin) || (standaloneDetected && standaloneClient === true)) {
39
+ if ((!standaloneDetected && !client) || (standaloneDetected && standaloneClient === true)) {
40
40
  void RimoriClient.getInstance(pluginId).then((client) => {
41
- setPlugin(client);
41
+ setClient(client);
42
42
  // Get applicationMode and theme from MessageChannel query params
43
43
  if (!standaloneDetected) {
44
44
  const mode = client.getQueryParam('applicationMode');
@@ -49,28 +49,20 @@ export const PluginProvider: React.FC<PluginProviderProps> = ({ children, plugin
49
49
  }
50
50
  });
51
51
  }
52
- }, [pluginId, standaloneClient, plugin]);
52
+ }, [pluginId, standaloneClient, client]);
53
53
 
54
- //route change
55
54
  useEffect(() => {
56
- if (!plugin) return;
57
-
58
- //sidebar pages should not report url changes
59
- if (isSidebar) return;
60
-
61
- let lastHash = window.location.hash;
62
- const emitUrlChange = (url: string) => plugin.event.emit('session.triggerUrlChange', { url });
63
-
64
- const interval = setInterval(() => {
65
- if (lastHash === window.location.hash) return;
66
- lastHash = window.location.hash;
67
- // console.log('url changed:', lastHash);
68
- emitUrlChange(lastHash);
69
- }, 1000);
70
-
71
- emitUrlChange(lastHash);
72
- return () => clearInterval(interval);
73
- }, [plugin, isSidebar]);
55
+ if (!client) return;
56
+ if (isSidebar) return; //sidebar pages should not report url changes
57
+
58
+ // react router overwrites native pushstate so it gets wrapped to detect url changes
59
+ const originalPushState = history.pushState;
60
+ history.pushState = (...args) => {
61
+ const result = originalPushState.apply(history, args);
62
+ client.event.emit('session.triggerUrlChange', { url: location.hash });
63
+ return result;
64
+ };
65
+ }, [client, isSidebar]);
74
66
 
75
67
  if (standaloneClient instanceof StandaloneClient) {
76
68
  return (
@@ -82,13 +74,13 @@ export const PluginProvider: React.FC<PluginProviderProps> = ({ children, plugin
82
74
  );
83
75
  }
84
76
 
85
- if (!plugin) {
77
+ if (!client) {
86
78
  return '';
87
79
  }
88
80
 
89
81
  return (
90
- <PluginContext.Provider value={plugin}>
91
- {!settings?.disableContextMenu && !isSidebar && !isSettings && <ContextMenu client={plugin} />}
82
+ <PluginContext.Provider value={client}>
83
+ {!settings?.disableContextMenu && !isSidebar && !isSettings && <ContextMenu client={client} />}
92
84
  {children}
93
85
  </PluginContext.Provider>
94
86
  );
@@ -148,10 +140,12 @@ function StandaloneAuth({ onLogin }: { onLogin: (user: string, password: string)
148
140
  justifyContent: 'center',
149
141
  }}
150
142
  >
151
- <p style={{ fontSize: '2rem', fontWeight: 'bold', marginBottom: '1rem', textAlign: 'center' }}>Rimori Login</p>
143
+ <p style={{ fontSize: '2rem', fontWeight: 'bold', marginBottom: '1rem', textAlign: 'center' }}>
144
+ Rimori Login Required
145
+ </p>
152
146
  <p style={{ marginBottom: '1rem', textAlign: 'center' }}>
153
147
  Please login with your Rimori developer account for this plugin to be able to access the Rimori platform the
154
- same it will operate in the Rimori platform.
148
+ same way it would when being deployed.
155
149
  </p>
156
150
  {/* email and password input */}
157
151
  <input