@rimori/react-client 0.1.1 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1 +1,142 @@
1
- # react-client
1
+ # Rimori React Client
2
+
3
+ The `@rimori/react-client` package contains the React bindings for the Rimori plugin runtime. Wrap your plugin UI with the provided context, use the hooks to talk to the Rimori platform, and drop in prebuilt assistant components without writing your own wiring.
4
+
5
+ ## Table of Contents
6
+ - [Overview](#overview)
7
+ - [Installation](#installation)
8
+ - [When to Use](#when-to-use)
9
+ - [Quick Start](#quick-start)
10
+ - [Hooks](#hooks)
11
+ - [Components](#components)
12
+ - [Styling](#styling)
13
+ - [Standalone Development](#standalone-development)
14
+ - [Additional Exports](#additional-exports)
15
+ - [Example](#example)
16
+
17
+ ## Overview
18
+
19
+ `@rimori/react-client` builds on top of `@rimori/client` and provides:
20
+ - `PluginProvider` that initializes the Rimori runtime, sets up the event bus, and injects the Rimori context into React.
21
+ - React hooks for AI chat, translation, and direct access to the Rimori client instance.
22
+ - Prebuilt components for voice-enabled assistants, avatars, audio playback, and the Rimori context menu.
23
+ - Automatic handling of theme propagation, standalone authentication, and URL tracking inside the Rimori shell.
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ npm install @rimori/react-client @rimori/client react react-dom
29
+ # or
30
+ yarn add @rimori/react-client @rimori/client react react-dom
31
+ ```
32
+
33
+ Both React 18 and `@rimori/client` are peer dependencies. Keep them in sync with the versions used by the Rimori platform.
34
+
35
+ ## When to Use
36
+
37
+ Choose this package whenever your plugin UI is written in React and you need:
38
+ - Access to the `RimoriClient` through idiomatic hooks (`useRimori`).
39
+ - Streamed AI chat experiences without managing the event bus yourself.
40
+ - Drop-in UI for the Rimori assistant, avatars, contextual menus, and audio controls.
41
+ - A translation helper that respects the Rimori i18next pipeline.
42
+
43
+ If you need to interact with the Rimori platform outside of React (workers, CLI, background scripts), keep using `@rimori/client` directly.
44
+
45
+ ## Quick Start
46
+
47
+ ```tsx
48
+ import "@rimori/react-client/dist/style.css";
49
+ import { PluginProvider, useRimori, useChat, useTranslation } from "@rimori/react-client";
50
+
51
+ function Dashboard() {
52
+ const client = useRimori();
53
+ const { t } = useTranslation();
54
+ const { messages, append, isLoading } = useChat();
55
+
56
+ const send = () => {
57
+ append([{ role: "user", content: t("discussion.prompts.askForHelp") }]);
58
+ };
59
+
60
+ return (
61
+ <div>
62
+ <h1>{t("discussion.title")}</h1>
63
+ <button onClick={send} disabled={isLoading}>
64
+ {t("common.buttons.getStarted")}
65
+ </button>
66
+ <pre>{JSON.stringify(messages, null, 2)}</pre>
67
+ <p>{client.plugin.pluginId}</p>
68
+ </div>
69
+ );
70
+ }
71
+
72
+ export function App() {
73
+ return (
74
+ <PluginProvider pluginId="your-plugin-id">
75
+ <Dashboard />
76
+ </PluginProvider>
77
+ );
78
+ }
79
+ ```
80
+
81
+ ## Hooks
82
+
83
+ - `useRimori()` – returns the underlying `RimoriClient` instance (database, AI, community, event bus, translator access, etc.). Throws if used outside `PluginProvider`.
84
+ - `useChat(tools?)` – manages streaming conversations with the Rimori AI. Provides `messages`, `append`, `isLoading`, `setMessages`, and `lastMessage`.
85
+ - `useTranslation()` – returns `{ t, ready }` where `t` is a safe translator bound to the current user language. Use it for every user-visible string, following the Rimori translation key conventions.
86
+
87
+ ## Components
88
+
89
+ All components require being rendered inside `PluginProvider` so they can reach the shared client instance.
90
+
91
+ - `Assistant` – full chat UI shell with streaming responses and optional tool invocation support.
92
+ - `Avatar` – status-aware avatar component that reflects assistant state.
93
+ - `EmbeddedAssistent` utilities – granular building blocks (`CircleAudioAvatar`, `VoiceRecorder`, TTS `Player`, etc.) for composing your own assistant flows.
94
+ - `PlayButton` – accessible audio playback button that integrates with Rimori's audio controller.
95
+ - `ContextMenu` – automatically injected by `PluginProvider` (can be disabled via `settings.disableContextMenu`).
96
+
97
+ ## Styling
98
+
99
+ Import the generated stylesheet once in your app entry point:
100
+
101
+ ```ts
102
+ import "@rimori/react-client/dist/style.css";
103
+ ```
104
+
105
+ The stylesheet contains base styles for the assistant components and ensures they respond to the Rimori theme tokens that are passed through the sandbox handshake.
106
+
107
+ ## Standalone Development
108
+
109
+ `PluginProvider` detects when the plugin is not hosted inside Rimori and automatically spins up `StandaloneClient`. During local development it shows a login overlay that lets you authenticate with your Rimori developer account before establishing the runtime connection.
110
+
111
+ ## Additional Exports
112
+
113
+ - `FirstMessages` – helper preset with common greeting messages for assistant onboarding flows.
114
+
115
+ ## Example
116
+
117
+ ```tsx
118
+ import { PluginProvider, Assistant, useRimori } from "@rimori/react-client";
119
+
120
+ function AssistantPanel() {
121
+ const client = useRimori();
122
+
123
+ return (
124
+ <div className="assistant-panel">
125
+ <Assistant
126
+ placeholderKey="discussion.input.placeholder"
127
+ onMessage={(message) => client.event.emit("self.discussion.onAssistantMessage", { message })}
128
+ />
129
+ </div>
130
+ );
131
+ }
132
+
133
+ export function Root() {
134
+ return (
135
+ <PluginProvider pluginId="your-plugin-id">
136
+ <AssistantPanel />
137
+ </PluginProvider>
138
+ );
139
+ }
140
+ ```
141
+
142
+ Pair this React package with the updated `@rimori/client` README to understand every controller that is available on the returned `RimoriClient` instance.
@@ -1,8 +1 @@
1
- import React from 'react';
2
- interface SpinnerProps {
3
- text?: string;
4
- size?: string;
5
- className?: string;
6
- }
7
- export declare const Spinner: React.FC<SpinnerProps>;
8
1
  export {};
@@ -1,4 +1 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- export const Spinner = ({ text, className, size = '30px' }) => {
3
- return (_jsxs("div", { className: 'flex items-center space-x-2 ' + className, children: [_jsxs("svg", { style: { width: size, height: size }, className: "animate-spin -ml-1 mr-3 h-5 w-5 text-white", xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" })] }), text && _jsx("span", { className: "", children: text })] }));
4
- };
1
+ export {};
@@ -5,10 +5,9 @@ interface Props {
5
5
  agentTools: Tool[];
6
6
  avatarImageUrl: string;
7
7
  circleSize?: string;
8
- isDarkTheme?: boolean;
9
8
  children?: React.ReactNode;
10
9
  autoStartConversation?: FirstMessages;
11
10
  className?: string;
12
11
  }
13
- export declare function Avatar({ avatarImageUrl, voiceId, agentTools, autoStartConversation, children, isDarkTheme, circleSize, className, }: Props): import("react/jsx-runtime").JSX.Element;
12
+ export declare function Avatar({ avatarImageUrl, voiceId, agentTools, autoStartConversation, children, circleSize, className, }: Props): import("react/jsx-runtime").JSX.Element;
14
13
  export {};
@@ -1,17 +1,19 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useState } from 'react';
3
- import { VoiceRecorder } from './EmbeddedAssistent/VoiceRecoder';
3
+ import { VoiceRecorder } from './EmbeddedAssistent/VoiceRecorder';
4
4
  import { MessageSender } from './EmbeddedAssistent/TTS/MessageSender';
5
5
  import { CircleAudioAvatar } from './EmbeddedAssistent/CircleAudioAvatar';
6
6
  import { useChat } from '../../hooks/UseChatHook';
7
7
  import { useRimori } from '../../providers/PluginProvider';
8
8
  import { getFirstMessages } from './utils';
9
- export function Avatar({ avatarImageUrl, voiceId, agentTools, autoStartConversation, children, isDarkTheme = false, circleSize = '300px', className, }) {
9
+ import { isDarkTheme } from '../../hooks/ThemeSetter';
10
+ export function Avatar({ avatarImageUrl, voiceId, agentTools, autoStartConversation, children, circleSize = '300px', className, }) {
10
11
  const { ai, event } = useRimori();
11
12
  const [agentReplying, setAgentReplying] = useState(false);
12
13
  const [isProcessingMessage, setIsProcessingMessage] = useState(false);
13
14
  const sender = useMemo(() => new MessageSender(ai.getVoice, voiceId), [voiceId]);
14
15
  const { messages, append, isLoading, lastMessage, setMessages } = useChat(agentTools);
16
+ const isDarkThemeValue = useMemo(() => isDarkTheme(), []);
15
17
  useEffect(() => {
16
18
  console.log('messages', messages);
17
19
  }, [messages]);
@@ -46,7 +48,7 @@ export function Avatar({ avatarImageUrl, voiceId, agentTools, autoStartConversat
46
48
  }
47
49
  }
48
50
  }, [lastMessage, isLoading]);
49
- return (_jsxs("div", { className: `md:pb-8 ${className || ''}`, children: [_jsx(CircleAudioAvatar, { width: circleSize, className: "mx-auto", imageUrl: avatarImageUrl, isDarkTheme: isDarkTheme }), children, _jsx(VoiceRecorder, { iconSize: "30", className: "w-16 h-16 shadow-lg rounded-full bg-gray-400 dark:bg-gray-800", disabled: agentReplying, loading: isProcessingMessage, enablePushToTalk: true, onVoiceRecorded: (message) => {
51
+ return (_jsxs("div", { className: `md:pb-8 ${className || ''}`, children: [_jsx(CircleAudioAvatar, { width: circleSize, className: "mx-auto", imageUrl: avatarImageUrl, isDarkTheme: isDarkThemeValue }), children, _jsx(VoiceRecorder, { iconSize: "30", className: "w-16 h-16 shadow-lg rounded-full bg-gray-400 dark:bg-gray-800", disabled: agentReplying, loading: isProcessingMessage, enablePushToTalk: true, onVoiceRecorded: (message) => {
50
52
  setAgentReplying(true);
51
53
  append([
52
54
  {
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from 'react';
3
- import { VoiceRecorder } from './VoiceRecoder';
3
+ import { VoiceRecorder } from './VoiceRecorder';
4
4
  import { BiSolidRightArrow } from 'react-icons/bi';
5
5
  import { HiMiniSpeakerXMark, HiMiniSpeakerWave } from 'react-icons/hi2';
6
6
  export function AudioInputField({ onSubmit, onAudioControl, blockSubmission = false }) {
@@ -0,0 +1,11 @@
1
+ interface Props {
2
+ iconSize?: string;
3
+ className?: string;
4
+ disabled?: boolean;
5
+ loading?: boolean;
6
+ enablePushToTalk?: boolean;
7
+ onRecordingStatusChange: (running: boolean) => void;
8
+ onVoiceRecorded: (message: string) => void;
9
+ }
10
+ export declare const VoiceRecorder: import("react").ForwardRefExoticComponent<Props & import("react").RefAttributes<unknown>>;
11
+ export {};
@@ -0,0 +1,95 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
10
+ import { jsx as _jsx } from "react/jsx-runtime";
11
+ import { useRimori } from '../../../providers/PluginProvider';
12
+ import { FaMicrophone, FaSpinner } from 'react-icons/fa6';
13
+ import { AudioController } from '@rimori/client';
14
+ import { useState, useRef, forwardRef, useImperativeHandle, useEffect } from 'react';
15
+ export const VoiceRecorder = forwardRef(({ onVoiceRecorded, iconSize, className, disabled, loading, onRecordingStatusChange, enablePushToTalk = false, }, ref) => {
16
+ const [isRecording, setIsRecording] = useState(false);
17
+ const [internalIsProcessing, setInternalIsProcessing] = useState(false);
18
+ const audioControllerRef = useRef(null);
19
+ const { ai, plugin } = useRimori();
20
+ // Ref for latest onVoiceRecorded callback
21
+ const onVoiceRecordedRef = useRef(onVoiceRecorded);
22
+ useEffect(() => {
23
+ onVoiceRecordedRef.current = onVoiceRecorded;
24
+ }, [onVoiceRecorded]);
25
+ const startRecording = () => __awaiter(void 0, void 0, void 0, function* () {
26
+ try {
27
+ if (!audioControllerRef.current) {
28
+ audioControllerRef.current = new AudioController(plugin.pluginId);
29
+ }
30
+ yield audioControllerRef.current.startRecording();
31
+ setIsRecording(true);
32
+ onRecordingStatusChange(true);
33
+ }
34
+ catch (error) {
35
+ console.error('Failed to start recording:', error);
36
+ // Handle permission denied or other errors
37
+ }
38
+ });
39
+ const stopRecording = () => __awaiter(void 0, void 0, void 0, function* () {
40
+ try {
41
+ if (audioControllerRef.current && isRecording) {
42
+ const audioResult = yield audioControllerRef.current.stopRecording();
43
+ // console.log("audioResult: ", audioResult);
44
+ setInternalIsProcessing(true);
45
+ // Play the recorded audio from the Blob
46
+ // const blobUrl = URL.createObjectURL(audioResult.recording);
47
+ // const audioRef = new Audio(blobUrl);
48
+ // audioRef.onended = () => URL.revokeObjectURL(blobUrl);
49
+ // audioRef.play().catch((e) => console.error('Playback error:', e));
50
+ // console.log("audioBlob: ", audioResult.recording);
51
+ const text = yield ai.getTextFromVoice(audioResult.recording);
52
+ // console.log("stt result", text);
53
+ // throw new Error("test");
54
+ setInternalIsProcessing(false);
55
+ onVoiceRecordedRef.current(text);
56
+ }
57
+ }
58
+ catch (error) {
59
+ console.error('Failed to stop recording:', error);
60
+ }
61
+ finally {
62
+ setIsRecording(false);
63
+ onRecordingStatusChange(false);
64
+ }
65
+ });
66
+ useImperativeHandle(ref, () => ({
67
+ startRecording,
68
+ stopRecording,
69
+ }));
70
+ // push to talk feature
71
+ const spacePressedRef = useRef(false);
72
+ useEffect(() => {
73
+ if (!enablePushToTalk)
74
+ return;
75
+ const handleKeyDown = (event) => __awaiter(void 0, void 0, void 0, function* () {
76
+ if (event.code === 'Space' && !spacePressedRef.current) {
77
+ spacePressedRef.current = true;
78
+ yield startRecording();
79
+ }
80
+ });
81
+ const handleKeyUp = (event) => {
82
+ if (event.code === 'Space' && spacePressedRef.current) {
83
+ spacePressedRef.current = false;
84
+ stopRecording();
85
+ }
86
+ };
87
+ window.addEventListener('keydown', handleKeyDown);
88
+ window.addEventListener('keyup', handleKeyUp);
89
+ return () => {
90
+ window.removeEventListener('keydown', handleKeyDown);
91
+ window.removeEventListener('keyup', handleKeyUp);
92
+ };
93
+ }, [enablePushToTalk]);
94
+ return (_jsx("button", { className: 'flex flex-row justify-center items-center rounded-full mx-auto disabled:opacity-50 ' + className, onClick: isRecording ? stopRecording : startRecording, disabled: disabled || loading || internalIsProcessing, children: loading || internalIsProcessing ? (_jsx(FaSpinner, { className: "animate-spin" })) : (_jsx(FaMicrophone, { size: iconSize, className: isRecording ? 'text-red-600' : '' })) }));
95
+ });
@@ -11,7 +11,6 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
11
11
  import { useState, useEffect } from 'react';
12
12
  import { FaPlayCircle, FaStopCircle } from 'react-icons/fa';
13
13
  import { useRimori } from '../../providers/PluginProvider';
14
- import { Spinner } from '../Spinner';
15
14
  import { EventBus } from '@rimori/client';
16
15
  export const AudioPlayOptions = [0.8, 0.9, 1.0, 1.1, 1.2, 1.5];
17
16
  let isFetchingAudio = false;
@@ -80,3 +79,6 @@ export const AudioPlayer = ({ text, voice, language, hide, playListenerEvent, in
80
79
  }, [playOnMount]);
81
80
  return (_jsx("div", { className: "group relative", children: _jsxs("div", { className: "flex flex-row items-end", children: [!hide && (_jsx("button", { className: "text-gray-500", onClick: togglePlayback, disabled: isLoading, children: isLoading ? _jsx(Spinner, {}) : isPlaying ? _jsx(FaStopCircle, { size: '25px' }) : _jsx(FaPlayCircle, { size: '25px' }) })), 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))) })] }))] }) }));
82
81
  };
82
+ const Spinner = ({ text, className, size = '30px' }) => {
83
+ return (_jsxs("div", { className: 'flex items-center space-x-2 ' + className, children: [_jsxs("svg", { style: { width: size, height: size }, className: "animate-spin -ml-1 mr-3 h-5 w-5 text-white", xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" })] }), text && _jsx("span", { className: "", children: text })] }));
84
+ };
@@ -0,0 +1,2 @@
1
+ export declare function useTheme(theme?: string | null): boolean;
2
+ export declare function isDarkTheme(theme?: string | null): boolean;
@@ -0,0 +1,31 @@
1
+ import { useEffect, useState } from 'react';
2
+ export function useTheme(theme) {
3
+ const [isDark, setIsDark] = useState(false);
4
+ useEffect(() => {
5
+ const root = document.documentElement;
6
+ const nextIsDark = isDarkTheme(theme);
7
+ setIsDark(nextIsDark);
8
+ root.classList.add('dark:text-gray-200');
9
+ if (nextIsDark) {
10
+ root.setAttribute('data-theme', 'dark');
11
+ root.classList.add('dark', 'dark:bg-gray-950');
12
+ root.style.background = 'hsl(var(--background))';
13
+ return;
14
+ }
15
+ root.removeAttribute('data-theme');
16
+ root.classList.remove('dark', 'dark:bg-gray-950');
17
+ root.style.background = '';
18
+ }, [theme]);
19
+ return isDark;
20
+ }
21
+ export function isDarkTheme(theme) {
22
+ // If no theme provided, try to get from URL as fallback (for standalone mode)
23
+ if (!theme) {
24
+ const urlParams = new URLSearchParams(window.location.search);
25
+ theme = urlParams.get('theme');
26
+ }
27
+ if (!theme || theme === 'system') {
28
+ return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
29
+ }
30
+ return theme === 'dark';
31
+ }
package/dist/index.d.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  export * from './hooks/UseChatHook';
2
2
  export * from './providers/PluginProvider';
3
- export * from './utils/FullscreenUtils';
4
3
  export * from './components/audio/Playbutton';
5
4
  export * from './components/ai/EmbeddedAssistent/TTS/Player';
6
- export * from './components/ContextMenu';
7
- export * from './components/MarkdownEditor';
8
- export * from './components/Spinner';
5
+ export * from './components/ai/Avatar';
9
6
  export { FirstMessages } from './components/ai/utils';
10
7
  export { useTranslation } from './hooks/I18nHooks';
8
+ export { Avatar } from './components/ai/Avatar';
9
+ export { VoiceRecorder } from './components/ai/EmbeddedAssistent/VoiceRecorder';
package/dist/index.js CHANGED
@@ -1,10 +1,9 @@
1
1
  // Re-export everything
2
2
  export * from './hooks/UseChatHook';
3
3
  export * from './providers/PluginProvider';
4
- export * from './utils/FullscreenUtils';
5
4
  export * from './components/audio/Playbutton';
6
5
  export * from './components/ai/EmbeddedAssistent/TTS/Player';
7
- export * from './components/ContextMenu';
8
- export * from './components/MarkdownEditor';
9
- export * from './components/Spinner';
6
+ export * from './components/ai/Avatar';
10
7
  export { useTranslation } from './hooks/I18nHooks';
8
+ export { Avatar } from './components/ai/Avatar';
9
+ export { VoiceRecorder } from './components/ai/EmbeddedAssistent/VoiceRecorder';
@@ -1,2 +1,2 @@
1
- export declare function setTheme(theme?: string | null): void;
1
+ export declare function useTheme(theme?: string | null): boolean;
2
2
  export declare function isDarkTheme(theme?: string | null): boolean;
@@ -1,10 +1,22 @@
1
- export function setTheme(theme) {
2
- document.documentElement.classList.add('dark:text-gray-200');
3
- if (isDarkTheme(theme)) {
4
- document.documentElement.setAttribute('data-theme', 'dark');
5
- document.documentElement.classList.add('dark', 'dark:bg-gray-950');
6
- document.documentElement.style.background = 'hsl(var(--background))';
7
- }
1
+ import { useEffect, useState } from 'react';
2
+ export function useTheme(theme) {
3
+ const [isDark, setIsDark] = useState(false);
4
+ useEffect(() => {
5
+ const root = document.documentElement;
6
+ const nextIsDark = isDarkTheme(theme);
7
+ setIsDark(nextIsDark);
8
+ root.classList.add('dark:text-gray-200');
9
+ if (nextIsDark) {
10
+ root.setAttribute('data-theme', 'dark');
11
+ root.classList.add('dark', 'dark:bg-gray-950');
12
+ root.style.background = 'hsl(var(--background))';
13
+ return;
14
+ }
15
+ root.removeAttribute('data-theme');
16
+ root.classList.remove('dark', 'dark:bg-gray-950');
17
+ root.style.background = '';
18
+ }, [theme]);
19
+ return isDark;
8
20
  }
9
21
  export function isDarkTheme(theme) {
10
22
  // If no theme provided, try to get from URL as fallback (for standalone mode)
@@ -11,12 +11,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
11
11
  import { createContext, useContext, useEffect, useState } from 'react';
12
12
  import { EventBusHandler, RimoriClient, StandaloneClient } from '@rimori/client';
13
13
  import ContextMenu from '../components/ContextMenu';
14
- import { setTheme } from '../plugin/ThemeSetter';
14
+ import { useTheme } from '../hooks/ThemeSetter';
15
15
  const PluginContext = createContext(null);
16
16
  export const PluginProvider = ({ children, pluginId, settings }) => {
17
17
  const [plugin, setPlugin] = useState(null);
18
18
  const [standaloneClient, setStandaloneClient] = useState(false);
19
19
  const [applicationMode, setApplicationMode] = useState(null);
20
+ const [theme, setTheme] = useState(null);
21
+ useTheme(theme);
20
22
  const isSidebar = applicationMode === 'sidebar';
21
23
  const isSettings = applicationMode === 'settings';
22
24
  useEffect(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rimori/react-client",
3
- "version": "0.1.1",
3
+ "version": "0.2.7",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -20,18 +20,15 @@
20
20
  "peerDependencies": {
21
21
  "react": "^18.0.0",
22
22
  "react-dom": "^18.0.0",
23
- "@rimori/client": "^2.0.0"
23
+ "@rimori/client": "^2.1.7"
24
24
  },
25
25
  "dependencies": {
26
- "@tiptap/react": "2.10.3",
27
- "@tiptap/starter-kit": "2.10.3",
28
26
  "html2canvas": "1.4.1",
29
- "react-icons": "5.4.0",
30
- "tiptap-markdown": "0.8.10"
27
+ "react-icons": "5.4.0"
31
28
  },
32
29
  "devDependencies": {
33
30
  "@eslint/js": "^9.37.0",
34
- "@rimori/client": "^2.0.0",
31
+ "@rimori/client": "^2.1.7",
35
32
  "eslint-config-prettier": "^10.1.8",
36
33
  "eslint-plugin-prettier": "^5.5.4",
37
34
  "eslint-plugin-react-hooks": "^7.0.0",
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useMemo, useState } from 'react';
2
- import { VoiceRecorder } from './EmbeddedAssistent/VoiceRecoder';
2
+ import { VoiceRecorder } from './EmbeddedAssistent/VoiceRecorder';
3
3
  import { MessageSender } from './EmbeddedAssistent/TTS/MessageSender';
4
4
  import { CircleAudioAvatar } from './EmbeddedAssistent/CircleAudioAvatar';
5
5
  import { Tool } from '@rimori/client';
@@ -7,13 +7,13 @@ import { useChat } from '../../hooks/UseChatHook';
7
7
  import { useRimori } from '../../providers/PluginProvider';
8
8
  import { getFirstMessages } from './utils';
9
9
  import { FirstMessages } from './utils';
10
+ import { isDarkTheme } from '../../hooks/ThemeSetter';
10
11
 
11
12
  interface Props {
12
13
  voiceId: string;
13
14
  agentTools: Tool[];
14
15
  avatarImageUrl: string;
15
16
  circleSize?: string;
16
- isDarkTheme?: boolean;
17
17
  children?: React.ReactNode;
18
18
  autoStartConversation?: FirstMessages;
19
19
  className?: string;
@@ -25,7 +25,6 @@ export function Avatar({
25
25
  agentTools,
26
26
  autoStartConversation,
27
27
  children,
28
- isDarkTheme = false,
29
28
  circleSize = '300px',
30
29
  className,
31
30
  }: Props) {
@@ -34,6 +33,7 @@ export function Avatar({
34
33
  const [isProcessingMessage, setIsProcessingMessage] = useState(false);
35
34
  const sender = useMemo(() => new MessageSender(ai.getVoice, voiceId), [voiceId]);
36
35
  const { messages, append, isLoading, lastMessage, setMessages } = useChat(agentTools);
36
+ const isDarkThemeValue = useMemo(() => isDarkTheme(), []);
37
37
 
38
38
  useEffect(() => {
39
39
  console.log('messages', messages);
@@ -74,7 +74,12 @@ export function Avatar({
74
74
 
75
75
  return (
76
76
  <div className={`md:pb-8 ${className || ''}`}>
77
- <CircleAudioAvatar width={circleSize} className="mx-auto" imageUrl={avatarImageUrl} isDarkTheme={isDarkTheme} />
77
+ <CircleAudioAvatar
78
+ width={circleSize}
79
+ className="mx-auto"
80
+ imageUrl={avatarImageUrl}
81
+ isDarkTheme={isDarkThemeValue}
82
+ />
78
83
  {children}
79
84
  <VoiceRecorder
80
85
  iconSize="30"
@@ -1,5 +1,5 @@
1
1
  import React, { useState } from 'react';
2
- import { VoiceRecorder } from './VoiceRecoder';
2
+ import { VoiceRecorder } from './VoiceRecorder';
3
3
  import { BiSolidRightArrow } from 'react-icons/bi';
4
4
  import { HiMiniSpeakerXMark, HiMiniSpeakerWave } from 'react-icons/hi2';
5
5
 
@@ -1,7 +1,6 @@
1
1
  import React, { useState, useEffect } from 'react';
2
2
  import { FaPlayCircle, FaStopCircle } from 'react-icons/fa';
3
3
  import { useRimori } from '../../providers/PluginProvider';
4
- import { Spinner } from '../Spinner';
5
4
  import { EventBus } from '@rimori/client';
6
5
 
7
6
  type AudioPlayerProps = {
@@ -124,3 +123,31 @@ export const AudioPlayer: React.FC<AudioPlayerProps> = ({
124
123
  </div>
125
124
  );
126
125
  };
126
+
127
+ interface SpinnerProps {
128
+ text?: string;
129
+ size?: string;
130
+ className?: string;
131
+ }
132
+
133
+ const Spinner = ({ text, className, size = '30px' }: SpinnerProps) => {
134
+ return (
135
+ <div className={'flex items-center space-x-2 ' + className}>
136
+ <svg
137
+ style={{ width: size, height: size }}
138
+ className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
139
+ xmlns="http://www.w3.org/2000/svg"
140
+ fill="none"
141
+ viewBox="0 0 24 24"
142
+ >
143
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
144
+ <path
145
+ className="opacity-75"
146
+ fill="currentColor"
147
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
148
+ ></path>
149
+ </svg>
150
+ {text && <span className="">{text}</span>}
151
+ </div>
152
+ );
153
+ };
@@ -0,0 +1,40 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ export function useTheme(theme?: string | null): boolean {
4
+ const [isDark, setIsDark] = useState(false);
5
+
6
+ useEffect(() => {
7
+ const root = document.documentElement;
8
+ const nextIsDark = isDarkTheme(theme);
9
+
10
+ setIsDark(nextIsDark);
11
+ root.classList.add('dark:text-gray-200');
12
+
13
+ if (nextIsDark) {
14
+ root.setAttribute('data-theme', 'dark');
15
+ root.classList.add('dark', 'dark:bg-gray-950');
16
+ root.style.background = 'hsl(var(--background))';
17
+ return;
18
+ }
19
+
20
+ root.removeAttribute('data-theme');
21
+ root.classList.remove('dark', 'dark:bg-gray-950');
22
+ root.style.background = '';
23
+ }, [theme]);
24
+
25
+ return isDark;
26
+ }
27
+
28
+ export function isDarkTheme(theme?: string | null): boolean {
29
+ // If no theme provided, try to get from URL as fallback (for standalone mode)
30
+ if (!theme) {
31
+ const urlParams = new URLSearchParams(window.location.search);
32
+ theme = urlParams.get('theme');
33
+ }
34
+
35
+ if (!theme || theme === 'system') {
36
+ return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
37
+ }
38
+
39
+ return theme === 'dark';
40
+ }
package/src/index.ts CHANGED
@@ -1,11 +1,10 @@
1
1
  // Re-export everything
2
2
  export * from './hooks/UseChatHook';
3
3
  export * from './providers/PluginProvider';
4
- export * from './utils/FullscreenUtils';
5
4
  export * from './components/audio/Playbutton';
6
5
  export * from './components/ai/EmbeddedAssistent/TTS/Player';
7
- export * from './components/ContextMenu';
8
- export * from './components/MarkdownEditor';
9
- export * from './components/Spinner';
6
+ export * from './components/ai/Avatar';
10
7
  export { FirstMessages } from './components/ai/utils';
11
8
  export { useTranslation } from './hooks/I18nHooks';
9
+ export { Avatar } from './components/ai/Avatar';
10
+ export { VoiceRecorder } from './components/ai/EmbeddedAssistent/VoiceRecorder';
@@ -1,7 +1,7 @@
1
1
  import React, { createContext, useContext, ReactNode, useEffect, useState } from 'react';
2
2
  import { EventBusHandler, RimoriClient, StandaloneClient } from '@rimori/client';
3
3
  import ContextMenu from '../components/ContextMenu';
4
- import { setTheme } from '../plugin/ThemeSetter';
4
+ import { useTheme } from '../hooks/ThemeSetter';
5
5
 
6
6
  interface PluginProviderProps {
7
7
  children: ReactNode;
@@ -17,6 +17,9 @@ export const PluginProvider: React.FC<PluginProviderProps> = ({ children, plugin
17
17
  const [plugin, setPlugin] = useState<RimoriClient | null>(null);
18
18
  const [standaloneClient, setStandaloneClient] = useState<StandaloneClient | boolean>(false);
19
19
  const [applicationMode, setApplicationMode] = useState<string | null>(null);
20
+ const [theme, setTheme] = useState<string | null>(null);
21
+
22
+ useTheme(theme);
20
23
 
21
24
  const isSidebar = applicationMode === 'sidebar';
22
25
  const isSettings = applicationMode === 'settings';
@@ -1,144 +0,0 @@
1
- import { Markdown } from 'tiptap-markdown';
2
- import StarterKit from '@tiptap/starter-kit';
3
- import { PiCodeBlock } from 'react-icons/pi';
4
- import { TbBlockquote } from 'react-icons/tb';
5
- import { GoListOrdered } from 'react-icons/go';
6
- import { AiOutlineUnorderedList } from 'react-icons/ai';
7
- import { EditorProvider, useCurrentEditor } from '@tiptap/react';
8
- import { LuHeading1, LuHeading2, LuHeading3 } from 'react-icons/lu';
9
- import { FaBold, FaCode, FaItalic, FaParagraph, FaStrikethrough } from 'react-icons/fa';
10
-
11
- // This inplementation is rooted in the Tiptap editor basic example https://codesandbox.io/p/devbox/editor-9x9dkd
12
-
13
- interface EditorButtonProps {
14
- action: string;
15
- isActive?: boolean;
16
- label: string | React.ReactNode;
17
- disabled?: boolean;
18
- }
19
-
20
- const EditorButton = ({ action, isActive, label, disabled }: EditorButtonProps) => {
21
- const { editor } = useCurrentEditor() as any;
22
-
23
- if (!editor) {
24
- return null;
25
- }
26
-
27
- if (action.includes('heading')) {
28
- const level = parseInt(action[action.length - 1]);
29
- return (
30
- <button
31
- onClick={() => editor.chain().focus().toggleHeading({ level: level }).run()}
32
- className={`pl-2 ${isActive ? 'is-active' : ''}`}
33
- >
34
- {label}
35
- </button>
36
- );
37
- }
38
-
39
- return (
40
- <button
41
- onClick={() => editor.chain().focus()[action]().run()}
42
- disabled={disabled ? !editor.can().chain().focus()[action]().run() : false}
43
- className={`pl-2 ${isActive ? 'is-active' : ''}`}
44
- >
45
- {label}
46
- </button>
47
- );
48
- };
49
-
50
- const MenuBar = () => {
51
- const { editor } = useCurrentEditor();
52
-
53
- if (!editor) {
54
- return null;
55
- }
56
-
57
- return (
58
- <div className="bg-gray-400 dark:bg-gray-800 dark:text-white text-lg flex flex-row flex-wrap items-center p-1">
59
- <EditorButton action="toggleBold" isActive={editor.isActive('bold')} label={<FaBold />} disabled />
60
- <EditorButton action="toggleItalic" isActive={editor.isActive('italic')} label={<FaItalic />} disabled />
61
- <EditorButton action="toggleStrike" isActive={editor.isActive('strike')} label={<FaStrikethrough />} disabled />
62
- <EditorButton action="toggleCode" isActive={editor.isActive('code')} label={<FaCode />} disabled />
63
- <EditorButton action="setParagraph" isActive={editor.isActive('paragraph')} label={<FaParagraph />} />
64
- <EditorButton
65
- action="setHeading1"
66
- isActive={editor.isActive('heading', { level: 1 })}
67
- label={<LuHeading1 size={'24px'} />}
68
- />
69
- <EditorButton
70
- action="setHeading2"
71
- isActive={editor.isActive('heading', { level: 2 })}
72
- label={<LuHeading2 size={'24px'} />}
73
- />
74
- <EditorButton
75
- action="setHeading3"
76
- isActive={editor.isActive('heading', { level: 3 })}
77
- label={<LuHeading3 size={'24px'} />}
78
- />
79
- <EditorButton
80
- action="toggleBulletList"
81
- isActive={editor.isActive('bulletList')}
82
- label={<AiOutlineUnorderedList size={'24px'} />}
83
- />
84
- <EditorButton
85
- action="toggleOrderedList"
86
- isActive={editor.isActive('orderedList')}
87
- label={<GoListOrdered size={'24px'} />}
88
- />
89
- <EditorButton
90
- action="toggleCodeBlock"
91
- isActive={editor.isActive('codeBlock')}
92
- label={<PiCodeBlock size={'24px'} />}
93
- />
94
- <EditorButton
95
- action="toggleBlockquote"
96
- isActive={editor.isActive('blockquote')}
97
- label={<TbBlockquote size={'24px'} />}
98
- />
99
- </div>
100
- );
101
- };
102
-
103
- const extensions = [
104
- StarterKit.configure({
105
- bulletList: {
106
- HTMLAttributes: {
107
- class: 'list-disc list-inside dark:text-white p-1 mt-1 [&_li]:mb-1 [&_p]:inline m-0',
108
- },
109
- },
110
- orderedList: {
111
- HTMLAttributes: {
112
- className: 'list-decimal list-inside dark:text-white p-1 mt-1 [&_li]:mb-1 [&_p]:inline m-0',
113
- },
114
- },
115
- }),
116
- Markdown,
117
- ];
118
-
119
- interface Props {
120
- content?: string;
121
- editable: boolean;
122
- className?: string;
123
- onUpdate?: (content: string) => void;
124
- }
125
-
126
- export const MarkdownEditor = (props: Props) => {
127
- return (
128
- <div
129
- className={'text-md border border-gray-800 overflow-hidden ' + props.className}
130
- style={{ borderWidth: props.editable ? 1 : 0 }}
131
- >
132
- <EditorProvider
133
- key={(props.editable ? 'editable' : 'readonly') + props.content}
134
- slotBefore={props.editable ? <MenuBar /> : null}
135
- extensions={extensions}
136
- content={props.content}
137
- editable={props.editable}
138
- onUpdate={(e) => {
139
- props.onUpdate && props.onUpdate(e.editor.storage.markdown.getMarkdown());
140
- }}
141
- ></EditorProvider>
142
- </div>
143
- );
144
- };
@@ -1,29 +0,0 @@
1
- import React from 'react';
2
-
3
- interface SpinnerProps {
4
- text?: string;
5
- size?: string;
6
- className?: string;
7
- }
8
-
9
- export const Spinner: React.FC<SpinnerProps> = ({ text, className, size = '30px' }) => {
10
- return (
11
- <div className={'flex items-center space-x-2 ' + className}>
12
- <svg
13
- style={{ width: size, height: size }}
14
- className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
15
- xmlns="http://www.w3.org/2000/svg"
16
- fill="none"
17
- viewBox="0 0 24 24"
18
- >
19
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
20
- <path
21
- className="opacity-75"
22
- fill="currentColor"
23
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
24
- ></path>
25
- </svg>
26
- {text && <span className="">{text}</span>}
27
- </div>
28
- );
29
- };
@@ -1,23 +0,0 @@
1
- export function setTheme(theme?: string | null): void {
2
- document.documentElement.classList.add('dark:text-gray-200');
3
-
4
- if (isDarkTheme(theme)) {
5
- document.documentElement.setAttribute('data-theme', 'dark');
6
- document.documentElement.classList.add('dark', 'dark:bg-gray-950');
7
- document.documentElement.style.background = 'hsl(var(--background))';
8
- }
9
- }
10
-
11
- export function isDarkTheme(theme?: string | null): boolean {
12
- // If no theme provided, try to get from URL as fallback (for standalone mode)
13
- if (!theme) {
14
- const urlParams = new URLSearchParams(window.location.search);
15
- theme = urlParams.get('theme');
16
- }
17
-
18
- if (!theme || theme === 'system') {
19
- return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
20
- }
21
-
22
- return theme === 'dark';
23
- }
@@ -1,22 +0,0 @@
1
- export function isFullscreen(): boolean {
2
- return !!document.fullscreenElement;
3
- }
4
-
5
- export function triggerFullscreen(onStateChange: (isFullscreen: boolean) => void, selector?: string): void {
6
- document.addEventListener('fullscreenchange', () => {
7
- onStateChange(isFullscreen());
8
- });
9
- try {
10
- const ref = document.querySelector(selector || '#root') as HTMLElement;
11
- if (!isFullscreen()) {
12
- // @ts-ignore
13
- void (ref.requestFullscreen() || ref.webkitRequestFullscreen());
14
- } else {
15
- // @ts-ignore
16
- void (document.exitFullscreen() || document.webkitExitFullscreen());
17
- }
18
- } catch (error: any) {
19
- console.error('Failed to enter fullscreen', error.message);
20
- }
21
- onStateChange(isFullscreen());
22
- }