@rimori/react-client 0.4.11-next.2 → 0.4.11

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.
@@ -9,7 +9,6 @@ type AudioPlayerProps = {
9
9
  initialSpeed?: number;
10
10
  enableSpeedAdjustment?: boolean;
11
11
  playListenerEvent?: string;
12
- size?: string;
13
12
  };
14
13
  export declare const AudioPlayOptions: number[];
15
14
  export type AudioPlayOptionType = 0.8 | 0.9 | 1.0 | 1.1 | 1.2 | 1.5;
@@ -14,7 +14,7 @@ import { useRimori } from '../../providers/PluginProvider';
14
14
  import { EventBus } from '@rimori/client';
15
15
  export const AudioPlayOptions = [0.8, 0.9, 1.0, 1.1, 1.2, 1.5];
16
16
  let isFetchingAudio = false;
17
- export const AudioPlayer = ({ text, voice, language, hide, playListenerEvent, initialSpeed = 1.0, playOnMount = false, enableSpeedAdjustment = false, cache = true, size = '25px', }) => {
17
+ export const AudioPlayer = ({ text, voice, language, hide, playListenerEvent, initialSpeed = 1.0, playOnMount = false, enableSpeedAdjustment = false, cache = true, }) => {
18
18
  const [audioUrl, setAudioUrl] = useState(null);
19
19
  const [speed, setSpeed] = useState(initialSpeed);
20
20
  const [isPlaying, setIsPlaying] = useState(false);
@@ -129,7 +129,7 @@ export const AudioPlayer = ({ text, voice, language, hide, playListenerEvent, in
129
129
  // console.log("playOnMount", playOnMount);
130
130
  togglePlayback();
131
131
  }, [playOnMount]);
132
- 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))) })] }))] }) }));
132
+ 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: "25px" })) : 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))) })] }))] }) }));
133
133
  };
134
134
  const Spinner = ({ text, className, size = '30px' }) => {
135
135
  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 })] }));
@@ -12,7 +12,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
12
12
  import { useRimori } from '../../providers/PluginProvider';
13
13
  import { Markdown } from 'tiptap-markdown';
14
14
  import StarterKit from '@tiptap/starter-kit';
15
- import { Paragraph } from '@tiptap/extension-paragraph';
16
15
  import Table from '@tiptap/extension-table';
17
16
  import TableCell from '@tiptap/extension-table-cell';
18
17
  import TableHeader from '@tiptap/extension-table-header';
@@ -27,28 +26,6 @@ import { AiOutlineUnorderedList } from 'react-icons/ai';
27
26
  import { LuClipboardPaste, LuHeading1, LuHeading2, LuHeading3, LuLink, LuUnlink } from 'react-icons/lu';
28
27
  import { FaBold, FaCode, FaItalic, FaParagraph, FaStrikethrough } from 'react-icons/fa';
29
28
  import { ImageUploadExtension, triggerImageUpload } from './ImageUploadExtension';
30
- // Extends TipTap's Paragraph to serialize empty paragraphs as <p></p>.
31
- // Standard markdown collapses consecutive blank lines, losing empty paragraph nodes.
32
- // Since tiptap-markdown enables html:true by default, <p></p> survives the round-trip.
33
- const ParagraphPreserveEmpty = Paragraph.extend({
34
- addStorage() {
35
- return {
36
- markdown: {
37
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
38
- serialize(state, node) {
39
- if (node.childCount === 0) {
40
- state.write('<p></p>');
41
- }
42
- else {
43
- state.renderInline(node);
44
- }
45
- state.closeBlock(node);
46
- },
47
- parse: {},
48
- },
49
- };
50
- },
51
- });
52
29
  function getMarkdown(editor) {
53
30
  return editor.storage.markdown.getMarkdown();
54
31
  }
@@ -183,8 +160,7 @@ export const MarkdownEditor = ({ content, editable, className, onUpdate, labels,
183
160
  return data.url;
184
161
  }), [storage]);
