@rimori/react-client 0.4.18 → 0.4.19-next.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.
@@ -11,7 +11,7 @@ export function AssistantChat({ avatarImageUrl, voiceId, onComplete, autoStartCo
11
11
  var _a;
12
12
  const [oralCommunication, setOralCommunication] = React.useState(true);
13
13
  const { ai: llm, event } = useRimori();
14
- const sender = useMemo(() => new MessageSender(llm.getVoice, voiceId), []);
14
+ const sender = useMemo(() => new MessageSender(llm.getVoice, voiceId), [llm.getVoice, voiceId]);
15
15
  const { messages, append, isLoading, setMessages } = useChat();
16
16
  const lastAssistantMessage = (_a = [...messages].filter((m) => m.role === 'assistant').pop()) === null || _a === void 0 ? void 0 : _a.content;
17
17
  useEffect(() => {
@@ -9,7 +9,7 @@ import { getFirstMessages } from './utils';
9
9
  import { useTheme } from '../../hooks/ThemeSetter';
10
10
  export function Avatar({ avatarImageUrl, voiceId, agentTools, autoStartConversation, children, circleSize = '300px', className, cache = false, prompt, promptVariables, }) {
11
11
  const { ai, event, plugin, userInfo } = useRimori();
12
- const { isDark: isDarkThemeValue } = useTheme(plugin.theme);
12
+ const { isDark: isDarkThemeValue } = useTheme(plugin.theme, true);
13
13
  const [agentReplying, setAgentReplying] = useState(false);
14
14
  const [isProcessingMessage, setIsProcessingMessage] = useState(false);
15
15
  const dialectTtsInstruction = (userInfo === null || userInfo === void 0 ? void 0 : userInfo.dialect)
@@ -27,6 +27,7 @@ export function Avatar({ avatarImageUrl, voiceId, agentTools, autoStartConversat
27
27
  console.log('messages', messages);
28
28
  }, [messages]);
29
29
  useEffect(() => {
30
+ // eslint-disable-next-line react-hooks/set-state-in-effect
30
31
  if (!isLoading)
31
32
  setIsProcessingMessage(false);
32
33
  }, [isLoading]);
@@ -58,6 +59,7 @@ export function Avatar({ avatarImageUrl, voiceId, agentTools, autoStartConversat
58
59
  sender.handleNewText(lastMessage.content, isLoading);
59
60
  if (lastMessage.toolCalls) {
60
61
  // console.log("unlocking mic", lastMessage)
62
+ // eslint-disable-next-line react-hooks/set-state-in-effect
61
63
  setAgentReplying(false);
62
64
  setIsProcessingMessage(false);
63
65
  }
@@ -1,4 +1,4 @@
1
- import React from 'react';
1
+ import React, { JSX } from 'react';
2
2
  import { Tool } from '@rimori/client';
3
3
  export interface BuddyAssistantAutoStart {
4
4
  /** Pre-written assistant message shown immediately (no AI call) */
@@ -11,7 +11,7 @@ const genId = () => `ba-${++idCounter}`;
11
11
  export function BuddyAssistant({ prompt, promptVariables, autoStartConversation, circleSize = '160px', chatPlaceholder, bottomAction, className, voiceSpeed = 1, tools, showName = false, }) {
12
12
  const { ai, event, plugin, userInfo } = useRimori();
13
13
  const ttsEnabled = plugin.ttsEnabled;
14
- const { isDark } = useTheme(plugin.theme);
14
+ const { isDark } = useTheme(plugin.theme, true);
15
15
  const buddy = userInfo.study_buddy;
16
16
  const dialect = userInfo === null || userInfo === void 0 ? void 0 : userInfo.dialect;
17
17
  const dialectTtsInstruction = dialect ? `Speak with a ${dialect} accent and pronunciation.` : undefined;
@@ -99,7 +99,7 @@ export function BuddyAssistant({ prompt, promptVariables, autoStartConversation,
99
99
  triggerAI(newMessages);
100
100
  };
101
101
  const lastAssistantMessage = [...messages].filter((m) => m.role === 'assistant').pop();
102
- return (_jsxs("div", { className: `flex flex-col items-center ${className || ''}`, children: [_jsx(CircleAudioAvatar, { width: circleSize, imageUrl: buddy.avatarUrl, isDarkTheme: isDark, className: "mx-auto" }), showName && (_jsx("div", { className: "flex items-center gap-2", children: _jsx("span", { className: "text-3xl font-semibold", children: buddy.name }) })), !ttsEnabled ? (_jsx("div", { className: "w-full rounded-xl bg-gray-200/70 dark:bg-gray-800/70 px-4 py-3 text-sm text-gray-800 dark:text-gray-200 leading-relaxed border border-gray-300/40 dark:border-gray-700/40 mt-4", children: !(lastAssistantMessage === null || lastAssistantMessage === void 0 ? void 0 : lastAssistantMessage.content) && isLoading ? (_jsxs("span", { className: "inline-flex gap-1 py-0.5", children: [_jsx("span", { className: "w-1.5 h-1.5 rounded-full bg-gray-400 animate-bounce" }), _jsx("span", { className: "w-1.5 h-1.5 rounded-full bg-gray-400 animate-bounce", style: { animationDelay: '0.15s' } }), _jsx("span", { className: "w-1.5 h-1.5 rounded-full bg-gray-400 animate-bounce", style: { animationDelay: '0.3s' } })] })) : (_jsx("span", { className: "whitespace-pre-wrap", children: lastAssistantMessage === null || lastAssistantMessage === void 0 ? void 0 : lastAssistantMessage.content })) })) : null, _jsxs("div", { className: 'w-full relative mt-4 ' + (ttsEnabled ? 'max-w-md' : ''), children: [_jsx("input", { value: chatInput, onChange: (e) => setChatInput(e.target.value), onKeyDown: (e) => {
102
+ return (_jsxs("div", { className: `flex flex-col items-center ${className || ''}`, children: [_jsx(CircleAudioAvatar, { width: circleSize, imageUrl: buddy.avatarUrl, isDarkTheme: isDark, className: "mx-auto" }), showName && (_jsx("div", { className: "flex items-center gap-2", children: _jsx("span", { className: "text-3xl font-semibold", children: buddy.name }) })), !plugin.ttsEnabled && messages.length > 1 ? (_jsx("div", { className: "w-full rounded-xl bg-gray-200/70 dark:bg-gray-800/70 px-4 py-3 text-sm text-gray-800 dark:text-gray-200 leading-relaxed border border-gray-300/40 dark:border-gray-700/40 mt-4", children: !(lastAssistantMessage === null || lastAssistantMessage === void 0 ? void 0 : lastAssistantMessage.content) && isLoading ? (_jsxs("span", { className: "inline-flex gap-1 py-0.5", children: [_jsx("span", { className: "w-1.5 h-1.5 rounded-full bg-gray-400 animate-bounce" }), _jsx("span", { className: "w-1.5 h-1.5 rounded-full bg-gray-400 animate-bounce", style: { animationDelay: '0.15s' } }), _jsx("span", { className: "w-1.5 h-1.5 rounded-full bg-gray-400 animate-bounce", style: { animationDelay: '0.3s' } })] })) : (_jsx("span", { className: "whitespace-pre-wrap", children: lastAssistantMessage === null || lastAssistantMessage === void 0 ? void 0 : lastAssistantMessage.content })) })) : null, _jsxs("div", { className: 'w-full relative mt-4 ' + (ttsEnabled ? 'max-w-md' : ''), children: [_jsx("input", { value: chatInput, onChange: (e) => setChatInput(e.target.value), onKeyDown: (e) => {
103
103
  if (e.key === 'Enter' && !e.shiftKey) {
104
104
  e.preventDefault();
105
105
  sendMessage(chatInput);
@@ -6,6 +6,32 @@ export function CircleAudioAvatar({ imageUrl, className, isDarkTheme = false, wi
6
6
  const currentLoudnessRef = useRef(0);
7
7
  const targetLoudnessRef = useRef(0);
8
8
  const animationFrameRef = useRef(null);
9
+ const draw = (ctx, canvas, image, loudness) => {
10
+ if (canvas && ctx) {
11
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
12
+ const radius = Math.min(canvas.width, canvas.height) / 3;
13
+ const centerX = canvas.width / 2;
14
+ const centerY = canvas.height / 2;
15
+ const pulseRadius = radius + loudness / 2.5;
16
+ ctx.beginPath();
17
+ ctx.arc(centerX, centerY, pulseRadius, 0, Math.PI * 2, true);
18
+ ctx.strokeStyle = isDarkTheme ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)';
19
+ ctx.lineWidth = 5;
20
+ ctx.stroke();
21
+ ctx.save();
22
+ ctx.beginPath();
23
+ ctx.arc(centerX, centerY, radius, 0, Math.PI * 2, true);
24
+ ctx.closePath();
25
+ ctx.clip();
26
+ ctx.drawImage(image, centerX - radius, centerY - radius, radius * 2, radius * 2);
27
+ ctx.restore();
28
+ ctx.beginPath();
29
+ ctx.arc(centerX, centerY, radius, 0, Math.PI * 2, true);
30
+ ctx.strokeStyle = isDarkTheme ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.9)';
31
+ ctx.lineWidth = 5;
32
+ ctx.stroke();
33
+ }
34
+ };
9
35
  useEffect(() => {
10
36
  const canvas = canvasRef.current;
11
37
  if (canvas) {
@@ -49,31 +75,5 @@ export function CircleAudioAvatar({ imageUrl, className, isDarkTheme = false, wi
49
75
  }
50
76
  }
51
77
  }, [imageUrl]);
52
- const draw = (ctx, canvas, image, loudness) => {
53
- if (canvas && ctx) {
54
- ctx.clearRect(0, 0, canvas.width, canvas.height);
55
- const radius = Math.min(canvas.width, canvas.height) / 3;
56
- const centerX = canvas.width / 2;
57
- const centerY = canvas.height / 2;
58
- const pulseRadius = radius + loudness / 2.5;
59
- ctx.beginPath();
60
- ctx.arc(centerX, centerY, pulseRadius, 0, Math.PI * 2, true);
61
- ctx.strokeStyle = isDarkTheme ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)';
62
- ctx.lineWidth = 5;
63
- ctx.stroke();
64
- ctx.save();
65
- ctx.beginPath();
66
- ctx.arc(centerX, centerY, radius, 0, Math.PI * 2, true);
67
- ctx.closePath();
68
- ctx.clip();
69
- ctx.drawImage(image, centerX - radius, centerY - radius, radius * 2, radius * 2);
70
- ctx.restore();
71
- ctx.beginPath();
72
- ctx.arc(centerX, centerY, radius, 0, Math.PI * 2, true);
73
- ctx.strokeStyle = isDarkTheme ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.9)';
74
- ctx.lineWidth = 5;
75
- ctx.stroke();
76
- }
77
- };
78
78
  return _jsx("canvas", { ref: canvasRef, className: className, width: 500, height: 500, style: { width } });
79
79
  }
@@ -23,6 +23,7 @@ export const AudioPlayer = ({ text, voice, language, hide, playListenerEvent, in
23
23
  const audioRef = useRef(null);
24
24
  const eventBusListenerRef = useRef(null);
25
25
  useEffect(() => {
26
+ // eslint-disable-next-line react-hooks/set-state-in-effect
26
27
  if (audioUrl)
27
28
  setAudioUrl(null);
28
29
  return () => {
@@ -128,6 +129,7 @@ export const AudioPlayer = ({ text, voice, language, hide, playListenerEvent, in
128
129
  return;
129
130
  isFetchingAudio = true;
130
131
  // console.log("playOnMount", playOnMount);
132
+ // eslint-disable-next-line react-hooks/set-state-in-effect
131
133
  togglePlayback();
132
134
  }, [playOnMount]);
133
135
  return (_jsx("div", { className: "group relative", children: _jsxs("div", { className: "flex flex-row items-end", children: [!hide && (_jsx("button", { className: "text-gray-400", onClick: togglePlayback, disabled: isLoading, children: isLoading ? (_jsx(Spinner, { size: size })) : isPlaying ? (_jsx(FaStopCircle, { size: size })) : (_jsx(FaPlayCircle, { size: size })) })), enableSpeedAdjustment && (_jsxs("div", { className: "ml-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-row text-sm text-gray-500", children: [_jsx("span", { className: "pr-1", children: "Speed: " }), _jsx("select", { value: speed, className: "appearance-none cursor-pointer pr-0 p-0 rounded shadow leading-tight focus:outline-none focus:bg-gray-800 focus:ring bg-transparent border-0", onChange: (e) => setSpeed(parseFloat(e.target.value)), disabled: isLoading, children: AudioPlayOptions.map((s) => (_jsx("option", { value: s, children: s }, s))) })] }))] }) }));
@@ -78,6 +78,7 @@ const InlinePanel = ({ panel, onClose, editor, onUpdate, labels, onTransform, is
78
78
  useEffect(() => {
79
79
  if (panel === 'link') {
80
80
  const href = editor.getAttributes('link').href;
81
+ // eslint-disable-next-line react-hooks/set-state-in-effect
81
82
  setValue(typeof href === 'string' ? href : 'https://');
82
83
  }
83
84
  else {
package/dist/index.d.ts CHANGED
@@ -12,3 +12,4 @@ export { VoiceRecorder } from './components/ai/EmbeddedAssistent/VoiceRecorder';
12
12
  export { MarkdownEditor } from './components/editor/MarkdownEditor';
13
13
  export type { MarkdownEditorProps, EditorLabels } from './components/editor/MarkdownEditor';
14
14
  export { extractImageUrls } from './components/editor/imageUtils';
15
+ export { injectFederationCss } from './utils/injectFederationCss';
package/dist/index.js CHANGED
@@ -10,3 +10,4 @@ export { BuddyAssistant } from './components/ai/BuddyAssistant';
10
10
  export { VoiceRecorder } from './components/ai/EmbeddedAssistent/VoiceRecorder';
11
11
  export { MarkdownEditor } from './components/editor/MarkdownEditor';
12
12
  export { extractImageUrls } from './components/editor/imageUtils';
13
+ export { injectFederationCss } from './utils/injectFederationCss';
@@ -4,14 +4,12 @@ import type { UserInfo } from '@rimori/client';
4
4
  interface PluginProviderProps {
5
5
  children: ReactNode;
6
6
  pluginId: string;
7
+ /** Pre-constructed RimoriClient (federation mode). When provided, skips the handshake entirely. */
8
+ client?: RimoriClient;
9
+ /** Pre-loaded user info (federation mode). Required when client is provided. */
10
+ userInfo?: UserInfo;
7
11
  settings?: {
8
12
  disableContextMenu?: boolean;
9
- disableThemeSetting?: boolean;
10
- /**
11
- * Skip the scrollbar detection that emits 'session.triggerScrollbarChange'.
12
- * In federation mode, the host (FederatedPluginRenderer) handles this instead.
13
- */
14
- disableScrollbarDetection?: boolean;
15
13
  };
16
14
  }
17
15
  export declare const PluginProvider: React.FC<PluginProviderProps>;
@@ -14,13 +14,14 @@ import posthog from 'posthog-js';
14
14
  import ContextMenu from '../components/ContextMenu';
15
15
  import { useTheme } from '../hooks/ThemeSetter';
16
16
  const PluginContext = createContext(null);
17
- export const PluginProvider = ({ children, pluginId, settings }) => {
18
- const [client, setClient] = useState(null);
17
+ export const PluginProvider = ({ children, pluginId, client: injectedClient, userInfo: injectedUserInfo, settings, }) => {
18
+ const isFederated = !!injectedClient;
19
+ const [client, setClient] = useState(injectedClient !== null && injectedClient !== void 0 ? injectedClient : null);
19
20
  const [standaloneClient, setStandaloneClient] = useState(false);
20
21
  const [applicationMode, setApplicationMode] = useState(null);
21
22
  const [theme, setTheme] = useState(undefined);
22
- const [userInfo, setUserInfo] = useState(null);
23
- useTheme(theme, settings === null || settings === void 0 ? void 0 : settings.disableThemeSetting);
23
+ const [userInfo, setUserInfo] = useState(injectedUserInfo !== null && injectedUserInfo !== void 0 ? injectedUserInfo : null);
24
+ useTheme(theme, isFederated);
24
25
  // Init PostHog once per plugin iframe
25
26
  useEffect(() => {
26
27
  if (!posthog.__loaded) {
@@ -40,6 +41,9 @@ export const PluginProvider = ({ children, pluginId, settings }) => {
40
41
  const isSidebar = applicationMode === 'sidebar';
41
42
  const isSettings = applicationMode === 'settings';
42
43
  useEffect(() => {
44
+ // In federation mode the client is already ready — skip handshake
45
+ if (isFederated)
46
+ return;
43
47
  initEventBus(pluginId);
44
48
  // Check if we're in an iframe context - if not, we're standalone
45
49
  const standaloneDetected = window === window.parent;
@@ -51,7 +55,6 @@ export const PluginProvider = ({ children, pluginId, settings }) => {
51
55
  if ((!standaloneDetected && !client) || (standaloneDetected && standaloneClient === true)) {
52
56
  void RimoriClient.getInstance(pluginId).then((client) => {
53
57
  setClient(client);
54
- // Set initial userInfo
55
58
  setUserInfo(client.plugin.getUserInfo());
56
59
  // Get applicationMode and theme from MessageChannel query params
57
60
  if (!standaloneDetected) {
@@ -61,7 +64,7 @@ export const PluginProvider = ({ children, pluginId, settings }) => {
61
64
  }
62
65
  });
63
66
  }
64
- }, [pluginId, standaloneClient, client]);
67
+ }, [pluginId, standaloneClient, client, isFederated]);
65
68
  // Identify user in PostHog when userInfo is available
66
69
  useEffect(() => {
67
70
  if (userInfo === null || userInfo === void 0 ? void 0 : userInfo.user_id) {
@@ -73,7 +76,7 @@ export const PluginProvider = ({ children, pluginId, settings }) => {
73
76
  if (!client)
74
77
  return;
75
78
  const unsubscribe = client.plugin.onRimoriInfoUpdate((info) => {
76
- console.log('[PluginProvider] Received RimoriInfo update, updating userInfo');
79
+ // console.log('[PluginProvider] Received RimoriInfo update, updating userInfo + ttsEnabled:', info.ttsEnabled);
77
80
  setUserInfo(info.profile);
78
81
  });
79
82
  return () => unsubscribe();
@@ -83,6 +86,8 @@ export const PluginProvider = ({ children, pluginId, settings }) => {
83
86
  return;
84
87
  if (isSidebar)
85
88
  return; //sidebar pages should not report url changes
89
+ if (isFederated)
90
+ return; // federation mode: host handles URL sync
86
91
  // react router overwrites native pushstate so it gets wrapped to detect url changes
87
92
  const originalPushState = history.pushState;
88
93
  history.pushState = (...args) => {
@@ -90,11 +95,11 @@ export const PluginProvider = ({ children, pluginId, settings }) => {
90
95
  client.event.emit('session.triggerUrlChange', { url: location.hash });
91
96
  return result;
92
97
  };
93
- }, [client, isSidebar]);
98
+ }, [client, isSidebar, isFederated]);
94
99
  useEffect(() => {
95
100
  if (!client)
96
101
  return;
97
- if (settings === null || settings === void 0 ? void 0 : settings.disableScrollbarDetection)
102
+ if (isFederated)
98
103
  return;
99
104
  const checkScrollbar = () => {
100
105
  const hasScrollbar = document.documentElement.scrollHeight > window.innerHeight;
@@ -111,7 +116,7 @@ export const PluginProvider = ({ children, pluginId, settings }) => {
111
116
  window.removeEventListener('resize', checkScrollbar);
112
117
  resizeObserver.disconnect();
113
118
  };
114
- }, [client, settings === null || settings === void 0 ? void 0 : settings.disableScrollbarDetection]);
119
+ }, [client, isFederated]);
115
120
  if (standaloneClient instanceof StandaloneClient) {
116
121
  return (_jsx(StandaloneAuth, { onLogin: (email, password) => __awaiter(void 0, void 0, void 0, function* () {
117
122
  if (yield standaloneClient.login(email, password))
@@ -128,9 +133,9 @@ export const useRimori = () => {
128
133
  if (context === null) {
129
134
  throw new Error('useRimori must be used within an PluginProvider');
130
135
  }
131
- // Return client with userInfo at root level for easy access
132
- // Maintains backwards compatibility - all client properties are still accessible
133
- return Object.assign(context.client, { userInfo: context.userInfo });
136
+ return Object.assign(context.client, {
137
+ userInfo: context.userInfo,
138
+ });
134
139
  };
135
140
  function getUrlParam(name) {
136
141
  // First try to get from URL hash query params (for compatibility)
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Inject a plugin's Tailwind CSS into the document when a federated module loads.
3
+ * Inserts BEFORE existing stylesheets so rimori-main's responsive classes (e.g. md:hidden)
4
+ * keep higher cascade priority and are not overridden by base utility classes from the plugin.
5
+ *
6
+ * @param pluginId - Unique plugin identifier used as the <style> element id (e.g. 'rimori-plugin-translator')
7
+ * @param cssText - The inlined CSS string (imported via `?inline`)
8
+ */
9
+ export declare function injectFederationCss(pluginId: string, cssText: string): void;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Inject a plugin's Tailwind CSS into the document when a federated module loads.
3
+ * Inserts BEFORE existing stylesheets so rimori-main's responsive classes (e.g. md:hidden)
4
+ * keep higher cascade priority and are not overridden by base utility classes from the plugin.
5
+ *
6
+ * @param pluginId - Unique plugin identifier used as the <style> element id (e.g. 'rimori-plugin-translator')
7
+ * @param cssText - The inlined CSS string (imported via `?inline`)
8
+ */
9
+ export function injectFederationCss(pluginId, cssText) {
10
+ if (typeof document === 'undefined' || !cssText)
11
+ return;
12
+ const id = `${pluginId}-css`;
13
+ if (document.getElementById(id))
14
+ return;
15
+ const style = document.createElement('style');
16
+ style.id = id;
17
+ style.textContent = cssText;
18
+ const firstStyle = document.head.querySelector('style, link[rel="stylesheet"]');
19
+ if (firstStyle) {
20
+ document.head.insertBefore(style, firstStyle);
21
+ }
22
+ else {
23
+ document.head.appendChild(style);
24
+ }
25
+ }
package/eslint.config.js CHANGED
@@ -1,53 +1,35 @@
1
- import js from '@eslint/js';
2
1
  import globals from 'globals';
3
2
  import reactHooks from 'eslint-plugin-react-hooks';
4
- import reactRefresh from 'eslint-plugin-react-refresh';
5
3
  import tseslint from 'typescript-eslint';
6
- import prettier from 'eslint-plugin-prettier';
7
4
  import prettierConfig from 'eslint-config-prettier';
8
5
 
9
6
  export default [
10
- { ignores: ['dist', 'node_modules', 'build', '*.js'] },
11
- js.configs.recommended,
12
- ...tseslint.configs.recommended,
7
+ { ignores: ['dist', 'node_modules', 'build', '*.js', '*.cjs', 'scripts/**'] },
8
+ ...tseslint.configs.recommended.map((config) => ({
9
+ ...config,
10
+ files: ['src/**/*.{ts,tsx}'],
11
+ })),
13
12
  prettierConfig,
14
13
  {
15
- files: ['**/*.{ts,tsx,js,jsx}'],
14
+ files: ['src/**/*.{ts,tsx}'],
16
15
  languageOptions: {
17
16
  ecmaVersion: 2020,
18
17
  globals: {
19
18
  ...globals.browser,
20
19
  ...globals.node,
21
- ...globals.jest,
22
20
  },
23
21
  sourceType: 'module',
24
22
  parserOptions: {
25
- projectService: true,
26
23
  tsconfigRootDir: import.meta.dirname,
27
24
  },
28
25
  },
29
26
  plugins: {
30
27
  'react-hooks': reactHooks,
31
- 'react-refresh': reactRefresh,
32
- prettier: prettier,
33
28
  },
34
29
  rules: {
35
30
  ...reactHooks.configs.recommended.rules,
36
- 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
37
- '@typescript-eslint/no-unused-vars': 'warn',
38
31
  '@typescript-eslint/no-explicit-any': 'warn',
39
- '@typescript-eslint/no-floating-promises': 'warn',
40
- '@typescript-eslint/no-unsafe-argument': 'warn',
41
- '@typescript-eslint/explicit-function-return-type': 'warn',
42
- '@typescript-eslint/explicit-module-boundary-types': 'warn',
43
- '@typescript-eslint/no-unsafe-assignment': 'warn',
44
- '@typescript-eslint/no-unsafe-member-access': 'warn',
45
- '@typescript-eslint/no-unsafe-call': 'warn',
46
- '@typescript-eslint/no-unsafe-return': 'warn',
47
- '@typescript-eslint/no-inferrable-types': 'warn',
48
- '@typescript-eslint/no-non-null-assertion': 'warn',
49
- '@typescript-eslint/ban-ts-comment': 'warn',
50
- 'prettier/prettier': 'error',
32
+ '@typescript-eslint/no-unused-vars': 'warn',
51
33
  },
52
34
  },
53
35
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rimori/react-client",
3
- "version": "0.4.18",
3
+ "version": "0.4.19-next.0",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,13 +21,13 @@
21
21
  "dev": "tsc -w --preserveWatchOutput",
22
22
  "dev:watch": "tsc -w --preserveWatchOutput",
23
23
  "css-dev": "sass --watch src/style.scss:dist/style.css",
24
- "lint": "eslint . --fix",
24
+ "lint": "npx eslint . --fix",
25
25
  "format": "prettier --write ."
26
26
  },
27
27
  "peerDependencies": {
28
- "@rimori/client": "^2.5.29",
29
- "react": "^18.1.0",
30
- "react-dom": "^18.1.0"
28
+ "@rimori/client": "^2.5.30",
29
+ "react": "^19.0.0",
30
+ "react-dom": "^19.0.0"
31
31
  },
32
32
  "dependencies": {
33
33
  "@tiptap/core": "^2.26.1",
@@ -48,13 +48,10 @@
48
48
  "tiptap-markdown": "^0.8.10"
49
49
  },
50
50
  "devDependencies": {
51
- "@eslint/js": "^9.37.0",
52
- "@rimori/client": "^2.5.29",
53
- "@types/react": "^18.3.21",
51
+ "@rimori/client": "^2.5.30",
52
+ "@types/react": "^19.0.0",
54
53
  "eslint-config-prettier": "^10.1.8",
55
- "eslint-plugin-prettier": "^5.5.4",
56
54
  "eslint-plugin-react-hooks": "^7.0.0",
57
- "eslint-plugin-react-refresh": "^0.4.23",
58
55
  "form-data": "^4.0.2",
59
56
  "globals": "^16.4.0",
60
57
  "node-fetch": "^3.3.2",
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useRef } from 'react';
1
+ import { JSX, useState, useEffect, useRef } from 'react';
2
2
  import { RimoriClient, MenuEntry } from '@rimori/client';
3
3
 
4
4
  export interface Position {
@@ -17,7 +17,7 @@ interface Props {
17
17
  export function AssistantChat({ avatarImageUrl, voiceId, onComplete, autoStartConversation }: Props) {
18
18
  const [oralCommunication, setOralCommunication] = React.useState(true);
19
19
  const { ai: llm, event } = useRimori();
20
- const sender = useMemo(() => new MessageSender(llm.getVoice, voiceId), []);
20
+ const sender = useMemo(() => new MessageSender(llm.getVoice, voiceId), [llm.getVoice, voiceId]);
21
21
  const { messages, append, isLoading, setMessages } = useChat();
22
22
 
23
23
  const lastAssistantMessage = [...messages].filter((m) => m.role === 'assistant').pop()?.content;
@@ -37,7 +37,7 @@ export function Avatar({
37
37
  promptVariables,
38
38
  }: Props) {
39
39
  const { ai, event, plugin, userInfo } = useRimori();
40
- const { isDark: isDarkThemeValue } = useTheme(plugin.theme);
40
+ const { isDark: isDarkThemeValue } = useTheme(plugin.theme, true);
41
41
  const [agentReplying, setAgentReplying] = useState(false);
42
42
  const [isProcessingMessage, setIsProcessingMessage] = useState(false);
43
43
  const dialectTtsInstruction = userInfo?.dialect
@@ -61,6 +61,7 @@ export function Avatar({
61
61
  }, [messages]);
62
62
 
63
63
  useEffect(() => {
64
+ // eslint-disable-next-line react-hooks/set-state-in-effect
64
65
  if (!isLoading) setIsProcessingMessage(false);
65
66
  }, [isLoading]);
66
67
 
@@ -94,7 +95,9 @@ export function Avatar({
94
95
  sender.handleNewText(lastMessage.content, isLoading);
95
96
  if (lastMessage.toolCalls) {
96
97
  // console.log("unlocking mic", lastMessage)
98
+ // eslint-disable-next-line react-hooks/set-state-in-effect
97
99
  setAgentReplying(false);
100
+
98
101
  setIsProcessingMessage(false);
99
102
  }
100
103
  }
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect, useMemo, useRef } from 'react';
1
+ import React, { JSX, useState, useEffect, useMemo, useRef } from 'react';
2
2
  import { CircleAudioAvatar } from './EmbeddedAssistent/CircleAudioAvatar';
3
3
  import { VoiceRecorder } from './EmbeddedAssistent/VoiceRecorder';
4
4
  import { MessageSender, Tool } from '@rimori/client';
@@ -48,7 +48,7 @@ export function BuddyAssistant({
48
48
  }: BuddyAssistantProps): JSX.Element {
49
49
  const { ai, event, plugin, userInfo } = useRimori();
50
50
  const ttsEnabled = plugin.ttsEnabled;
51
- const { isDark } = useTheme(plugin.theme);
51
+ const { isDark } = useTheme(plugin.theme, true);
52
52
  const buddy = userInfo.study_buddy;
53
53
  const dialect = userInfo?.dialect;
54
54
  const dialectTtsInstruction = dialect ? `Speak with a ${dialect} accent and pronunciation.` : undefined;
@@ -158,7 +158,7 @@ export function BuddyAssistant({
158
158
  </div>
159
159
  )}
160
160
 
161
- {!ttsEnabled ? (
161
+ {!plugin.ttsEnabled && messages.length > 1 ? (
162
162
  <div className="w-full rounded-xl bg-gray-200/70 dark:bg-gray-800/70 px-4 py-3 text-sm text-gray-800 dark:text-gray-200 leading-relaxed border border-gray-300/40 dark:border-gray-700/40 mt-4">
163
163
  {!lastAssistantMessage?.content && isLoading ? (
164
164
  <span className="inline-flex gap-1 py-0.5">
@@ -19,6 +19,41 @@ export function CircleAudioAvatar({
19
19
  const targetLoudnessRef = useRef(0);
20
20
  const animationFrameRef = useRef<number | null>(null);
21
21
 
22
+ const draw = (
23
+ ctx: CanvasRenderingContext2D,
24
+ canvas: HTMLCanvasElement,
25
+ image: HTMLImageElement,
26
+ loudness: number,
27
+ ) => {
28
+ if (canvas && ctx) {
29
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
30
+
31
+ const radius = Math.min(canvas.width, canvas.height) / 3;
32
+ const centerX = canvas.width / 2;
33
+ const centerY = canvas.height / 2;
34
+ const pulseRadius = radius + loudness / 2.5;
35
+ ctx.beginPath();
36
+ ctx.arc(centerX, centerY, pulseRadius, 0, Math.PI * 2, true);
37
+ ctx.strokeStyle = isDarkTheme ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)';
38
+ ctx.lineWidth = 5;
39
+ ctx.stroke();
40
+
41
+ ctx.save();
42
+ ctx.beginPath();
43
+ ctx.arc(centerX, centerY, radius, 0, Math.PI * 2, true);
44
+ ctx.closePath();
45
+ ctx.clip();
46
+ ctx.drawImage(image, centerX - radius, centerY - radius, radius * 2, radius * 2);
47
+ ctx.restore();
48
+
49
+ ctx.beginPath();
50
+ ctx.arc(centerX, centerY, radius, 0, Math.PI * 2, true);
51
+ ctx.strokeStyle = isDarkTheme ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.9)';
52
+ ctx.lineWidth = 5;
53
+ ctx.stroke();
54
+ }
55
+ };
56
+
22
57
  useEffect(() => {
23
58
  const canvas = canvasRef.current;
24
59
  if (canvas) {
@@ -68,40 +103,5 @@ export function CircleAudioAvatar({
68
103
  }
69
104
  }, [imageUrl]);
70
105
 
71
- const draw = (
72
- ctx: CanvasRenderingContext2D,
73
- canvas: HTMLCanvasElement,
74
- image: HTMLImageElement,
75
- loudness: number,
76
- ) => {
77
- if (canvas && ctx) {
78
- ctx.clearRect(0, 0, canvas.width, canvas.height);
79
-
80
- const radius = Math.min(canvas.width, canvas.height) / 3;
81
- const centerX = canvas.width / 2;
82
- const centerY = canvas.height / 2;
83
- const pulseRadius = radius + loudness / 2.5;
84
- ctx.beginPath();
85
- ctx.arc(centerX, centerY, pulseRadius, 0, Math.PI * 2, true);
86
- ctx.strokeStyle = isDarkTheme ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)';
87
- ctx.lineWidth = 5;
88
- ctx.stroke();
89
-
90
- ctx.save();
91
- ctx.beginPath();
92
- ctx.arc(centerX, centerY, radius, 0, Math.PI * 2, true);
93
- ctx.closePath();
94
- ctx.clip();
95
- ctx.drawImage(image, centerX - radius, centerY - radius, radius * 2, radius * 2);
96
- ctx.restore();
97
-
98
- ctx.beginPath();
99
- ctx.arc(centerX, centerY, radius, 0, Math.PI * 2, true);
100
- ctx.strokeStyle = isDarkTheme ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.9)';
101
- ctx.lineWidth = 5;
102
- ctx.stroke();
103
- }
104
- };
105
-
106
106
  return <canvas ref={canvasRef} className={className} width={500} height={500} style={{ width }} />;
107
107
  }
@@ -48,6 +48,7 @@ export const AudioPlayer: React.FC<AudioPlayerProps> = ({
48
48
  const eventBusListenerRef = useRef<{ off: () => void } | null>(null);
49
49
 
50
50
  useEffect(() => {
51
+ // eslint-disable-next-line react-hooks/set-state-in-effect
51
52
  if (audioUrl) setAudioUrl(null);
52
53
  return () => {
53
54
  if (audioUrl) URL.revokeObjectURL(audioUrl);
@@ -170,6 +171,7 @@ export const AudioPlayer: React.FC<AudioPlayerProps> = ({
170
171
  if (!playOnMount || isFetchingAudio) return;
171
172
  isFetchingAudio = true;
172
173
  // console.log("playOnMount", playOnMount);
174
+ // eslint-disable-next-line react-hooks/set-state-in-effect
173
175
  togglePlayback();
174
176
  }, [playOnMount]);
175
177
 
@@ -153,6 +153,7 @@ const InlinePanel = ({
153
153
  useEffect(() => {
154
154
  if (panel === 'link') {
155
155
  const href = editor.getAttributes('link').href as string | undefined;
156
+ // eslint-disable-next-line react-hooks/set-state-in-effect
156
157
  setValue(typeof href === 'string' ? href : 'https://');
157
158
  } else {
158
159
  setValue('');
package/src/index.ts CHANGED
@@ -13,3 +13,4 @@ export { VoiceRecorder } from './components/ai/EmbeddedAssistent/VoiceRecorder';
13
13
  export { MarkdownEditor } from './components/editor/MarkdownEditor';
14
14
  export type { MarkdownEditorProps, EditorLabels } from './components/editor/MarkdownEditor';
15
15
  export { extractImageUrls } from './components/editor/imageUtils';
16
+ export { injectFederationCss } from './utils/injectFederationCss';
@@ -1,4 +1,4 @@
1
- import React, { createContext, useContext, ReactNode, useEffect, useState } from 'react';
1
+ import React, { createContext, useContext, JSX, ReactNode, useEffect, useState } from 'react';
2
2
  import { EventBusHandler, RimoriClient, StandaloneClient } from '@rimori/client';
3
3
  import type { UserInfo } from '@rimori/client';
4
4
  import posthog from 'posthog-js';
@@ -9,14 +9,12 @@ import { Theme } from '@rimori/client';
9
9
  interface PluginProviderProps {
10
10
  children: ReactNode;
11
11
  pluginId: string;
12
+ /** Pre-constructed RimoriClient (federation mode). When provided, skips the handshake entirely. */
13
+ client?: RimoriClient;
14
+ /** Pre-loaded user info (federation mode). Required when client is provided. */
15
+ userInfo?: UserInfo;
12
16
  settings?: {
13
17
  disableContextMenu?: boolean;
14
- disableThemeSetting?: boolean;
15
- /**
16
- * Skip the scrollbar detection that emits 'session.triggerScrollbarChange'.
17
- * In federation mode, the host (FederatedPluginRenderer) handles this instead.
18
- */
19
- disableScrollbarDetection?: boolean;
20
18
  };
21
19
  }
22
20
 
@@ -27,14 +25,21 @@ interface PluginContextValue {
27
25
 
28
26
  const PluginContext = createContext<PluginContextValue | null>(null);
29
27
 
30
- export const PluginProvider: React.FC<PluginProviderProps> = ({ children, pluginId, settings }) => {
31
- const [client, setClient] = useState<RimoriClient | null>(null);
28
+ export const PluginProvider: React.FC<PluginProviderProps> = ({
29
+ children,
30
+ pluginId,
31
+ client: injectedClient,
32
+ userInfo: injectedUserInfo,
33
+ settings,
34
+ }) => {
35
+ const isFederated = !!injectedClient;
36
+ const [client, setClient] = useState<RimoriClient | null>(injectedClient ?? null);
32
37
  const [standaloneClient, setStandaloneClient] = useState<StandaloneClient | boolean>(false);
33
38
  const [applicationMode, setApplicationMode] = useState<string | null>(null);
34
39
  const [theme, setTheme] = useState<Theme | undefined>(undefined);
35
- const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
40
+ const [userInfo, setUserInfo] = useState<UserInfo | null>(injectedUserInfo ?? null);
36
41
 
37
- useTheme(theme, settings?.disableThemeSetting);
42
+ useTheme(theme, isFederated);
38
43
 
39
44
  // Init PostHog once per plugin iframe
40
45
  useEffect(() => {
@@ -57,6 +62,9 @@ export const PluginProvider: React.FC<PluginProviderProps> = ({ children, plugin
57
62
  const isSettings = applicationMode === 'settings';
58
63
 
59
64
  useEffect(() => {
65
+ // In federation mode the client is already ready — skip handshake
66
+ if (isFederated) return;
67
+
60
68
  initEventBus(pluginId);
61
69
 
62
70
  // Check if we're in an iframe context - if not, we're standalone
@@ -71,7 +79,6 @@ export const PluginProvider: React.FC<PluginProviderProps> = ({ children, plugin
71
79
  if ((!standaloneDetected && !client) || (standaloneDetected && standaloneClient === true)) {
72
80
  void RimoriClient.getInstance(pluginId).then((client) => {
73
81
  setClient(client);
74
- // Set initial userInfo
75
82
  setUserInfo(client.plugin.getUserInfo());
76
83
 
77
84
  // Get applicationMode and theme from MessageChannel query params
@@ -82,7 +89,7 @@ export const PluginProvider: React.FC<PluginProviderProps> = ({ children, plugin
82
89
  }
83
90
  });
84
91
  }
85
- }, [pluginId, standaloneClient, client]);
92
+ }, [pluginId, standaloneClient, client, isFederated]);
86
93
 
87
94
  // Identify user in PostHog when userInfo is available
88
95
  useEffect(() => {
@@ -96,7 +103,7 @@ export const PluginProvider: React.FC<PluginProviderProps> = ({ children, plugin
96
103
  if (!client) return;
97
104
 
98
105
  const unsubscribe = client.plugin.onRimoriInfoUpdate((info) => {
99
- console.log('[PluginProvider] Received RimoriInfo update, updating userInfo');
106
+ // console.log('[PluginProvider] Received RimoriInfo update, updating userInfo + ttsEnabled:', info.ttsEnabled);
100
107
  setUserInfo(info.profile);
101
108
  });
102
109
 
@@ -106,6 +113,7 @@ export const PluginProvider: React.FC<PluginProviderProps> = ({ children, plugin
106
113
  useEffect(() => {
107
114
  if (!client) return;
108
115
  if (isSidebar) return; //sidebar pages should not report url changes
116
+ if (isFederated) return; // federation mode: host handles URL sync
109
117
 
110
118
  // react router overwrites native pushstate so it gets wrapped to detect url changes
111
119
  const originalPushState = history.pushState;
@@ -114,11 +122,11 @@ export const PluginProvider: React.FC<PluginProviderProps> = ({ children, plugin
114
122
  client.event.emit('session.triggerUrlChange', { url: location.hash });
115
123
  return result;
116
124
  };
117
- }, [client, isSidebar]);
125
+ }, [client, isSidebar, isFederated]);
118
126
 
119
127
  useEffect(() => {
120
128
  if (!client) return;
121
- if (settings?.disableScrollbarDetection) return;
129
+ if (isFederated) return;
122
130
 
123
131
  const checkScrollbar = (): void => {
124
132
  const hasScrollbar = document.documentElement.scrollHeight > window.innerHeight;
@@ -140,7 +148,7 @@ export const PluginProvider: React.FC<PluginProviderProps> = ({ children, plugin
140
148
  window.removeEventListener('resize', checkScrollbar);
141
149
  resizeObserver.disconnect();
142
150
  };
143
- }, [client, settings?.disableScrollbarDetection]);
151
+ }, [client, isFederated]);
144
152
 
145
153
  if (standaloneClient instanceof StandaloneClient) {
146
154
  return (
@@ -169,9 +177,9 @@ export const useRimori = (): RimoriClient & { userInfo: UserInfo } => {
169
177
  if (context === null) {
170
178
  throw new Error('useRimori must be used within an PluginProvider');
171
179
  }
172
- // Return client with userInfo at root level for easy access
173
- // Maintains backwards compatibility - all client properties are still accessible
174
- return Object.assign(context.client, { userInfo: context.userInfo }) as RimoriClient & { userInfo: UserInfo };
180
+ return Object.assign(context.client, {
181
+ userInfo: context.userInfo,
182
+ }) as RimoriClient & { userInfo: UserInfo };
175
183
  };
176
184
 
177
185
  function getUrlParam(name: string): string | null {
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Inject a plugin's Tailwind CSS into the document when a federated module loads.
3
+ * Inserts BEFORE existing stylesheets so rimori-main's responsive classes (e.g. md:hidden)
4
+ * keep higher cascade priority and are not overridden by base utility classes from the plugin.
5
+ *
6
+ * @param pluginId - Unique plugin identifier used as the <style> element id (e.g. 'rimori-plugin-translator')
7
+ * @param cssText - The inlined CSS string (imported via `?inline`)
8
+ */
9
+ export function injectFederationCss(pluginId: string, cssText: string): void {
10
+ if (typeof document === 'undefined' || !cssText) return;
11
+
12
+ const id = `${pluginId}-css`;
13
+ if (document.getElementById(id)) return;
14
+
15
+ const style = document.createElement('style');
16
+ style.id = id;
17
+ style.textContent = cssText;
18
+
19
+ const firstStyle = document.head.querySelector('style, link[rel="stylesheet"]');
20
+ if (firstStyle) {
21
+ document.head.insertBefore(style, firstStyle);
22
+ } else {
23
+ document.head.appendChild(style);
24
+ }
25
+ }