@rimori/react-client 0.3.0 → 0.4.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.
@@ -0,0 +1,149 @@
1
+ name: Pre-Release Rimori React Client
2
+
3
+ on:
4
+ push:
5
+ branches: [dev]
6
+ paths:
7
+ - '**'
8
+ - '!.github/workflows/**'
9
+
10
+ jobs:
11
+ pre-release:
12
+ runs-on: ubuntu-latest
13
+ permissions:
14
+ contents: write
15
+ id-token: write
16
+
17
+ steps:
18
+ - name: Checkout repository
19
+ uses: actions/checkout@v4
20
+ with:
21
+ token: ${{ secrets.GITHUB_TOKEN }}
22
+ fetch-depth: 0
23
+
24
+ - name: Setup Node.js
25
+ uses: actions/setup-node@v4
26
+ with:
27
+ node-version: '20'
28
+ registry-url: 'https://registry.npmjs.org'
29
+ cache: 'yarn'
30
+ cache-dependency-path: yarn.lock
31
+
32
+ - name: Update npm
33
+ run: npm install -g npm@latest
34
+
35
+ - name: Get latest @rimori/client@next version
36
+ id: client-version
37
+ run: |
38
+ VERSION=$(npm view @rimori/client@next version 2>/dev/null || echo "")
39
+ if [ -z "$VERSION" ]; then
40
+ echo "⚠️ Warning: No @rimori/client@next version found. Using current dependency version."
41
+ VERSION=$(node -p "require('./package.json').peerDependencies['@rimori/client'] || require('./package.json').devDependencies['@rimori/client']")
42
+ # Remove ^ prefix if present
43
+ VERSION="${VERSION#^}"
44
+ fi
45
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
46
+ echo "Using @rimori/client version: $VERSION"
47
+
48
+ - name: Update @rimori/client dependency
49
+ run: |
50
+ # Update both peerDependencies and devDependencies
51
+ yarn add "@rimori/client@${{ steps.client-version.outputs.version }}" --dev --exact
52
+ # Also update peerDependencies using node
53
+ node -e "const fs = require('fs'); const pkg = JSON.parse(fs.readFileSync('package.json')); if (pkg.peerDependencies && pkg.peerDependencies['@rimori/client']) { pkg.peerDependencies['@rimori/client'] = '${{ steps.client-version.outputs.version }}'; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); }"
54
+ echo "Updated @rimori/client to ${{ steps.client-version.outputs.version }}"
55
+
56
+ - name: Install dependencies
57
+ run: yarn install
58
+
59
+ - name: Build react-client (TypeScript verification)
60
+ run: yarn build
61
+
62
+ - name: Calculate next pre-release version
63
+ id: version
64
+ run: |
65
+ # Read current version from package.json (may be base or pre-release)
66
+ CURRENT_VERSION=$(node -p "require('./package.json').version")
67
+
68
+ # Extract base version (strip any pre-release suffix)
69
+ # Examples: "0.3.0" -> "0.3.0", "0.3.0-next.5" -> "0.3.0"
70
+ if [[ "$CURRENT_VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+) ]]; then
71
+ BASE_VERSION="${BASH_REMATCH[1]}"
72
+ else
73
+ BASE_VERSION="$CURRENT_VERSION"
74
+ fi
75
+
76
+ # Try to get latest next version from npm
77
+ PACKAGE_NAME="@rimori/react-client"
78
+ LATEST_NEXT=$(npm view ${PACKAGE_NAME}@next version 2>/dev/null || echo "none")
79
+
80
+ if [ "$LATEST_NEXT" != "none" ]; then
81
+ # Extract base version and pre-release number from latest next version
82
+ # Example: "0.3.0-next.5" -> extract "0.3.0" and "5"
83
+ if [[ "$LATEST_NEXT" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-next\.([0-9]+)$ ]]; then
84
+ LATEST_BASE="${BASH_REMATCH[1]}"
85
+ PRERELEASE_NUM="${BASH_REMATCH[2]}"
86
+
87
+ # If base version changed, reset to 1, otherwise increment
88
+ if [ "$LATEST_BASE" != "$BASE_VERSION" ]; then
89
+ NEW_NUM=1
90
+ else
91
+ NEW_NUM=$((PRERELEASE_NUM + 1))
92
+ fi
93
+ else
94
+ # Fallback: if format doesn't match, start at 1
95
+ NEW_NUM=1
96
+ fi
97
+ else
98
+ # First pre-release
99
+ NEW_NUM=1
100
+ fi
101
+
102
+ NEW_VERSION="${BASE_VERSION}-next.${NEW_NUM}"
103
+ echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
104
+ echo "Base version: $BASE_VERSION"
105
+ echo "Calculated next version: $NEW_VERSION"
106
+
107
+ - name: Update package.json version
108
+ run: |
109
+ # Use node to update version directly (yarn version creates git tags)
110
+ node -e "const fs = require('fs'); const pkg = JSON.parse(fs.readFileSync('package.json')); pkg.version = '${{ steps.version.outputs.new_version }}'; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');"
111
+
112
+ - name: Publish to npm
113
+ run: npm publish --tag next --access public
114
+ # Uses OIDC token automatically (no NODE_AUTH_TOKEN needed)
115
+ # Requires npm 11.5.1+ and id-token: write permission (already set)
116
+
117
+ - name: Output published version
118
+ run: |
119
+ echo "✅ Published @rimori/react-client@${{ steps.version.outputs.new_version }} to npm with @next tag"
120
+ echo "Using @rimori/client@${{ steps.client-version.outputs.version }}"
121
+
122
+ - name: Create git tag
123
+ run: |
124
+ git config --local user.email "action@github.com"
125
+ git config --local user.name "GitHub Action"
126
+ git tag "v${{ steps.version.outputs.new_version }}" -m "Pre-release v${{ steps.version.outputs.new_version }}"
127
+ git push origin "v${{ steps.version.outputs.new_version }}"
128
+ echo "🏷️ Created and pushed tag v${{ steps.version.outputs.new_version }}"
129
+
130
+ - name: Notify Slack
131
+ if: always()
132
+ uses: slackapi/slack-github-action@v1.24.0
133
+ with:
134
+ channel-id: ${{ secrets.SLACK_CHANNEL_ID }}
135
+ payload: |
136
+ {
137
+ "text": "Pre-Release Pipeline Status",
138
+ "blocks": [
139
+ {
140
+ "type": "section",
141
+ "text": {
142
+ "type": "mrkdwn",
143
+ "text": "📦 *@rimori/react-client Pre-Release*\n\n*Branch:* ${{ github.ref_name }}\n*Version:* ${{ steps.version.outputs.new_version }}\n*Using @rimori/client:* ${{ steps.client-version.outputs.version }}\n*Author:* ${{ github.actor }}\n*Pipeline:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run>\n\n${{ job.status == 'success' && '✅ Successfully published to npm with @next tag!' || '❌ Pipeline failed. Check the logs for details.' }}"
144
+ }
145
+ }
146
+ ]
147
+ }
148
+ env:
149
+ SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
@@ -2,12 +2,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState, useEffect, useRef } from 'react';
3
3
  const ContextMenu = ({ client }) => {
4
4
  const [isOpen, setIsOpen] = useState(false);
5
- const [actions, setActions] = useState([]);
6
5
  const [position, setPosition] = useState({ x: 0, y: 0 });
7
- const [openOnTextSelect, setOpenOnTextSelect] = useState(false);
8
6
  const [menuWidth, setMenuWidth] = useState(0);
9
7
  const menuRef = useRef(null);
10
8
  const isMobile = window.innerWidth < 768;
9
+ const openOnTextSelect = client.plugin.getUserInfo().context_menu_on_select;
10
+ const actions = client.plugin
11
+ .getPluginInfo()
12
+ .installedPlugins.flatMap((p) => p.context_menu_actions)
13
+ .filter(Boolean);
11
14
  /**
12
15
  * Calculates position for mobile context menu based on selected text bounds.
13
16
  * Centers the menu horizontally over the selected text and positions it 30px below the text's end.
@@ -29,14 +32,8 @@ const ContextMenu = ({ client }) => {
29
32
  return { x: centerX, y: textEndY, text: selectedText };
30
33
  };
31
34
  useEffect(() => {
32
- const actions = client.plugin
33
- .getPluginInfo()
34
- .installedPlugins.flatMap((p) => p.context_menu_actions)
35
- .filter(Boolean);
36
- setActions(actions);
37
- setOpenOnTextSelect(client.plugin.getUserInfo().context_menu_on_select);
38
35
  client.event.on('global.contextMenu.createActions', ({ data }) => {
39
- setActions([...data.actions, ...actions]);
36
+ actions.push(...data.actions);
40
37
  });
41
38
  }, []);
42
39
  // Update menu width when menu is rendered
@@ -44,7 +41,7 @@ const ContextMenu = ({ client }) => {
44
41
  if (isOpen && menuRef.current) {
45
42
  setMenuWidth(menuRef.current.offsetWidth);
46
43
  }
47
- }, [isOpen, actions]);
44
+ }, [isOpen]);
48
45
  useEffect(() => {
49
46
  // Track mouse position globally
50
47
  const handleMouseMove = (e) => {
@@ -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();
@@ -12,6 +12,7 @@ export declare class ChunkedAudioPlayer {
12
12
  private currentIndex;
13
13
  private startedPlaying;
14
14
  private onEndOfSpeech;
15
+ private readonly backgroundNoiseLevel;
15
16
  constructor();
16
17
  private init;
17
18
  setOnLoudnessChange(callback: (value: number) => void): void;
@@ -19,6 +20,7 @@ export declare class ChunkedAudioPlayer {
19
20
  addChunk(chunk: ArrayBuffer, position: number): Promise<void>;
20
21
  private playChunks;
21
22
  stopPlayback(): void;
23
+ cleanup(): void;
22
24
  private playChunk;
23
25
  playAgain(): Promise<void>;
24
26
  private monitorLoudness;
@@ -19,6 +19,7 @@ export class ChunkedAudioPlayer {
19
19
  this.currentIndex = 0;
20
20
  this.startedPlaying = false;
21
21
  this.onEndOfSpeech = () => { };
22
+ this.backgroundNoiseLevel = 30; // Background noise level that should be treated as baseline (0)
22
23
  this.init();
23
24
  }
24
25
  init() {
@@ -88,6 +89,16 @@ export class ChunkedAudioPlayer {
88
89
  this.shouldMonitorLoudness = false;
89
90
  cancelAnimationFrame(this.handle);
90
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
+ }
91
102
  playChunk(chunk) {
92
103
  // console.log({queue: this.chunkQueue})
93
104
  if (!chunk) {
@@ -162,9 +173,18 @@ export class ChunkedAudioPlayer {
162
173
  if (loudnessInDb > maxDb) {
163
174
  loudnessInDb = maxDb;
164
175
  }
165
- const loudnessScale = ((loudnessInDb - minDb) / (maxDb - minDb)) * 100;
166
- // console.log("root:corrent loudness", loudnessScale);
167
- this.loudnessCallback(loudnessScale);
176
+ let loudnessScale = ((loudnessInDb - minDb) / (maxDb - minDb)) * 100;
177
+ // Adjust loudness: shift zero level up by background noise amount
178
+ // Values below background noise level are set to 0
179
+ // Values above are remapped to 0-100 scale
180
+ if (loudnessScale < this.backgroundNoiseLevel) {
181
+ loudnessScale = 0;
182
+ }
183
+ else {
184
+ // Remap from [backgroundNoiseLevel, 100] to [0, 100]
185
+ loudnessScale = ((loudnessScale - this.backgroundNoiseLevel) / (100 - this.backgroundNoiseLevel)) * 100;
186
+ }
187
+ this.loudnessCallback(Math.round(loudnessScale));
168
188
  }
169
189
  // Call this method again at regular intervals if you want continuous loudness monitoring
170
190
  this.handle = requestAnimationFrame(() => this.monitorLoudness());
@@ -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,11 @@
1
1
  {
2
2
  "name": "@rimori/react-client",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
+ "license": "Apache-2.0",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/rimori-org/react-client.git"
8
+ },
4
9
  "main": "dist/index.js",
5
10
  "types": "dist/index.d.ts",
6
11
  "type": "module",
@@ -18,17 +23,19 @@
18
23
  "format": "prettier --write ."
19
24
  },
20
25
  "peerDependencies": {
21
- "react": "^18.0.0",
22
- "react-dom": "^18.0.0",
23
- "@rimori/client": "^2.2.0"
26
+ "@rimori/client": "^2.4.0",
27
+ "react": "^18.1.0",
28
+ "react-dom": "^18.1.0"
24
29
  },
25
30
  "dependencies": {
26
31
  "html2canvas": "1.4.1",
27
- "react-icons": "5.4.0"
32
+ "react-icons": "5.4.0",
33
+ "react-markdown": "^10.1.0"
28
34
  },
29
35
  "devDependencies": {
30
36
  "@eslint/js": "^9.37.0",
31
- "@rimori/client": "^2.2.0",
37
+ "@rimori/client": "^2.4.0",
38
+ "@types/react": "^18.3.21",
32
39
  "eslint-config-prettier": "^10.1.8",
33
40
  "eslint-plugin-prettier": "^5.5.4",
34
41
  "eslint-plugin-react-hooks": "^7.0.0",
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect, useRef } from 'react';
1
+ import { useState, useEffect, useRef } from 'react';
2
2
  import { RimoriClient, MenuEntry } from '@rimori/client';
3
3
 
4
4
  export interface Position {
@@ -9,12 +9,15 @@ export interface Position {
9
9
 
10
10
  const ContextMenu = ({ client }: { client: RimoriClient }) => {
11
11
  const [isOpen, setIsOpen] = useState<boolean>(false);
12
- const [actions, setActions] = useState<MenuEntry[]>([]);
13
12
  const [position, setPosition] = useState<Position>({ x: 0, y: 0 });
14
- const [openOnTextSelect, setOpenOnTextSelect] = useState(false);
15
13
  const [menuWidth, setMenuWidth] = useState<number>(0);
16
14
  const menuRef = useRef<HTMLDivElement>(null);
17
15
  const isMobile = window.innerWidth < 768;
16
+ const openOnTextSelect = client.plugin.getUserInfo().context_menu_on_select;
17
+ const actions = client.plugin
18
+ .getPluginInfo()
19
+ .installedPlugins.flatMap((p) => p.context_menu_actions)
20
+ .filter(Boolean);
18
21
 
19
22
  /**
20
23
  * Calculates position for mobile context menu based on selected text bounds.
@@ -23,7 +26,7 @@ const ContextMenu = ({ client }: { client: RimoriClient }) => {
23
26
  * @param menuWidth - The width of the menu to center properly
24
27
  * @returns Position object with x and y coordinates
25
28
  */
26
- const calculateMobilePosition = (selectedText: string, menuWidth: number = 0): Position => {
29
+ const calculateMobilePosition = (selectedText: string, menuWidth = 0): Position => {
27
30
  const selection = window.getSelection();
28
31
  if (!selection || !selectedText) {
29
32
  return { x: 0, y: 0, text: selectedText };
@@ -42,15 +45,8 @@ const ContextMenu = ({ client }: { client: RimoriClient }) => {
42
45
  };
43
46
 
44
47
  useEffect(() => {
45
- const actions = client.plugin
46
- .getPluginInfo()
47
- .installedPlugins.flatMap((p) => p.context_menu_actions)
48
- .filter(Boolean);
49
- setActions(actions);
50
- setOpenOnTextSelect(client.plugin.getUserInfo().context_menu_on_select);
51
-
52
48
  client.event.on<{ actions: MenuEntry[] }>('global.contextMenu.createActions', ({ data }) => {
53
- setActions([...data.actions, ...actions]);
49
+ actions.push(...data.actions);
54
50
  });
55
51
  }, []);
56
52
 
@@ -59,11 +55,11 @@ const ContextMenu = ({ client }: { client: RimoriClient }) => {
59
55
  if (isOpen && menuRef.current) {
60
56
  setMenuWidth(menuRef.current.offsetWidth);
61
57
  }
62
- }, [isOpen, actions]);
58
+ }, [isOpen]);
63
59
 
64
60
  useEffect(() => {
65
61
  // Track mouse position globally
66
- const handleMouseMove = (e: MouseEvent) => {
62
+ const handleMouseMove = (e: MouseEvent): void => {
67
63
  const selectedText = window.getSelection()?.toString().trim();
68
64
  if (isOpen && selectedText === position.text) return;
69
65
 
@@ -74,7 +70,7 @@ const ContextMenu = ({ client }: { client: RimoriClient }) => {
74
70
  }
75
71
  };
76
72
 
77
- const handleMouseUp = (e: MouseEvent) => {
73
+ const handleMouseUp = (e: MouseEvent): void => {
78
74
  const selectedText = window.getSelection()?.toString().trim();
79
75
  // Check if click is inside the context menu
80
76
  if (menuRef.current && menuRef.current.contains(e.target as Node)) {
@@ -112,7 +108,7 @@ const ContextMenu = ({ client }: { client: RimoriClient }) => {
112
108
  };
113
109
 
114
110
  // Add selectionchange listener to close menu if selection is cleared and update position for mobile
115
- const handleSelectionChange = () => {
111
+ const handleSelectionChange = (): void => {
116
112
  const selectedText = window.getSelection()?.toString().trim();
117
113
  if (!selectedText && isOpen) {
118
114
  setIsOpen(false);
@@ -161,7 +157,7 @@ const ContextMenu = ({ client }: { client: RimoriClient }) => {
161
157
  );
162
158
  };
163
159
 
164
- function MenuEntryItem(props: { iconUrl?: string; text: string; onClick: () => void }) {
160
+ function MenuEntryItem(props: { iconUrl?: string; text: string; onClick: () => void }): JSX.Element {
165
161
  return (
166
162
  <button
167
163
  onClick={props.onClick}
@@ -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();
@@ -12,6 +12,7 @@ export class ChunkedAudioPlayer {
12
12
  private currentIndex = 0;
13
13
  private startedPlaying = false;
14
14
  private onEndOfSpeech: () => void = () => {};
15
+ private readonly backgroundNoiseLevel = 30; // Background noise level that should be treated as baseline (0)
15
16
 
16
17
  constructor() {
17
18
  this.init();
@@ -87,6 +88,17 @@ export class ChunkedAudioPlayer {
87
88
  cancelAnimationFrame(this.handle);
88
89
  }
89
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
+
90
102
  private playChunk(chunk: ArrayBuffer): Promise<void> {
91
103
  // console.log({queue: this.chunkQueue})
92
104
  if (!chunk) {
@@ -171,10 +183,19 @@ export class ChunkedAudioPlayer {
171
183
  loudnessInDb = maxDb;
172
184
  }
173
185
 
174
- const loudnessScale = ((loudnessInDb - minDb) / (maxDb - minDb)) * 100;
175
- // console.log("root:corrent loudness", loudnessScale);
186
+ let loudnessScale = ((loudnessInDb - minDb) / (maxDb - minDb)) * 100;
187
+
188
+ // Adjust loudness: shift zero level up by background noise amount
189
+ // Values below background noise level are set to 0
190
+ // Values above are remapped to 0-100 scale
191
+ if (loudnessScale < this.backgroundNoiseLevel) {
192
+ loudnessScale = 0;
193
+ } else {
194
+ // Remap from [backgroundNoiseLevel, 100] to [0, 100]
195
+ loudnessScale = ((loudnessScale - this.backgroundNoiseLevel) / (100 - this.backgroundNoiseLevel)) * 100;
196
+ }
176
197
 
177
- this.loudnessCallback(loudnessScale);
198
+ this.loudnessCallback(Math.round(loudnessScale));
178
199
  }
179
200
 
180
201
  // Call this method again at regular intervals if you want continuous loudness monitoring
@@ -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