185
162
  const extensions = useMemo(() => [
186
- StarterKit.configure({ paragraph: false }),
187
- ParagraphPreserveEmpty,
163
+ StarterKit,
188
164
  Table.configure({ resizable: false }),
189
165
  TableRow,
190
166
  TableHeader,
@@ -208,8 +184,7 @@ export const MarkdownEditor = ({ content, editable, className, onUpdate, labels,
208
184
  onUpdate === null || onUpdate === void 0 ? void 0 : onUpdate(markdown);
209
185
  },
210
186
  });
211
- // Sync external content changes (e.g. AI autofill) without triggering update loop.
212
- // Pass false to setContent to prevent onUpdate from firing and re-normalizing the content.
187
+ // Sync external content changes (e.g. AI autofill) without triggering update loop
213
188
  useEffect(() => {
214
189
  if (!editor)
215
190
  return;
@@ -217,7 +192,7 @@ export const MarkdownEditor = ({ content, editable, className, onUpdate, labels,
217
192
  if (incoming === lastEmittedRef.current)
218
193
  return;
219
194
  lastEmittedRef.current = incoming;
220
- editor.commands.setContent(incoming, false);
195
+ editor.commands.setContent(incoming);
221
196
  }, [editor, content]);
222
197
  // Sync editable prop
223
198
  useEffect(() => {
package/dist/index.d.ts CHANGED
@@ -5,8 +5,6 @@ export * from './components/ai/Avatar';
5
5
  export { FirstMessages } from './components/ai/utils';
6
6
  export { useTranslation } from './hooks/I18nHooks';
7
7
  export { Avatar } from './components/ai/Avatar';
8
- export { BuddyAssistant } from './components/ai/BuddyAssistant';
9
- export type { BuddyAssistantProps, BuddyAssistantAutoStart } from './components/ai/BuddyAssistant';
10
8
  export { VoiceRecorder } from './components/ai/EmbeddedAssistent/VoiceRecorder';
11
9
  export { MarkdownEditor } from './components/editor/MarkdownEditor';
12
10
  export type { MarkdownEditorProps, EditorLabels } from './components/editor/MarkdownEditor';
package/dist/index.js CHANGED
@@ -5,7 +5,6 @@ export * from './components/audio/Playbutton';
5
5
  export * from './components/ai/Avatar';
6
6
  export { useTranslation } from './hooks/I18nHooks';
7
7
  export { Avatar } from './components/ai/Avatar';
8
- export { BuddyAssistant } from './components/ai/BuddyAssistant';
9
8
  export { VoiceRecorder } from './components/ai/EmbeddedAssistent/VoiceRecorder';
10
9
  export { MarkdownEditor } from './components/editor/MarkdownEditor';
11
10
  export { extractImageUrls } from './components/editor/imageUtils';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rimori/react-client",
3
- "version": "0.4.11-next.2",
3
+ "version": "0.4.11",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,7 +24,7 @@
24
24
  "format": "prettier --write ."
25
25
  },
26
26
  "peerDependencies": {
27
- "@rimori/client": "2.5.19-next.5",
27
+ "@rimori/client": "^2.5.19",
28
28
  "react": "^18.1.0",
29
29
  "react-dom": "^18.1.0"
30
30
  },
@@ -32,7 +32,6 @@
32
32
  "@tiptap/core": "^2.26.1",
33
33
  "@tiptap/extension-image": "^2.26.1",
34
34
  "@tiptap/extension-link": "^2.26.1",
35
- "@tiptap/extension-paragraph": "^2.26.1",
36
35
  "@tiptap/extension-table": "^2.26.1",
37
36
  "@tiptap/extension-table-cell": "^2.26.1",
38
37
  "@tiptap/extension-table-header": "^2.26.1",
@@ -47,7 +46,7 @@
47
46
  },
48
47
  "devDependencies": {
49
48
  "@eslint/js": "^9.37.0",
50
- "@rimori/client": "2.5.19-next.5",
49
+ "@rimori/client": "^2.5.19",
51
50
  "@types/react": "^18.3.21",
52
51
  "eslint-config-prettier": "^10.1.8",
53
52
  "eslint-plugin-prettier": "^5.5.4",
@@ -13,7 +13,6 @@ type AudioPlayerProps = {
13
13
  initialSpeed?: number;
14
14
  enableSpeedAdjustment?: boolean;
15
15
  playListenerEvent?: string;
16
- size?: string;
17
16
  };
18
17
 
19
18
  export const AudioPlayOptions = [0.8, 0.9, 1.0, 1.1, 1.2, 1.5];
@@ -31,7 +30,6 @@ export const AudioPlayer: React.FC<AudioPlayerProps> = ({
31
30
  playOnMount = false,
32
31
  enableSpeedAdjustment = false,
33
32
  cache = true,
34
- size = '25px',
35
33
  }) => {
36
34
  const [audioUrl, setAudioUrl] = useState<string | null>(null);
37
35
  const [speed, setSpeed] = useState(initialSpeed);
@@ -163,11 +161,11 @@ export const AudioPlayer: React.FC<AudioPlayerProps> = ({
163
161
  {!hide && (
164
162
  <button className="text-gray-400" onClick={togglePlayback} disabled={isLoading}>
165
163
  {isLoading ? (
166
- <Spinner size={size} />
164
+ <Spinner size="25px" />
167
165
  ) : isPlaying ? (
168
- <FaStopCircle size={size} />
166
+ <FaStopCircle size="25px" />
169
167
  ) : (
170
- <FaPlayCircle size={size} />
168
+ <FaPlayCircle size="25px" />
171
169
  )}
172
170
  </button>
173
171
  )}
@@ -2,7 +2,6 @@ import { JSX, useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { useRimori } from '../../providers/PluginProvider';
3
3
  import { Markdown } from 'tiptap-markdown';
4
4
  import StarterKit from '@tiptap/starter-kit';
5
- import { Paragraph } from '@tiptap/extension-paragraph';
6
5
  import Table from '@tiptap/extension-table';
7
6
  import TableCell from '@tiptap/extension-table-cell';
8
7
  import TableHeader from '@tiptap/extension-table-header';
@@ -29,28 +28,6 @@ import { LuClipboardPaste, LuHeading1, LuHeading2, LuHeading3, LuLink, LuUnlink
29
28
  import { FaBold, FaCode, FaItalic, FaParagraph, FaStrikethrough } from 'react-icons/fa';
30
29
  import { ImageUploadExtension, triggerImageUpload } from './ImageUploadExtension';
31
30
 
32
- // Extends TipTap's Paragraph to serialize empty paragraphs as <p></p>.
33
- // Standard markdown collapses consecutive blank lines, losing empty paragraph nodes.
34
- // Since tiptap-markdown enables html:true by default, <p></p> survives the round-trip.
35
- const ParagraphPreserveEmpty = Paragraph.extend({
36
- addStorage() {
37
- return {
38
- markdown: {
39
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
40
- serialize(state: any, node: any) {
41
- if (node.childCount === 0) {
42
- state.write('<p></p>');
43
- } else {
44
- state.renderInline(node);
45
- }
46
- state.closeBlock(node);
47
- },
48
- parse: {},
49
- },
50
- };
51
- },
52
- });
53
-
54
31
  function getMarkdown(editor: Editor): string {
55
32
  return (editor.storage as { markdown: { getMarkdown: () => string } }).markdown.getMarkdown();
56
33
  }
@@ -582,8 +559,7 @@ export const MarkdownEditor = ({
582
559
 
583
560
  const extensions = useMemo(
584
561
  () => [
585
- StarterKit.configure({ paragraph: false }),
586
- ParagraphPreserveEmpty,
562
+ StarterKit,
587
563
  Table.configure({ resizable: false }),
588
564
  TableRow,
589
565
  TableHeader,
@@ -611,14 +587,13 @@ export const MarkdownEditor = ({
611
587
  },
612
588
  });
613
589
 
614
- // Sync external content changes (e.g. AI autofill) without triggering update loop.
615
- // Pass false to setContent to prevent onUpdate from firing and re-normalizing the content.
590
+ // Sync external content changes (e.g. AI autofill) without triggering update loop
616
591
  useEffect(() => {
617
592
  if (!editor) return;
618
593
  const incoming = content ?? '';
619
594
  if (incoming === lastEmittedRef.current) return;
620
595
  lastEmittedRef.current = incoming;
621
- editor.commands.setContent(incoming, false);
596
+ editor.commands.setContent(incoming);
622
597
  }, [editor, content]);
623
598
 
624
599
  // Sync editable prop
package/src/index.ts CHANGED
@@ -6,8 +6,6 @@ export * from './components/ai/Avatar';
6
6
  export { FirstMessages } from './components/ai/utils';
7
7
  export { useTranslation } from './hooks/I18nHooks';
8
8
  export { Avatar } from './components/ai/Avatar';
9
- export { BuddyAssistant } from './components/ai/BuddyAssistant';
10
- export type { BuddyAssistantProps, BuddyAssistantAutoStart } from './components/ai/BuddyAssistant';
11
9
  export { VoiceRecorder } from './components/ai/EmbeddedAssistent/VoiceRecorder';
12
10
  export { MarkdownEditor } from './components/editor/MarkdownEditor';
13
11
  export type { MarkdownEditorProps, EditorLabels } from './components/editor/MarkdownEditor';
package/tsconfig.json CHANGED
@@ -14,6 +14,5 @@
14
14
  },
15
15
  "include": [
16
16
  "src/**/*"
17
- ],
18
- "exclude": ["node_modules", "dist", "build"]
17
+ ]
19
18
  }
@@ -1,22 +0,0 @@
1
- import React from 'react';
2
- import { Tool } from '@rimori/client';
3
- export interface BuddyAssistantAutoStart {
4
- /** Pre-written assistant message shown immediately (no AI call) */
5
- assistantMessage?: string;
6
- /** Silently sent as the first user message to trigger an AI-generated intro */
7
- userMessage?: string;
8
- }
9
- export interface BuddyAssistantProps {
10
- buddyName: string;
11
- avatarImageUrl: string;
12
- voiceId: string;
13
- systemPrompt: string;
14
- autoStartConversation?: BuddyAssistantAutoStart;
15
- circleSize?: string;
16
- chatPlaceholder?: string;
17
- bottomAction?: React.ReactNode;
18
- className?: string;
19
- voiceSpeed?: number;
20
- tools?: Tool[];
21
- }
22
- export declare function BuddyAssistant({ buddyName, avatarImageUrl, voiceId, systemPrompt, autoStartConversation, circleSize, chatPlaceholder, bottomAction, className, voiceSpeed, tools, }: BuddyAssistantProps): import("react/jsx-runtime").JSX.Element;
@@ -1,102 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState, useEffect, useMemo, useRef } from 'react';
3
- import { CircleAudioAvatar } from './EmbeddedAssistent/CircleAudioAvatar';
4
- import { VoiceRecorder } from './EmbeddedAssistent/VoiceRecorder';
5
- import { MessageSender } from '@rimori/client';
6
- import { useRimori } from '../../providers/PluginProvider';
7
- import { useTheme } from '../../hooks/ThemeSetter';
8
- import { HiMiniSpeakerWave, HiMiniSpeakerXMark } from 'react-icons/hi2';
9
- import { BiSolidRightArrow } from 'react-icons/bi';
10
- let idCounter = 0;
11
- const genId = () => `ba-${++idCounter}`;
12
- export function BuddyAssistant({ buddyName, avatarImageUrl, voiceId, systemPrompt, autoStartConversation, circleSize = '160px', chatPlaceholder, bottomAction, className, voiceSpeed = 1, tools, }) {
13
- const { ai, event, plugin } = useRimori();
14
- const { isDark } = useTheme(plugin.theme);
15
- const [ttsEnabled, setTtsEnabled] = useState(true);
16
- const [chatInput, setChatInput] = useState('');
17
- const [messages, setMessages] = useState([]);
18
- const [isLoading, setIsLoading] = useState(false);
19
- const [isSpeaking, setIsSpeaking] = useState(false);
20
- const ttsEnabledRef = useRef(ttsEnabled);
21
- useEffect(() => {
22
- ttsEnabledRef.current = ttsEnabled;
23
- }, [ttsEnabled]);
24
- const sender = useMemo(() => new MessageSender((...args) => ai.getVoice(...args), voiceId), [voiceId, ai]);
25
- // Setup sender callbacks and cleanup
26
- useEffect(() => {
27
- sender.setOnLoudnessChange((value) => event.emit('self.avatar.triggerLoudness', { loudness: value }));
28
- sender.setOnEndOfSpeech(() => setIsSpeaking(false));
29
- return () => sender.cleanup();
30
- }, [sender]);
31
- // Build full API message list with system prompt
32
- const buildApiMessages = (history) => [
33
- { role: 'system', content: systemPrompt },
34
- ...history.map((m) => ({ role: m.role, content: m.content })),
35
- ];
36
- const triggerAI = (history) => {
37
- setIsLoading(true);
38
- void ai.getSteamedText(buildApiMessages(history), (id, partial, finished) => {
39
- setIsLoading(!finished);
40
- const assistantId = `ai-${id}`;
41
- setMessages((prev) => {
42
- const last = prev[prev.length - 1];
43
- if ((last === null || last === void 0 ? void 0 : last.id) === assistantId) {
44
- return [...prev.slice(0, -1), Object.assign(Object.assign({}, last), { content: partial })];
45
- }
46
- return [...prev, { id: assistantId, role: 'assistant', content: partial }];
47
- });
48
- if (ttsEnabledRef.current) {
49
- void sender.handleNewText(partial, !finished);
50
- if (partial)
51
- setIsSpeaking(true);
52
- }
53
- }, tools);
54
- };
55
- // Auto-start conversation on mount
56
- const autoStartedRef = useRef(false);
57
- useEffect(() => {
58
- if (autoStartedRef.current)
59
- return;
60
- autoStartedRef.current = true;
61
- if (autoStartConversation === null || autoStartConversation === void 0 ? void 0 : autoStartConversation.assistantMessage) {
62
- const initMsg = { id: 'init', role: 'assistant', content: autoStartConversation.assistantMessage };
63
- setMessages([initMsg]);
64
- if (ttsEnabledRef.current) {
65
- void sender.handleNewText(autoStartConversation.assistantMessage, false);
66
- setIsSpeaking(true);
67
- }
68
- }
69
- else if (autoStartConversation === null || autoStartConversation === void 0 ? void 0 : autoStartConversation.userMessage) {
70
- const userMsg = { id: 'auto-start', role: 'user', content: autoStartConversation.userMessage };
71
- setMessages([userMsg]);
72
- triggerAI([userMsg]);
73
- }
74
- // eslint-disable-next-line react-hooks/exhaustive-deps
75
- }, []);
76
- const sendMessage = (text) => {
77
- if (!text.trim() || isLoading)
78
- return;
79
- const userMsg = { id: genId(), role: 'user', content: text };
80
- const newMessages = [...messages, userMsg];
81
- setMessages(newMessages);
82
- triggerAI(newMessages);
83
- };
84
- const handleToggleTts = () => {
85
- if (ttsEnabled && isSpeaking) {
86
- sender.stop();
87
- setIsSpeaking(false);
88
- }
89
- setTtsEnabled((prev) => !prev);
90
- };
91
- const lastAssistantMessage = [...messages].filter((m) => m.role === 'assistant').pop();
92
- return (_jsxs("div", { className: `flex flex-col items-center ${className || ''}`, children: [_jsx(CircleAudioAvatar, { width: circleSize, imageUrl: avatarImageUrl, isDarkTheme: isDark, className: "mx-auto" }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-3xl font-semibold", children: buddyName }), _jsx("button", { type: "button", onClick: handleToggleTts, className: "p-1 rounded-md hover:bg-gray-700/50 transition-colors", title: ttsEnabled ? 'Disable voice' : 'Enable voice', children: ttsEnabled ? (_jsx(HiMiniSpeakerWave, { className: `w-5 h-5 mt-0.5 ${isSpeaking ? 'text-blue-400' : 'text-gray-300'}` })) : (_jsx(HiMiniSpeakerXMark, { className: "w-5 h-5 mt-0.5 text-gray-500" })) })] }), !ttsEnabled && (_jsx("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", 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 })) })), _jsxs("div", { className: "w-full max-w-md relative mt-4", children: [_jsx("input", { value: chatInput, onChange: (e) => setChatInput(e.target.value), onKeyDown: (e) => {
93
- if (e.key === 'Enter' && !e.shiftKey) {
94
- e.preventDefault();
95
- sendMessage(chatInput);
96
- setChatInput('');
97
- }
98
- }, placeholder: chatPlaceholder !== null && chatPlaceholder !== void 0 ? chatPlaceholder : `Ask ${buddyName} a question…`, disabled: isLoading, className: "w-full bg-gray-800/50 border border-gray-700 rounded-lg px-3 py-2 pr-16 text-sm text-gray-200 placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-60" }), _jsxs("div", { className: "absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1", children: [_jsx(VoiceRecorder, { iconSize: "14", className: "p-1 text-gray-400 hover:text-white transition-colors", disabled: isLoading, onVoiceRecorded: (text) => sendMessage(text), onRecordingStatusChange: () => { } }), _jsx("div", { className: "w-px h-3.5 bg-gray-600" }), _jsx("button", { type: "button", onClick: () => {
99
- sendMessage(chatInput);
100
- setChatInput('');
101
- }, disabled: isLoading || !chatInput.trim(), className: "p-1 text-gray-400 hover:text-white disabled:opacity-40 transition-colors", children: _jsx(BiSolidRightArrow, { className: "w-4 h-4" }) })] })] }), bottomAction && _jsx("div", { className: "w-full max-w-md border-t border-gray-700/60 pt-3", children: bottomAction })] }));
102
- }
@@ -1,226 +0,0 @@
1
- import React, { useState, useEffect, useMemo, useRef } from 'react';
2
- import { CircleAudioAvatar } from './EmbeddedAssistent/CircleAudioAvatar';
3
- import { VoiceRecorder } from './EmbeddedAssistent/VoiceRecorder';
4
- import { MessageSender, Tool } from '@rimori/client';
5
- import { useRimori } from '../../providers/PluginProvider';
6
- import { useTheme } from '../../hooks/ThemeSetter';
7
- import { HiMiniSpeakerWave, HiMiniSpeakerXMark } from 'react-icons/hi2';
8
- import { BiSolidRightArrow } from 'react-icons/bi';
9
-
10
- type ChatMessage = { id: string; role: 'user' | 'assistant'; content: string };
11
-
12
- export interface BuddyAssistantAutoStart {
13
- /** Pre-written assistant message shown immediately (no AI call) */
14
- assistantMessage?: string;
15
- /** Silently sent as the first user message to trigger an AI-generated intro */
16
- userMessage?: string;
17
- }
18
-
19
- export interface BuddyAssistantProps {
20
- buddyName: string;
21
- avatarImageUrl: string;
22
- voiceId: string;
23
- systemPrompt: string;
24
- autoStartConversation?: BuddyAssistantAutoStart;
25
- circleSize?: string;
26
- chatPlaceholder?: string;
27
- bottomAction?: React.ReactNode;
28
- className?: string;
29
- voiceSpeed?: number;
30
- tools?: Tool[];
31
- }
32
-
33
- let idCounter = 0;
34
- const genId = () => `ba-${++idCounter}`;
35
-
36
- export function BuddyAssistant({
37
- buddyName,
38
- avatarImageUrl,
39
- voiceId,
40
- systemPrompt,
41
- autoStartConversation,
42
- circleSize = '160px',
43
- chatPlaceholder,
44
- bottomAction,
45
- className,
46
- voiceSpeed = 1,
47
- tools,
48
- }: BuddyAssistantProps) {
49
- const { ai, event, plugin } = useRimori();
50
- const { isDark } = useTheme(plugin.theme);
51
-
52
- const [ttsEnabled, setTtsEnabled] = useState(true);
53
- const [chatInput, setChatInput] = useState('');
54
- const [messages, setMessages] = useState<ChatMessage[]>([]);
55
- const [isLoading, setIsLoading] = useState(false);
56
- const [isSpeaking, setIsSpeaking] = useState(false);
57
-
58
- const ttsEnabledRef = useRef(ttsEnabled);
59
- useEffect(() => {
60
- ttsEnabledRef.current = ttsEnabled;
61
- }, [ttsEnabled]);
62
-
63
- const sender = useMemo(() => new MessageSender((...args) => ai.getVoice(...args), voiceId), [voiceId, ai]);
64
-
65
- // Setup sender callbacks and cleanup
66
- useEffect(() => {
67
- sender.setOnLoudnessChange((value: number) => event.emit('self.avatar.triggerLoudness', { loudness: value }));
68
- sender.setOnEndOfSpeech(() => setIsSpeaking(false));
69
- return () => sender.cleanup();
70
- }, [sender]);
71
-
72
- // Build full API message list with system prompt
73
- const buildApiMessages = (history: ChatMessage[]) => [
74
- { role: 'system' as const, content: systemPrompt },
75
- ...history.map((m) => ({ role: m.role, content: m.content })),
76
- ];
77
-
78
- const triggerAI = (history: ChatMessage[]) => {
79
- setIsLoading(true);
80
- void ai.getSteamedText(
81
- buildApiMessages(history),
82
- (id: string, partial: string, finished: boolean) => {
83
- setIsLoading(!finished);
84
- const assistantId = `ai-${id}`;
85
- setMessages((prev) => {
86
- const last = prev[prev.length - 1];
87
- if (last?.id === assistantId) {
88
- return [...prev.slice(0, -1), { ...last, content: partial }];
89
- }
90
- return [...prev, { id: assistantId, role: 'assistant', content: partial }];
91
- });
92
- if (ttsEnabledRef.current) {
93
- void sender.handleNewText(partial, !finished);
94
- if (partial) setIsSpeaking(true);
95
- }
96
- },
97
- tools,
98
- );
99
- };
100
-
101
- // Auto-start conversation on mount
102
- const autoStartedRef = useRef(false);
103
- useEffect(() => {
104
- if (autoStartedRef.current) return;
105
- autoStartedRef.current = true;
106
-
107
- if (autoStartConversation?.assistantMessage) {
108
- const initMsg: ChatMessage = { id: 'init', role: 'assistant', content: autoStartConversation.assistantMessage };
109
- setMessages([initMsg]);
110
- if (ttsEnabledRef.current) {
111
- void sender.handleNewText(autoStartConversation.assistantMessage, false);
112
- setIsSpeaking(true);
113
- }
114
- } else if (autoStartConversation?.userMessage) {
115
- const userMsg: ChatMessage = { id: 'auto-start', role: 'user', content: autoStartConversation.userMessage };
116
- setMessages([userMsg]);
117
- triggerAI([userMsg]);
118
- }
119
- // eslint-disable-next-line react-hooks/exhaustive-deps
120
- }, []);
121
-
122
- const sendMessage = (text: string) => {
123
- if (!text.trim() || isLoading) return;
124
- const userMsg: ChatMessage = { id: genId(), role: 'user', content: text };
125
- const newMessages = [...messages, userMsg];
126
- setMessages(newMessages);
127
- triggerAI(newMessages);
128
- };
129
-
130
- const handleToggleTts = () => {
131
- if (ttsEnabled && isSpeaking) {
132
- sender.stop();
133
- setIsSpeaking(false);
134
- }
135
- setTtsEnabled((prev) => !prev);
136
- };
137
-
138
- const lastAssistantMessage = [...messages].filter((m) => m.role === 'assistant').pop();
139
-
140
- return (
141
- <div className={`flex flex-col items-center ${className || ''}`}>
142
- {/* Animated circle avatar */}
143
- <CircleAudioAvatar width={circleSize} imageUrl={avatarImageUrl} isDarkTheme={isDark} className="mx-auto" />
144
-
145
- {/* Buddy name + TTS toggle */}
146
- <div className="flex items-center gap-2">
147
- <span className="text-3xl font-semibold">{buddyName}</span>
148
- <button
149
- type="button"
150
- onClick={handleToggleTts}
151
- className="p-1 rounded-md hover:bg-gray-700/50 transition-colors"
152
- title={ttsEnabled ? 'Disable voice' : 'Enable voice'}
153
- >
154
- {ttsEnabled ? (
155
- <HiMiniSpeakerWave className={`w-5 h-5 mt-0.5 ${isSpeaking ? 'text-blue-400' : 'text-gray-300'}`} />
156
- ) : (
157
- <HiMiniSpeakerXMark className="w-5 h-5 mt-0.5 text-gray-500" />
158
- )}
159
- </button>
160
- </div>
161
-
162
- {/* Last buddy message card — only shown when TTS is disabled */}
163
- {!ttsEnabled && (
164
- <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">
165
- {!lastAssistantMessage?.content && isLoading ? (
166
- <span className="inline-flex gap-1 py-0.5">
167
- <span className="w-1.5 h-1.5 rounded-full bg-gray-400 animate-bounce" />
168
- <span
169
- className="w-1.5 h-1.5 rounded-full bg-gray-400 animate-bounce"
170
- style={{ animationDelay: '0.15s' }}
171
- />
172
- <span
173
- className="w-1.5 h-1.5 rounded-full bg-gray-400 animate-bounce"
174
- style={{ animationDelay: '0.3s' }}
175
- />
176
- </span>
177
- ) : (
178
- <span className="whitespace-pre-wrap">{lastAssistantMessage?.content}</span>
179
- )}
180
- </div>
181
- )}
182
-
183
- {/* Chat input */}
184
- <div className="w-full max-w-md relative mt-4">
185
- <input
186
- value={chatInput}
187
- onChange={(e) => setChatInput(e.target.value)}
188
- onKeyDown={(e) => {
189
- if (e.key === 'Enter' && !e.shiftKey) {
190
- e.preventDefault();
191
- sendMessage(chatInput);
192
- setChatInput('');
193
- }
194
- }}
195
- placeholder={chatPlaceholder ?? `Ask ${buddyName} a question…`}
196
- disabled={isLoading}
197
- className="w-full bg-gray-800/50 border border-gray-700 rounded-lg px-3 py-2 pr-16 text-sm text-gray-200 placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-60"
198
- />
199
- <div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
200
- <VoiceRecorder
201
- iconSize="14"
202
- className="p-1 text-gray-400 hover:text-white transition-colors"
203
- disabled={isLoading}
204
- onVoiceRecorded={(text) => sendMessage(text)}
205
- onRecordingStatusChange={() => {}}
206
- />
207
- <div className="w-px h-3.5 bg-gray-600" />
208
- <button
209
- type="button"
210
- onClick={() => {
211
- sendMessage(chatInput);
212
- setChatInput('');
213
- }}
214
- disabled={isLoading || !chatInput.trim()}
215
- className="p-1 text-gray-400 hover:text-white disabled:opacity-40 transition-colors"
216
- >
217
- <BiSolidRightArrow className="w-4 h-4" />
218
- </button>
219
- </div>
220
- </div>
221
-
222
- {/* Optional bottom action (e.g. a CTA button) */}
223
- {bottomAction && <div className="w-full max-w-md border-t border-gray-700/60 pt-3">{bottomAction}</div>}
224
- </div>
225
- );
226
- }