@rimori/react-client 0.4.17 → 0.4.18-next.1

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.
@@ -4,7 +4,6 @@ import { VoiceRecorder } from './EmbeddedAssistent/VoiceRecorder';
4
4
  import { MessageSender, Tool } from '@rimori/client';
5
5
  import { useRimori } from '../../providers/PluginProvider';
6
6
  import { useTheme } from '../../hooks/ThemeSetter';
7
- import { HiMiniSpeakerWave, HiMiniSpeakerXMark } from 'react-icons/hi2';
8
7
  import { BiSolidRightArrow } from 'react-icons/bi';
9
8
 
10
9
  type ChatMessage = { id: string; role: 'user' | 'assistant'; content: string };
@@ -17,7 +16,10 @@ export interface BuddyAssistantAutoStart {
17
16
  }
18
17
 
19
18
  export interface BuddyAssistantProps {
20
- systemPrompt: string;
19
+ /** Server-side prompt name (e.g. 'studyplan.goalSummary'). The backend resolves the system prompt. */
20
+ prompt: string;
21
+ /** Variables for the server-side prompt template. */
22
+ promptVariables?: Record<string, any>;
21
23
  autoStartConversation?: BuddyAssistantAutoStart;
22
24
  circleSize?: string;
23
25
  chatPlaceholder?: string;
@@ -25,15 +27,16 @@ export interface BuddyAssistantProps {
25
27
  className?: string;
26
28
  voiceSpeed?: number;
27
29
  tools?: Tool[];
28
- /** Set to true to disable automatic dialect from userInfo. Default: false (dialect enabled). */
29
- disableDialect?: boolean;
30
+ /** Show the buddy name below the avatar. Default: false. */
31
+ showName?: boolean;
30
32
  }
31
33
 
32
34
  let idCounter = 0;
33
35
  const genId = () => `ba-${++idCounter}`;
34
36
 
35
37
  export function BuddyAssistant({
36
- systemPrompt,
38
+ prompt,
39
+ promptVariables,
37
40
  autoStartConversation,
38
41
  circleSize = '160px',
39
42
  chatPlaceholder,
@@ -41,18 +44,14 @@ export function BuddyAssistant({
41
44
  className,
42
45
  voiceSpeed = 1,
43
46
  tools,
44
- disableDialect = false,
47
+ showName = false,
45
48
  }: BuddyAssistantProps): JSX.Element {
46
49
  const { ai, event, plugin, userInfo } = useRimori();
47
- const { isDark } = useTheme(plugin.theme);
48
- const buddy = plugin.getUserInfo()?.study_buddy;
49
- const dialect = !disableDialect ? userInfo?.dialect : undefined;
50
- const dialectSystemSuffix = dialect
51
- ? `\n\nThe user is learning the regional ${dialect} dialect. Occasionally use typical regional vocabulary and expressions from this dialect to help them learn local language naturally.`
52
- : '';
50
+ const ttsEnabled = plugin.ttsEnabled;
51
+ const { isDark } = useTheme(plugin.theme, true);
52
+ const buddy = userInfo.study_buddy;
53
+ const dialect = userInfo?.dialect;
53
54
  const dialectTtsInstruction = dialect ? `Speak with a ${dialect} accent and pronunciation.` : undefined;
54
-
55
- const [ttsEnabled, setTtsEnabled] = useState(true);
56
55
  const [chatInput, setChatInput] = useState('');
57
56
  const [messages, setMessages] = useState<ChatMessage[]>([]);
58
57
  const [isLoading, setIsLoading] = useState(false);
@@ -61,6 +60,11 @@ export function BuddyAssistant({
61
60
  const ttsEnabledRef = useRef(ttsEnabled);
62
61
  useEffect(() => {
63
62
  ttsEnabledRef.current = ttsEnabled;
63
+ // Stop speaking when TTS is turned off globally
64
+ if (!ttsEnabled && isSpeaking) {
65
+ sender.stop();
66
+ setIsSpeaking(false);
67
+ }
64
68
  }, [ttsEnabled]);
65
69
 
66
70
  const sender = useMemo(
@@ -80,17 +84,15 @@ export function BuddyAssistant({
80
84
  return () => sender.cleanup();
81
85
  }, [sender]);
82
86
 
83
- // Build full API message list with system prompt (dialect appended when enabled)
84
- const buildApiMessages = (history: ChatMessage[]) => [
85
- { role: 'system' as const, content: systemPrompt + dialectSystemSuffix },
86
- ...history.map((m) => ({ role: m.role, content: m.content })),
87
- ];
87
+ const buildApiMessages = (history: ChatMessage[]) => {
88
+ return history.map((m) => ({ role: m.role, content: m.content }));
89
+ };
88
90
 
89
91
  const triggerAI = (history: ChatMessage[]) => {
90
92
  setIsLoading(true);
91
- void ai.getSteamedText(
92
- buildApiMessages(history),
93
- (id: string, partial: string, finished: boolean) => {
93
+ void ai.getStreamedText({
94
+ messages: buildApiMessages(history),
95
+ onMessage: (id: string, partial: string, finished: boolean) => {
94
96
  setIsLoading(!finished);
95
97
  const assistantId = `ai-${id}`;
96
98
  setMessages((prev) => {
@@ -106,7 +108,9 @@ export function BuddyAssistant({
106
108
  }
107
109
  },
108
110
  tools,
109
- );
111
+ prompt,
112
+ variables: promptVariables,
113
+ });
110
114
  };
111
115
 
112
116
  // Auto-start conversation on mount
@@ -140,14 +144,6 @@ export function BuddyAssistant({
140
144
  triggerAI(newMessages);
141
145
  };
142
146
 
143
- const handleToggleTts = () => {
144
- if (ttsEnabled && isSpeaking) {
145
- sender.stop();
146
- setIsSpeaking(false);
147
- }
148
- setTtsEnabled((prev) => !prev);
149
- };
150
-
151
147
  const lastAssistantMessage = [...messages].filter((m) => m.role === 'assistant').pop();
152
148
 
153
149
  return (
@@ -155,26 +151,15 @@ export function BuddyAssistant({
155
151
  {/* Animated circle avatar */}
156
152
  <CircleAudioAvatar width={circleSize} imageUrl={buddy.avatarUrl} isDarkTheme={isDark} className="mx-auto" />
157
153
 
158
- {/* Buddy name + TTS toggle */}
159
- <div className="flex items-center gap-2 pl-10">
160
- <span className="text-3xl font-semibold">{buddy.name}</span>
161
- <button
162
- type="button"
163
- onClick={handleToggleTts}
164
- className="p-1 rounded-md hover:bg-gray-700/50 transition-colors"
165
- title={ttsEnabled ? 'Disable voice' : 'Enable voice'}
166
- >
167
- {ttsEnabled ? (
168
- <HiMiniSpeakerWave className={`w-5 h-5 mt-0.5 ${isSpeaking ? 'text-blue-400' : 'text-gray-300'}`} />
169
- ) : (
170
- <HiMiniSpeakerXMark className="w-5 h-5 mt-0.5 text-gray-500" />
171
- )}
172
- </button>
173
- </div>
154
+ {/* Buddy name (hidden by default) */}
155
+ {showName && (
156
+ <div className="flex items-center gap-2">
157
+ <span className="text-3xl font-semibold">{buddy.name}</span>
158
+ </div>
159
+ )}
174
160
 
175
- {/* Last buddy message card only shown when TTS is disabled */}
176
- {!ttsEnabled && (
177
- <div className="w-full max-w-md rounded-xl bg-gray-800/70 px-4 py-3 text-sm text-gray-200 leading-relaxed border border-gray-700/40 mt-4">
161
+ {!plugin.ttsEnabled && messages.length > 1 ? (
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">
178
163
  {!lastAssistantMessage?.content && isLoading ? (
179
164
  <span className="inline-flex gap-1 py-0.5">
180
165
  <span className="w-1.5 h-1.5 rounded-full bg-gray-400 animate-bounce" />
@@ -191,10 +176,10 @@ export function BuddyAssistant({
191
176
  <span className="whitespace-pre-wrap">{lastAssistantMessage?.content}</span>
192
177
  )}
193
178
  </div>
194
- )}
179
+ ) : null}
195
180
 
196
181
  {/* Chat input */}
197
- <div className="w-full max-w-md relative mt-4">
182
+ <div className={'w-full relative mt-4 ' + (ttsEnabled ? 'max-w-md' : '')}>
198
183
  <input
199
184
  value={chatInput}
200
185
  onChange={(e) => setChatInput(e.target.value)}
@@ -217,7 +202,7 @@ export function BuddyAssistant({
217
202
  onVoiceRecorded={(text) => sendMessage(text)}
218
203
  onRecordingStatusChange={() => {}}
219
204
  />
220
- <div className="w-px h-3.5 bg-gray-600" />
205
+ {/* <div className="w-px h-3.5 bg-gray-600" /> */}
221
206
  <button
222
207
  type="button"
223
208
  onClick={() => {
@@ -1,22 +1,16 @@
1
- /** @deprecated Use server-side prompt definitions (prompt + variables) instead of building messages client-side. */
2
1
  export interface FirstMessages {
3
- instructions?: string;
4
2
  userMessage?: string;
5
3
  assistantMessage?: string;
6
4
  }
7
5
 
8
- /** @deprecated Use server-side prompt definitions (prompt + variables) instead of building messages client-side. */
9
- export function getFirstMessages(instructions: FirstMessages): any[] {
6
+ export function getFirstMessages(config: FirstMessages): any[] {
10
7
  const messages = [];
11
8
 
12
- if (instructions.instructions) {
13
- messages.push({ id: '1', role: 'system', content: instructions.instructions });
9
+ if (config.userMessage) {
10
+ messages.push({ id: '1', role: 'user', content: config.userMessage });
14
11
  }
15
- if (instructions.userMessage) {
16
- messages.push({ id: '2', role: 'user', content: instructions.userMessage });
17
- }
18
- if (instructions.assistantMessage) {
19
- messages.push({ id: '3', role: 'assistant', content: instructions.assistantMessage });
12
+ if (config.assistantMessage) {
13
+ messages.push({ id: '2', role: 'assistant', content: config.assistantMessage });
20
14
  }
21
15
 
22
16
  return messages;
@@ -717,17 +717,11 @@ export const MarkdownEditor = ({
717
717
  if (from === to) return;
718
718
  const selectedText = editor.state.doc.textBetween(from, to, '\n');
719
719
  setIsTransforming(true);
720
- const transformed = await ai.getText([
721
- {
722
- role: 'system',
723
- content:
724
- 'You are a text editor assistant. Transform the provided text according to the user instruction. Return only the transformed text, no explanations. If formattation is wished, use markdown syntax in the output.',
725
- },
726
- {
727
- role: 'user',
728
- content: `Instruction: ${prompt}\n\nText:\n${selectedText}`,
729
- },
730
- ]);
720
+ const transformed = await ai.getText({
721
+ messages: [],
722
+ prompt: 'global.editor.transform-text',
723
+ variables: { instruction: prompt, text: selectedText },
724
+ });
731
725
  setIsTransforming(false);
732
726
  if (!transformed) return;
733
727
  editor.chain().focus().deleteRange({ from, to }).insertContentAt(from, transformed).run();
@@ -1,8 +1,15 @@
1
1
  import { Theme } from '@rimori/client';
2
2
 
3
- export function useTheme(theme: Theme = 'system'): { isDark: boolean; theme: Theme } {
3
+ export function useTheme(
4
+ theme: Theme = 'system',
5
+ isDisabled = false,
6
+ ): { isDark: boolean; theme: Theme; isDisabled: boolean } {
4
7
  const isDark = theme === 'system' ? systenUsesDarkMode() : theme === 'dark';
5
8
 
9
+ if (isDisabled) {
10
+ return { isDark, theme, isDisabled };
11
+ }
12
+
6
13
  const dom = document.documentElement;
7
14
  dom.dataset.theme = isDark ? 'dark' : 'light';
8
15
  dom.style.colorScheme = isDark ? 'dark' : 'light';
@@ -19,7 +26,7 @@ export function useTheme(theme: Theme = 'system'): { isDark: boolean; theme: The
19
26
  'bg-fixed',
20
27
  );
21
28
 
22
- return { isDark, theme };
29
+ return { isDark, theme, isDisabled };
23
30
  }
24
31
 
25
32
  function systenUsesDarkMode(): boolean {
@@ -13,8 +13,6 @@ export interface UseChatConfig {
13
13
  export function useChat(
14
14
  tools?: Tool[],
15
15
  options?: {
16
- /** @deprecated Use uuid variable with resolver 'knowledgeEntry' in prompt definitions instead. */
17
- knowledgeId?: string;
18
16
  /** Server-side prompt name (e.g. 'storytelling.story'). When set, the backend resolves the system prompt. */
19
17
  prompt?: string;
20
18
  /** Variables for the server-side prompt template. */
@@ -42,13 +40,12 @@ export function useChat(
42
40
  const allMessages = [...messages, ...appendMessages];
43
41
  setMessages(allMessages);
44
42
  setIsLoading(true);
45
- const knowledgeId = options?.knowledgeId;
46
43
  const promptVariables = configRef.current.promptVariables ?? options?.promptVariables;
47
44
  const prompt = configRef.current.prompt ?? options?.prompt;
48
45
 
49
- void ai.getSteamedText(
50
- allMessages,
51
- (id, message, finished: boolean, toolInvocations?: ToolInvocation[]) => {
46
+ void ai.getStreamedText({
47
+ messages: allMessages,
48
+ onMessage: (id, message, finished: boolean, toolInvocations?: ToolInvocation[]) => {
52
49
  setIsLoading(!finished);
53
50
  setMessages((prev) => {
54
51
  const last = prev[prev.length - 1];
@@ -59,12 +56,9 @@ export function useChat(
59
56
  });
60
57
  },
61
58
  tools,
62
- false,
63
- undefined,
64
- knowledgeId,
65
59
  prompt,
66
- promptVariables,
67
- );
60
+ variables: promptVariables,
61
+ });
68
62
  };
69
63
 
70
64
  return {
@@ -9,6 +9,10 @@ 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
18
  };
@@ -21,14 +25,21 @@ interface PluginContextValue {
21
25
 
22
26
  const PluginContext = createContext<PluginContextValue | null>(null);
23
27
 
24
- export const PluginProvider: React.FC<PluginProviderProps> = ({ children, pluginId, settings }) => {
25
- 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);
26
37
  const [standaloneClient, setStandaloneClient] = useState<StandaloneClient | boolean>(false);
27
38
  const [applicationMode, setApplicationMode] = useState<string | null>(null);
28
39
  const [theme, setTheme] = useState<Theme | undefined>(undefined);
29
- const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
40
+ const [userInfo, setUserInfo] = useState<UserInfo | null>(injectedUserInfo ?? null);
30
41
 
31
- useTheme(theme);
42
+ useTheme(theme, isFederated);
32
43
 
33
44
  // Init PostHog once per plugin iframe
34
45
  useEffect(() => {
@@ -51,6 +62,9 @@ export const PluginProvider: React.FC<PluginProviderProps> = ({ children, plugin
51
62
  const isSettings = applicationMode === 'settings';
52
63
 
53
64
  useEffect(() => {
65
+ // In federation mode the client is already ready — skip handshake
66
+ if (isFederated) return;
67
+
54
68
  initEventBus(pluginId);
55
69
 
56
70
  // Check if we're in an iframe context - if not, we're standalone
@@ -65,7 +79,6 @@ export const PluginProvider: React.FC<PluginProviderProps> = ({ children, plugin
65
79
  if ((!standaloneDetected && !client) || (standaloneDetected && standaloneClient === true)) {
66
80
  void RimoriClient.getInstance(pluginId).then((client) => {
67
81
  setClient(client);
68
- // Set initial userInfo
69
82
  setUserInfo(client.plugin.getUserInfo());
70
83
 
71
84
  // Get applicationMode and theme from MessageChannel query params
@@ -76,7 +89,7 @@ export const PluginProvider: React.FC<PluginProviderProps> = ({ children, plugin
76
89
  }
77
90
  });
78
91
  }
79
- }, [pluginId, standaloneClient, client]);
92
+ }, [pluginId, standaloneClient, client, isFederated]);
80
93
 
81
94
  // Identify user in PostHog when userInfo is available
82
95
  useEffect(() => {
@@ -90,7 +103,7 @@ export const PluginProvider: React.FC<PluginProviderProps> = ({ children, plugin
90
103
  if (!client) return;
91
104
 
92
105
  const unsubscribe = client.plugin.onRimoriInfoUpdate((info) => {
93
- console.log('[PluginProvider] Received RimoriInfo update, updating userInfo');
106
+ // console.log('[PluginProvider] Received RimoriInfo update, updating userInfo + ttsEnabled:', info.ttsEnabled);
94
107
  setUserInfo(info.profile);
95
108
  });
96
109
 
@@ -100,6 +113,7 @@ export const PluginProvider: React.FC<PluginProviderProps> = ({ children, plugin
100
113
  useEffect(() => {
101
114
  if (!client) return;
102
115
  if (isSidebar) return; //sidebar pages should not report url changes
116
+ if (isFederated) return; // federation mode: host handles URL sync
103
117
 
104
118
  // react router overwrites native pushstate so it gets wrapped to detect url changes
105
119
  const originalPushState = history.pushState;
@@ -108,10 +122,11 @@ export const PluginProvider: React.FC<PluginProviderProps> = ({ children, plugin
108
122
  client.event.emit('session.triggerUrlChange', { url: location.hash });
109
123
  return result;
110
124
  };
111
- }, [client, isSidebar]);
125
+ }, [client, isSidebar, isFederated]);
112
126
 
113
127
  useEffect(() => {
114
128
  if (!client) return;
129
+ if (isFederated) return;
115
130
 
116
131
  const checkScrollbar = (): void => {
117
132
  const hasScrollbar = document.documentElement.scrollHeight > window.innerHeight;
@@ -133,7 +148,7 @@ export const PluginProvider: React.FC<PluginProviderProps> = ({ children, plugin
133
148
  window.removeEventListener('resize', checkScrollbar);
134
149
  resizeObserver.disconnect();
135
150
  };
136
- }, [client]);
151
+ }, [client, isFederated]);
137
152
 
138
153
  if (standaloneClient instanceof StandaloneClient) {
139
154
  return (
@@ -162,9 +177,9 @@ export const useRimori = (): RimoriClient & { userInfo: UserInfo } => {
162
177
  if (context === null) {
163
178
  throw new Error('useRimori must be used within an PluginProvider');
164
179
  }
165
- // Return client with userInfo at root level for easy access
166
- // Maintains backwards compatibility - all client properties are still accessible
167
- 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 };
168
183
  };
169
184
 
170
185
  function getUrlParam(name: string): string | null {