@rimori/react-client 0.4.11 → 0.4.12-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.
@@ -10,6 +10,8 @@ interface Props {
10
10
  autoStartConversation?: FirstMessages;
11
11
  className?: string;
12
12
  knowledgeId?: string;
13
+ /** Set to true to disable automatic dialect TTS from userInfo. Default: false (dialect enabled). */
14
+ disableDialect?: boolean;
13
15
  }
14
- export declare function Avatar({ avatarImageUrl, voiceId, agentTools, autoStartConversation, children, circleSize, className, cache, knowledgeId, }: Props): import("react/jsx-runtime").JSX.Element;
16
+ export declare function Avatar({ avatarImageUrl, voiceId, agentTools, autoStartConversation, children, circleSize, className, cache, knowledgeId, disableDialect, }: Props): import("react/jsx-runtime").JSX.Element;
15
17
  export {};
@@ -7,12 +7,18 @@ import { useChat } from '../../hooks/UseChatHook';
7
7
  import { useRimori } from '../../providers/PluginProvider';
8
8
  import { getFirstMessages } from './utils';
9
9
  import { useTheme } from '../../hooks/ThemeSetter';
10
- export function Avatar({ avatarImageUrl, voiceId, agentTools, autoStartConversation, children, circleSize = '300px', className, cache = false, knowledgeId, }) {
11
- const { ai, event, plugin } = useRimori();
10
+ export function Avatar({ avatarImageUrl, voiceId, agentTools, autoStartConversation, children, circleSize = '300px', className, cache = false, knowledgeId, disableDialect = false, }) {
11
+ const { ai, event, plugin, userInfo } = useRimori();
12
12
  const { isDark: isDarkThemeValue } = useTheme(plugin.theme);
13
13
  const [agentReplying, setAgentReplying] = useState(false);
14
14
  const [isProcessingMessage, setIsProcessingMessage] = useState(false);
15
+ const dialectTtsInstruction = !disableDialect && (userInfo === null || userInfo === void 0 ? void 0 : userInfo.dialect)
16
+ ? `Speak with a ${userInfo.dialect} accent and pronunciation.`
17
+ : undefined;
15
18
  const sender = useMemo(() => new MessageSender((...args) => ai.getVoice(...args), voiceId, cache), [voiceId, ai, cache]);
19
+ useEffect(() => {
20
+ sender.setInstructions(dialectTtsInstruction);
21
+ }, [sender, dialectTtsInstruction]);
16
22
  const { messages, append, isLoading, lastMessage, setMessages } = useChat(agentTools, { knowledgeId });
17
23
  useEffect(() => {
18
24
  console.log('messages', messages);
@@ -0,0 +1,21 @@
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
+ systemPrompt: string;
11
+ autoStartConversation?: BuddyAssistantAutoStart;
12
+ circleSize?: string;
13
+ chatPlaceholder?: string;
14
+ bottomAction?: React.ReactNode;
15
+ className?: string;
16
+ voiceSpeed?: number;
17
+ tools?: Tool[];
18
+ /** Set to true to disable automatic dialect from userInfo. Default: false (dialect enabled). */
19
+ disableDialect?: boolean;
20
+ }
21
+ export declare function BuddyAssistant({ systemPrompt, autoStartConversation, circleSize, chatPlaceholder, bottomAction, className, voiceSpeed, tools, disableDialect, }: BuddyAssistantProps): JSX.Element;
@@ -0,0 +1,115 @@
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({ systemPrompt, autoStartConversation, circleSize = '160px', chatPlaceholder, bottomAction, className, voiceSpeed = 1, tools, disableDialect = false, }) {
13
+ var _a;
14
+ const { ai, event, plugin, userInfo } = useRimori();
15
+ const { isDark } = useTheme(plugin.theme);
16
+ const buddy = (_a = plugin.getUserInfo()) === null || _a === void 0 ? void 0 : _a.study_buddy;
17
+ const dialect = !disableDialect ? userInfo === null || userInfo === void 0 ? void 0 : userInfo.dialect : undefined;
18
+ const dialectSystemSuffix = dialect
19
+ ? `\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.`
20
+ : '';
21
+ const dialectTtsInstruction = dialect ? `Speak with a ${dialect} accent and pronunciation.` : undefined;
22
+ const [ttsEnabled, setTtsEnabled] = useState(true);
23
+ const [chatInput, setChatInput] = useState('');
24
+ const [messages, setMessages] = useState([]);
25
+ const [isLoading, setIsLoading] = useState(false);
26
+ const [isSpeaking, setIsSpeaking] = useState(false);
27
+ const ttsEnabledRef = useRef(ttsEnabled);
28
+ useEffect(() => {
29
+ ttsEnabledRef.current = ttsEnabled;
30
+ }, [ttsEnabled]);
31
+ const sender = useMemo(() => { var _a; return new MessageSender((...args) => ai.getVoice(...args), (_a = buddy === null || buddy === void 0 ? void 0 : buddy.voiceId) !== null && _a !== void 0 ? _a : ''); }, [buddy === null || buddy === void 0 ? void 0 : buddy.voiceId, ai]);
32
+ useEffect(() => {
33
+ sender.setInstructions(dialectTtsInstruction);
34
+ }, [sender, dialectTtsInstruction]);
35
+ // Setup sender callbacks and cleanup
36
+ useEffect(() => {
37
+ sender.setVoiceSpeed(voiceSpeed);
38
+ sender.setOnLoudnessChange((value) => event.emit('self.avatar.triggerLoudness', { loudness: value }));
39
+ sender.setOnEndOfSpeech(() => setIsSpeaking(false));
40
+ return () => sender.cleanup();
41
+ }, [sender]);
42
+ // Build full API message list with system prompt (dialect appended when enabled)
43
+ const buildApiMessages = (history) => [
44
+ { role: 'system', content: systemPrompt + dialectSystemSuffix },
45
+ ...history.map((m) => ({ role: m.role, content: m.content })),
46
+ ];
47
+ const triggerAI = (history) => {
48
+ setIsLoading(true);
49
+ void ai.getSteamedText(buildApiMessages(history), (id, partial, finished) => {
50
+ setIsLoading(!finished);
51
+ const assistantId = `ai-${id}`;
52
+ setMessages((prev) => {
53
+ const last = prev[prev.length - 1];
54
+ if ((last === null || last === void 0 ? void 0 : last.id) === assistantId) {
55
+ return [...prev.slice(0, -1), Object.assign(Object.assign({}, last), { content: partial })];
56
+ }
57
+ return [...prev, { id: assistantId, role: 'assistant', content: partial }];
58
+ });
59
+ if (ttsEnabledRef.current) {
60
+ void sender.handleNewText(partial, !finished);
61
+ if (partial)
62
+ setIsSpeaking(true);
63
+ }
64
+ }, tools);
65
+ };
66
+ // Auto-start conversation on mount
67
+ const autoStartedRef = useRef(false);
68
+ useEffect(() => {
69
+ if (autoStartedRef.current)
70
+ return;
71
+ autoStartedRef.current = true;
72
+ if (autoStartConversation === null || autoStartConversation === void 0 ? void 0 : autoStartConversation.assistantMessage) {
73
+ const initMsg = { id: 'init', role: 'assistant', content: autoStartConversation.assistantMessage };
74
+ setMessages([initMsg]);
75
+ if (ttsEnabledRef.current) {
76
+ void sender.handleNewText(autoStartConversation.assistantMessage, false);
77
+ setIsSpeaking(true);
78
+ }
79
+ }
80
+ else if (autoStartConversation === null || autoStartConversation === void 0 ? void 0 : autoStartConversation.userMessage) {
81
+ const userMsg = { id: 'auto-start', role: 'user', content: autoStartConversation.userMessage };
82
+ setMessages([userMsg]);
83
+ triggerAI([userMsg]);
84
+ }
85
+ // eslint-disable-next-line react-hooks/exhaustive-deps
86
+ }, []);
87
+ if (!buddy)
88
+ return _jsx("div", {});
89
+ const sendMessage = (text) => {
90
+ if (!text.trim() || isLoading)
91
+ return;
92
+ const userMsg = { id: genId(), role: 'user', content: text };
93
+ const newMessages = [...messages, userMsg];
94
+ setMessages(newMessages);
95
+ triggerAI(newMessages);
96
+ };
97
+ const handleToggleTts = () => {
98
+ if (ttsEnabled && isSpeaking) {
99
+ sender.stop();
100
+ setIsSpeaking(false);
101
+ }
102
+ setTtsEnabled((prev) => !prev);
103
+ };
104
+ const lastAssistantMessage = [...messages].filter((m) => m.role === 'assistant').pop();
105
+ return (_jsxs("div", { className: `flex flex-col items-center ${className || ''}`, children: [_jsx(CircleAudioAvatar, { width: circleSize, imageUrl: buddy.avatarUrl, isDarkTheme: isDark, className: "mx-auto" }), _jsxs("div", { className: "flex items-center gap-2 pl-10", children: [_jsx("span", { className: "text-3xl font-semibold", children: buddy.name }), _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) => {
106
+ if (e.key === 'Enter' && !e.shiftKey) {
107
+ e.preventDefault();
108
+ sendMessage(chatInput);
109
+ setChatInput('');
110
+ }
111
+ }, placeholder: chatPlaceholder !== null && chatPlaceholder !== void 0 ? chatPlaceholder : `Ask ${buddy.name} 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: () => {
112
+ sendMessage(chatInput);
113
+ setChatInput('');
114
+ }, 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 })] }));
115
+ }
@@ -9,6 +9,11 @@ type AudioPlayerProps = {
9
9
  initialSpeed?: number;
10
10
  enableSpeedAdjustment?: boolean;
11
11
  playListenerEvent?: string;
12
+ size?: string;
13
+ /** Explicit TTS instruction string. If provided, overrides auto-dialect. */
14
+ ttsInstructions?: string;
15
+ /** Set to true to disable automatic dialect from userInfo. Default: false (dialect enabled). */
16
+ disableDialect?: boolean;
12
17
  };
13
18
  export declare const AudioPlayOptions: number[];
14
19
  export type AudioPlayOptionType = 0.8 | 0.9 | 1.0 | 1.1 | 1.2 | 1.5;
@@ -14,12 +14,12 @@ 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, }) => {
17
+ export const AudioPlayer = ({ text, voice, language, hide, playListenerEvent, initialSpeed = 1.0, playOnMount = false, enableSpeedAdjustment = false, cache = true, size = '25px', ttsInstructions, disableDialect = false, }) => {
18
18
  const [audioUrl, setAudioUrl] = useState(null);
19
19
  const [speed, setSpeed] = useState(initialSpeed);
20
20
  const [isPlaying, setIsPlaying] = useState(false);
21
21
  const [isLoading, setIsLoading] = useState(false);
22
- const { ai } = useRimori();
22
+ const { ai, userInfo } = useRimori();
23
23
  const audioRef = useRef(null);
24
24
  const eventBusListenerRef = useRef(null);
25
25
  useEffect(() => {
@@ -33,7 +33,8 @@ export const AudioPlayer = ({ text, voice, language, hide, playListenerEvent, in
33
33
  // Function to generate audio from text using API
34
34
  const generateAudio = () => __awaiter(void 0, void 0, void 0, function* () {
35
35
  setIsLoading(true);
36
- const blob = yield ai.getVoice(text, voice || (language ? 'aws_default' : 'openai_alloy'), 1, language, cache);
36
+ const effectiveInstructions = ttsInstructions !== null && ttsInstructions !== void 0 ? ttsInstructions : (!disableDialect && (userInfo === null || userInfo === void 0 ? void 0 : userInfo.dialect) ? `Speak with a ${userInfo.dialect} accent and pronunciation.` : undefined);
37
+ const blob = yield ai.getVoice(text, voice || (language ? 'aws_default' : 'openai_alloy'), 1, language, cache, effectiveInstructions);
37
38
  setAudioUrl(URL.createObjectURL(blob));
38
39
  setIsLoading(false);
39
40
  });
@@ -129,7 +130,7 @@ export const AudioPlayer = ({ text, voice, language, hide, playListenerEvent, in
129
130
  // console.log("playOnMount", playOnMount);
130
131
  togglePlayback();
131
132
  }, [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: "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
+ 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))) })] }))] }) }));
133
134
  };
134
135
  const Spinner = ({ text, className, size = '30px' }) => {
135
136
  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,6 +12,7 @@ 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';
15
16
  import Table from '@tiptap/extension-table';
16
17
  import TableCell from '@tiptap/extension-table-cell';
17
18
  import TableHeader from '@tiptap/extension-table-header';
@@ -23,9 +24,31 @@ import { PiCodeBlock } from 'react-icons/pi';
23
24
  import { TbBlockquote, TbTable, TbColumnInsertRight, TbRowInsertBottom, TbColumnRemove, TbRowRemove, TbArrowMergeBoth, TbBrandYoutube, TbPhoto, } from 'react-icons/tb';
24
25
  import { GoListOrdered } from 'react-icons/go';
25
26
  import { AiOutlineUnorderedList } from 'react-icons/ai';
26
- import { LuClipboardPaste, LuHeading1, LuHeading2, LuHeading3, LuLink, LuUnlink } from 'react-icons/lu';
27
+ import { LuClipboardPaste, LuHeading1, LuHeading2, LuHeading3, LuLink, LuUnlink, LuCopy, LuCheck, LuMaximize2, LuMinimize2, } from 'react-icons/lu';
27
28
  import { FaBold, FaCode, FaItalic, FaParagraph, FaStrikethrough } from 'react-icons/fa';
28
29
  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
+ });
29
52
  function getMarkdown(editor) {
30
53
  return editor.storage.markdown.getMarkdown();
31
54
  }
@@ -103,7 +126,7 @@ const InlinePanel = ({ panel, onClose, editor, onUpdate, labels }) => {
103
126
  ? labels.addYoutubeConfirm
104
127
  : labels.appendMarkdownConfirm })] })] }));
105
128
  };
106
- const MenuBar = ({ editor, onUpdate, uploadImage, labels }) => {
129
+ const MenuBar = ({ editor, onUpdate, uploadImage, labels, onCopy, copied, isFullscreen, onToggleFullscreen, }) => {
107
130
  const [activePanel, setActivePanel] = useState(null);
108
131
  const toggle = (panel) => setActivePanel((prev) => (prev === panel ? null : panel));
109
132
  const inTable = editor.isActive('table');
@@ -112,7 +135,7 @@ const MenuBar = ({ editor, onUpdate, uploadImage, labels }) => {
112
135
  'text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-40 disabled:pointer-events-none';
113
136
  return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "bg-muted/50 border-b border-border text-base flex flex-row flex-wrap items-center gap-0.5 p-1.5", children: [_jsx(EditorButton, { editor: editor, action: "toggleBold", isActive: editor.isActive('bold'), label: _jsx(FaBold, {}), disabled: true, title: labels.bold }), _jsx(EditorButton, { editor: editor, action: "toggleItalic", isActive: editor.isActive('italic'), label: _jsx(FaItalic, {}), disabled: true, title: labels.italic }), _jsx(EditorButton, { editor: editor, action: "toggleStrike", isActive: editor.isActive('strike'), label: _jsx(FaStrikethrough, {}), disabled: true, title: labels.strike }), _jsx(EditorButton, { editor: editor, action: "toggleCode", isActive: editor.isActive('code'), label: _jsx(FaCode, {}), disabled: true, title: labels.code }), _jsx(EditorButton, { editor: editor, action: "setParagraph", isActive: editor.isActive('paragraph'), label: _jsx(FaParagraph, {}), title: labels.paragraph }), _jsx(EditorButton, { editor: editor, action: "setHeading1", isActive: editor.isActive('heading', { level: 1 }), label: _jsx(LuHeading1, { size: "24px" }), title: labels.heading1 }), _jsx(EditorButton, { editor: editor, action: "setHeading2", isActive: editor.isActive('heading', { level: 2 }), label: _jsx(LuHeading2, { size: "24px" }), title: labels.heading2 }), _jsx(EditorButton, { editor: editor, action: "setHeading3", isActive: editor.isActive('heading', { level: 3 }), label: _jsx(LuHeading3, { size: "24px" }), title: labels.heading3 }), _jsx(EditorButton, { editor: editor, action: "toggleBulletList", isActive: editor.isActive('bulletList'), label: _jsx(AiOutlineUnorderedList, { size: "24px" }), title: labels.bulletList }), _jsx(EditorButton, { editor: editor, action: "toggleOrderedList", isActive: editor.isActive('orderedList'), label: _jsx(GoListOrdered, { size: "24px" }), title: labels.orderedList }), _jsx(EditorButton, { editor: editor, action: "toggleCodeBlock", isActive: editor.isActive('codeBlock'), label: _jsx(PiCodeBlock, { size: "24px" }), title: labels.codeBlock }), _jsx(EditorButton, { editor: editor, action: "toggleBlockquote", isActive: editor.isActive('blockquote'), label: _jsx(TbBlockquote, { size: "24px" }), title: labels.blockquote }), _jsx("div", { className: "w-px h-5 bg-border mx-0.5" }), _jsxs("div", { className: "inline-flex rounded-md border border-border bg-transparent overflow-hidden", children: [_jsx("button", { type: "button", onClick: () => toggle('link'), title: labels.setLink, className: 'w-8 h-8 flex items-center justify-center rounded-none first:rounded-l-md last:rounded-r-md transition-colors duration-150 ' +
114
137
  'text-muted-foreground hover:bg-accent hover:text-accent-foreground border-r border-border last:border-r-0' +
115
- (isLink ? ' bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground' : ''), children: _jsx(LuLink, { size: 18 }) }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().unsetLink().run(), disabled: !isLink, title: labels.unsetLink, className: "w-8 h-8 flex items-center justify-center rounded-none first:rounded-l-md last:rounded-r-md transition-colors duration-150 text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-40 disabled:pointer-events-none border-r border-border last:border-r-0", children: _jsx(LuUnlink, { size: 18 }) })] }), _jsx("button", { type: "button", onClick: () => toggle('youtube'), className: tableBtnClass, title: labels.addYoutube, children: _jsx(TbBrandYoutube, { size: 18 }) }), uploadImage && (_jsx("button", { type: "button", onClick: () => triggerImageUpload(uploadImage, editor), className: tableBtnClass, title: labels.insertImage, children: _jsx(TbPhoto, { size: 18 }) })), _jsx("div", { className: "w-px h-5 bg-border mx-0.5" }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(), className: tableBtnClass, title: labels.insertTable, children: _jsx(TbTable, { size: 18 }) }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().addColumnAfter().run(), className: tableBtnClass, disabled: !inTable, title: labels.addColumnAfter, children: _jsx(TbColumnInsertRight, { size: 18 }) }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().addRowAfter().run(), className: tableBtnClass, disabled: !inTable, title: labels.addRowAfter, children: _jsx(TbRowInsertBottom, { size: 18 }) }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().deleteColumn().run(), className: tableBtnClass, disabled: !inTable, title: labels.deleteColumn, children: _jsx(TbColumnRemove, { size: 18 }) }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().deleteRow().run(), className: tableBtnClass, disabled: !inTable, title: labels.deleteRow, children: _jsx(TbRowRemove, { size: 18 }) }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().mergeOrSplit().run(), className: tableBtnClass, disabled: !inTable, title: labels.mergeOrSplit, children: _jsx(TbArrowMergeBoth, { size: 18 }) }), _jsx("div", { className: "w-px h-5 bg-border mx-0.5" }), _jsx("button", { type: "button", onClick: () => toggle('markdown'), className: tableBtnClass, title: labels.appendMarkdown, children: _jsx(LuClipboardPaste, { size: 18 }) })] }), _jsx(InlinePanel, { panel: activePanel, onClose: () => setActivePanel(null), editor: editor, onUpdate: onUpdate, labels: labels })] }));
138
+ (isLink ? ' bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground' : ''), children: _jsx(LuLink, { size: 18 }) }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().unsetLink().run(), disabled: !isLink, title: labels.unsetLink, className: "w-8 h-8 flex items-center justify-center rounded-none first:rounded-l-md last:rounded-r-md transition-colors duration-150 text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-40 disabled:pointer-events-none border-r border-border last:border-r-0", children: _jsx(LuUnlink, { size: 18 }) })] }), _jsx("button", { type: "button", onClick: () => toggle('youtube'), className: tableBtnClass, title: labels.addYoutube, children: _jsx(TbBrandYoutube, { size: 18 }) }), uploadImage && (_jsx("button", { type: "button", onClick: () => triggerImageUpload(uploadImage, editor), className: tableBtnClass, title: labels.insertImage, children: _jsx(TbPhoto, { size: 18 }) })), _jsx("div", { className: "w-px h-5 bg-border mx-0.5" }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(), className: tableBtnClass, title: labels.insertTable, children: _jsx(TbTable, { size: 18 }) }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().addColumnAfter().run(), className: tableBtnClass, disabled: !inTable, title: labels.addColumnAfter, children: _jsx(TbColumnInsertRight, { size: 18 }) }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().addRowAfter().run(), className: tableBtnClass, disabled: !inTable, title: labels.addRowAfter, children: _jsx(TbRowInsertBottom, { size: 18 }) }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().deleteColumn().run(), className: tableBtnClass, disabled: !inTable, title: labels.deleteColumn, children: _jsx(TbColumnRemove, { size: 18 }) }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().deleteRow().run(), className: tableBtnClass, disabled: !inTable, title: labels.deleteRow, children: _jsx(TbRowRemove, { size: 18 }) }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().mergeOrSplit().run(), className: tableBtnClass, disabled: !inTable, title: labels.mergeOrSplit, children: _jsx(TbArrowMergeBoth, { size: 18 }) }), _jsx("div", { className: "w-px h-5 bg-border mx-0.5" }), _jsx("button", { type: "button", onClick: () => toggle('markdown'), className: tableBtnClass, title: labels.appendMarkdown, children: _jsx(LuClipboardPaste, { size: 18, style: { transform: 'scaleX(-1)' } }) }), _jsxs("div", { className: 'ml-auto flex items-center gap-0.5 ' + (isFullscreen ? 'pr-10' : ''), children: [_jsx("button", { type: "button", onClick: onCopy, title: "Copy as Markdown", className: tableBtnClass, children: copied ? _jsx(LuCheck, { size: 16, className: "text-green-500" }) : _jsx(LuCopy, { size: 16 }) }), _jsx("button", { type: "button", onClick: onToggleFullscreen, title: isFullscreen ? 'Exit fullscreen' : 'Fullscreen', className: tableBtnClass, children: isFullscreen ? _jsx(LuMinimize2, { size: 16 }) : _jsx(LuMaximize2, { size: 16 }) })] })] }), _jsx(InlinePanel, { panel: activePanel, onClose: () => setActivePanel(null), editor: editor, onUpdate: onUpdate, labels: labels })] }));
116
139
  };
117
140
  const DEFAULT_LABELS = {
118
141
  bold: 'Bold',
@@ -153,6 +176,8 @@ export const MarkdownEditor = ({ content, editable, className, onUpdate, labels,
153
176
  const { storage } = useRimori();
154
177
  const mergedLabels = Object.assign(Object.assign({}, DEFAULT_LABELS), labels);
155
178
  const lastEmittedRef = useRef(content !== null && content !== void 0 ? content : '');
179
+ const [isFullscreen, setIsFullscreen] = useState(false);
180
+ const [copied, setCopied] = useState(false);
156
181
  const stableUpload = useCallback((pngBlob) => __awaiter(void 0, void 0, void 0, function* () {
157
182
  const { data, error } = yield storage.uploadImage(pngBlob);
158
183
  if (error)
@@ -160,7 +185,8 @@ export const MarkdownEditor = ({ content, editable, className, onUpdate, labels,
160
185
  return data.url;
161
186
  }), [storage]);
162
187
  const extensions = useMemo(() => [
163
- StarterKit,
188
+ StarterKit.configure({ paragraph: false }),
189
+ ParagraphPreserveEmpty,
164
190
  Table.configure({ resizable: false }),
165
191
  TableRow,
166
192
  TableHeader,
@@ -184,7 +210,16 @@ export const MarkdownEditor = ({ content, editable, className, onUpdate, labels,
184
210
  onUpdate === null || onUpdate === void 0 ? void 0 : onUpdate(markdown);
185
211
  },
186
212
  });
187
- // Sync external content changes (e.g. AI autofill) without triggering update loop
213
+ const handleCopy = useCallback(() => {
214
+ if (!editor)
215
+ return;
216
+ navigator.clipboard.writeText(getMarkdown(editor)).then(() => {
217
+ setCopied(true);
218
+ setTimeout(() => setCopied(false), 2000);
219
+ });
220
+ }, [editor]);
221
+ // Sync external content changes (e.g. AI autofill) without triggering update loop.
222
+ // Pass false to setContent to prevent onUpdate from firing and re-normalizing the content.
188
223
  useEffect(() => {
189
224
  if (!editor)
190
225
  return;
@@ -192,7 +227,7 @@ export const MarkdownEditor = ({ content, editable, className, onUpdate, labels,
192
227
  if (incoming === lastEmittedRef.current)
193
228
  return;
194
229
  lastEmittedRef.current = incoming;
195
- editor.commands.setContent(incoming);
230
+ editor.commands.setContent(incoming, false);
196
231
  }, [editor, content]);
197
232
  // Sync editable prop
198
233
  useEffect(() => {
@@ -200,8 +235,11 @@ export const MarkdownEditor = ({ content, editable, className, onUpdate, labels,
200
235
  return;
201
236
  editor.setEditable(editable);
202
237
  }, [editor, editable]);
203
- return (_jsxs("div", { className: 'text-md overflow-hidden rounded-lg ' +
238
+ const wrapperClass = isFullscreen
239
+ ? 'text-md overflow-hidden rounded-lg border border-border bg-card shadow-sm fixed inset-0 z-50 flex flex-col'
240
+ : 'text-md overflow-hidden rounded-lg ' +
204
241
  (editable ? 'border border-border bg-card shadow-sm' : 'bg-transparent') +
205
242
  ' ' +
206
- (className !== null && className !== void 0 ? className : ''), onClick: onContentClick, children: [editor && editable && (_jsx(MenuBar, { editor: editor, onUpdate: onUpdate !== null && onUpdate !== void 0 ? onUpdate : (() => { }), uploadImage: stableUpload, labels: mergedLabels })), _jsx(EditorContent, { editor: editor })] }));
243
+ (className !== null && className !== void 0 ? className : '');
244
+ return (_jsxs("div", { className: wrapperClass, onClick: onContentClick, children: [editor && editable && (_jsx(MenuBar, { editor: editor, onUpdate: onUpdate !== null && onUpdate !== void 0 ? onUpdate : (() => { }), uploadImage: stableUpload, labels: mergedLabels, onCopy: handleCopy, copied: copied, isFullscreen: isFullscreen, onToggleFullscreen: () => setIsFullscreen((v) => !v) })), _jsx(EditorContent, { editor: editor, className: isFullscreen ? 'flex-1 overflow-y-auto' : '' })] }));
207
245
  };
@@ -1,13 +1,11 @@
1
1
  export function useTheme(theme = 'system') {
2
2
  const isDark = theme === 'system' ? systenUsesDarkMode() : theme === 'dark';
3
3
  const dom = document.documentElement;
4
- dom.style.background = 'hsl(var(--background))';
5
- dom.classList.add('text-gray-900', 'dark:text-gray-200', 'bg-gray-50', 'dark:bg-gray-950');
6
4
  dom.dataset.theme = isDark ? 'dark' : 'light';
7
5
  dom.style.colorScheme = isDark ? 'dark' : 'light';
8
- const root = document.querySelector('#root');
9
- root.style.background = 'hsl(var(--background))';
10
6
  dom.classList[isDark ? 'add' : 'remove']('dark');
7
+ const root = document.querySelector('#root');
8
+ root.classList.add('dark:bg-gradient-to-br', 'dark:from-gray-900', 'dark:to-gray-800', 'dark:text-white', 'min-h-screen', 'bg-fixed');
11
9
  return { isDark, theme };
12
10
  }
13
11
  function systenUsesDarkMode() {
package/dist/index.d.ts CHANGED
@@ -5,6 +5,8 @@ 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';
8
10
  export { VoiceRecorder } from './components/ai/EmbeddedAssistent/VoiceRecorder';
9
11
  export { MarkdownEditor } from './components/editor/MarkdownEditor';
10
12
  export type { MarkdownEditorProps, EditorLabels } from './components/editor/MarkdownEditor';
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ 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';
8
9
  export { VoiceRecorder } from './components/ai/EmbeddedAssistent/VoiceRecorder';
9
10
  export { MarkdownEditor } from './components/editor/MarkdownEditor';
10
11
  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",
3
+ "version": "0.4.12-next.0",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,12 +24,13 @@
24
24
  "format": "prettier --write ."
25
25
  },
26
26
  "peerDependencies": {
27
- "@rimori/client": "^2.5.19",
27
+ "@rimori/client": "^2.5.21",
28
28
  "react": "^18.1.0",
29
29
  "react-dom": "^18.1.0"
30
30
  },
31
31
  "dependencies": {
32
32
  "@tiptap/core": "^2.26.1",
33
+ "@tiptap/extension-paragraph": "^2.26.1",
33
34
  "@tiptap/extension-image": "^2.26.1",
34
35
  "@tiptap/extension-link": "^2.26.1",
35
36
  "@tiptap/extension-table": "^2.26.1",
@@ -46,7 +47,7 @@
46
47
  },
47
48
  "devDependencies": {
48
49
  "@eslint/js": "^9.37.0",
49
- "@rimori/client": "^2.5.19",
50
+ "@rimori/client": "^2.5.21",
50
51
  "@types/react": "^18.3.21",
51
52
  "eslint-config-prettier": "^10.1.8",
52
53
  "eslint-plugin-prettier": "^5.5.4",
@@ -19,6 +19,8 @@ interface Props {
19
19
  autoStartConversation?: FirstMessages;
20
20
  className?: string;
21
21
  knowledgeId?: string;
22
+ /** Set to true to disable automatic dialect TTS from userInfo. Default: false (dialect enabled). */
23
+ disableDialect?: boolean;
22
24
  }
23
25
 
24
26
  export function Avatar({
@@ -31,15 +33,23 @@ export function Avatar({
31
33
  className,
32
34
  cache = false,
33
35
  knowledgeId,
36
+ disableDialect = false,
34
37
  }: Props) {
35
- const { ai, event, plugin } = useRimori();
38
+ const { ai, event, plugin, userInfo } = useRimori();
36
39
  const { isDark: isDarkThemeValue } = useTheme(plugin.theme);
37
40
  const [agentReplying, setAgentReplying] = useState(false);
38
41
  const [isProcessingMessage, setIsProcessingMessage] = useState(false);
42
+ const dialectTtsInstruction = !disableDialect && userInfo?.dialect
43
+ ? `Speak with a ${userInfo.dialect} accent and pronunciation.`
44
+ : undefined;
39
45
  const sender = useMemo(
40
46
  () => new MessageSender((...args) => ai.getVoice(...args), voiceId, cache),
41
47
  [voiceId, ai, cache],
42
48
  );
49
+
50
+ useEffect(() => {
51
+ sender.setInstructions(dialectTtsInstruction);
52
+ }, [sender, dialectTtsInstruction]);
43
53
  const { messages, append, isLoading, lastMessage, setMessages } = useChat(agentTools, { knowledgeId });
44
54
 
45
55
  useEffect(() => {
@@ -0,0 +1,239 @@
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
+ systemPrompt: string;
21
+ autoStartConversation?: BuddyAssistantAutoStart;
22
+ circleSize?: string;
23
+ chatPlaceholder?: string;
24
+ bottomAction?: React.ReactNode;
25
+ className?: string;
26
+ voiceSpeed?: number;
27
+ tools?: Tool[];
28
+ /** Set to true to disable automatic dialect from userInfo. Default: false (dialect enabled). */
29
+ disableDialect?: boolean;
30
+ }
31
+
32
+ let idCounter = 0;
33
+ const genId = () => `ba-${++idCounter}`;
34
+
35
+ export function BuddyAssistant({
36
+ systemPrompt,
37
+ autoStartConversation,
38
+ circleSize = '160px',
39
+ chatPlaceholder,
40
+ bottomAction,
41
+ className,
42
+ voiceSpeed = 1,
43
+ tools,
44
+ disableDialect = false,
45
+ }: BuddyAssistantProps): JSX.Element {
46
+ 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
+ : '';
53
+ const dialectTtsInstruction = dialect ? `Speak with a ${dialect} accent and pronunciation.` : undefined;
54
+
55
+ const [ttsEnabled, setTtsEnabled] = useState(true);
56
+ const [chatInput, setChatInput] = useState('');
57
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
58
+ const [isLoading, setIsLoading] = useState(false);
59
+ const [isSpeaking, setIsSpeaking] = useState(false);
60
+
61
+ const ttsEnabledRef = useRef(ttsEnabled);
62
+ useEffect(() => {
63
+ ttsEnabledRef.current = ttsEnabled;
64
+ }, [ttsEnabled]);
65
+
66
+ const sender = useMemo(
67
+ () => new MessageSender((...args) => ai.getVoice(...args), buddy?.voiceId ?? ''),
68
+ [buddy?.voiceId, ai],
69
+ );
70
+
71
+ useEffect(() => {
72
+ sender.setInstructions(dialectTtsInstruction);
73
+ }, [sender, dialectTtsInstruction]);
74
+
75
+ // Setup sender callbacks and cleanup
76
+ useEffect(() => {
77
+ sender.setVoiceSpeed(voiceSpeed);
78
+ sender.setOnLoudnessChange((value: number) => event.emit('self.avatar.triggerLoudness', { loudness: value }));
79
+ sender.setOnEndOfSpeech(() => setIsSpeaking(false));
80
+ return () => sender.cleanup();
81
+ }, [sender]);
82
+
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
+ ];
88
+
89
+ const triggerAI = (history: ChatMessage[]) => {
90
+ setIsLoading(true);
91
+ void ai.getSteamedText(
92
+ buildApiMessages(history),
93
+ (id: string, partial: string, finished: boolean) => {
94
+ setIsLoading(!finished);
95
+ const assistantId = `ai-${id}`;
96
+ setMessages((prev) => {
97
+ const last = prev[prev.length - 1];
98
+ if (last?.id === assistantId) {
99
+ return [...prev.slice(0, -1), { ...last, content: partial }];
100
+ }
101
+ return [...prev, { id: assistantId, role: 'assistant', content: partial }];
102
+ });
103
+ if (ttsEnabledRef.current) {
104
+ void sender.handleNewText(partial, !finished);
105
+ if (partial) setIsSpeaking(true);
106
+ }
107
+ },
108
+ tools,
109
+ );
110
+ };
111
+
112
+ // Auto-start conversation on mount
113
+ const autoStartedRef = useRef(false);
114
+ useEffect(() => {
115
+ if (autoStartedRef.current) return;
116
+ autoStartedRef.current = true;
117
+
118
+ if (autoStartConversation?.assistantMessage) {
119
+ const initMsg: ChatMessage = { id: 'init', role: 'assistant', content: autoStartConversation.assistantMessage };
120
+ setMessages([initMsg]);
121
+ if (ttsEnabledRef.current) {
122
+ void sender.handleNewText(autoStartConversation.assistantMessage, false);
123
+ setIsSpeaking(true);
124
+ }
125
+ } else if (autoStartConversation?.userMessage) {
126
+ const userMsg: ChatMessage = { id: 'auto-start', role: 'user', content: autoStartConversation.userMessage };
127
+ setMessages([userMsg]);
128
+ triggerAI([userMsg]);
129
+ }
130
+ // eslint-disable-next-line react-hooks/exhaustive-deps
131
+ }, []);
132
+
133
+ if (!buddy) return <div />;
134
+
135
+ const sendMessage = (text: string) => {
136
+ if (!text.trim() || isLoading) return;
137
+ const userMsg: ChatMessage = { id: genId(), role: 'user', content: text };
138
+ const newMessages = [...messages, userMsg];
139
+ setMessages(newMessages);
140
+ triggerAI(newMessages);
141
+ };
142
+
143
+ const handleToggleTts = () => {
144
+ if (ttsEnabled && isSpeaking) {
145
+ sender.stop();
146
+ setIsSpeaking(false);
147
+ }
148
+ setTtsEnabled((prev) => !prev);
149
+ };
150
+
151
+ const lastAssistantMessage = [...messages].filter((m) => m.role === 'assistant').pop();
152
+
153
+ return (
154
+ <div className={`flex flex-col items-center ${className || ''}`}>
155
+ {/* Animated circle avatar */}
156
+ <CircleAudioAvatar width={circleSize} imageUrl={buddy.avatarUrl} isDarkTheme={isDark} className="mx-auto" />
157
+
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>
174
+
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">
178
+ {!lastAssistantMessage?.content && isLoading ? (
179
+ <span className="inline-flex gap-1 py-0.5">
180
+ <span className="w-1.5 h-1.5 rounded-full bg-gray-400 animate-bounce" />
181
+ <span
182
+ className="w-1.5 h-1.5 rounded-full bg-gray-400 animate-bounce"
183
+ style={{ animationDelay: '0.15s' }}
184
+ />
185
+ <span
186
+ className="w-1.5 h-1.5 rounded-full bg-gray-400 animate-bounce"
187
+ style={{ animationDelay: '0.3s' }}
188
+ />
189
+ </span>
190
+ ) : (
191
+ <span className="whitespace-pre-wrap">{lastAssistantMessage?.content}</span>
192
+ )}
193
+ </div>
194
+ )}
195
+
196
+ {/* Chat input */}
197
+ <div className="w-full max-w-md relative mt-4">
198
+ <input
199
+ value={chatInput}
200
+ onChange={(e) => setChatInput(e.target.value)}
201
+ onKeyDown={(e) => {
202
+ if (e.key === 'Enter' && !e.shiftKey) {
203
+ e.preventDefault();
204
+ sendMessage(chatInput);
205
+ setChatInput('');
206
+ }
207
+ }}
208
+ placeholder={chatPlaceholder ?? `Ask ${buddy.name} a question…`}
209
+ disabled={isLoading}
210
+ 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"
211
+ />
212
+ <div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
213
+ <VoiceRecorder
214
+ iconSize="14"
215
+ className="p-1 text-gray-400 hover:text-white transition-colors"
216
+ disabled={isLoading}
217
+ onVoiceRecorded={(text) => sendMessage(text)}
218
+ onRecordingStatusChange={() => {}}
219
+ />
220
+ <div className="w-px h-3.5 bg-gray-600" />
221
+ <button
222
+ type="button"
223
+ onClick={() => {
224
+ sendMessage(chatInput);
225
+ setChatInput('');
226
+ }}
227
+ disabled={isLoading || !chatInput.trim()}
228
+ className="p-1 text-gray-400 hover:text-white disabled:opacity-40 transition-colors"
229
+ >
230
+ <BiSolidRightArrow className="w-4 h-4" />
231
+ </button>
232
+ </div>
233
+ </div>
234
+
235
+ {/* Optional bottom action (e.g. a CTA button) */}
236
+ {bottomAction && <div className="w-full max-w-md border-t border-gray-700/60 pt-3">{bottomAction}</div>}
237
+ </div>
238
+ );
239
+ }
@@ -13,6 +13,11 @@ type AudioPlayerProps = {
13
13
  initialSpeed?: number;
14
14
  enableSpeedAdjustment?: boolean;
15
15
  playListenerEvent?: string;
16
+ size?: string;
17
+ /** Explicit TTS instruction string. If provided, overrides auto-dialect. */
18
+ ttsInstructions?: string;
19
+ /** Set to true to disable automatic dialect from userInfo. Default: false (dialect enabled). */
20
+ disableDialect?: boolean;
16
21
  };
17
22
 
18
23
  export const AudioPlayOptions = [0.8, 0.9, 1.0, 1.1, 1.2, 1.5];
@@ -30,12 +35,15 @@ export const AudioPlayer: React.FC<AudioPlayerProps> = ({
30
35
  playOnMount = false,
31
36
  enableSpeedAdjustment = false,
32
37
  cache = true,
38
+ size = '25px',
39
+ ttsInstructions,
40
+ disableDialect = false,
33
41
  }) => {
34
42
  const [audioUrl, setAudioUrl] = useState<string | null>(null);
35
43
  const [speed, setSpeed] = useState(initialSpeed);
36
44
  const [isPlaying, setIsPlaying] = useState(false);
37
45
  const [isLoading, setIsLoading] = useState(false);
38
- const { ai } = useRimori();
46
+ const { ai, userInfo } = useRimori();
39
47
  const audioRef = useRef<HTMLAudioElement | null>(null);
40
48
  const eventBusListenerRef = useRef<{ off: () => void } | null>(null);
41
49
 
@@ -50,7 +58,9 @@ export const AudioPlayer: React.FC<AudioPlayerProps> = ({
50
58
  const generateAudio = async () => {
51
59
  setIsLoading(true);
52
60
 
53
- const blob = await ai.getVoice(text, voice || (language ? 'aws_default' : 'openai_alloy'), 1, language, cache);
61
+ const effectiveInstructions = ttsInstructions
62
+ ?? (!disableDialect && userInfo?.dialect ? `Speak with a ${userInfo.dialect} accent and pronunciation.` : undefined);
63
+ const blob = await ai.getVoice(text, voice || (language ? 'aws_default' : 'openai_alloy'), 1, language, cache, effectiveInstructions);
54
64
  setAudioUrl(URL.createObjectURL(blob));
55
65
  setIsLoading(false);
56
66
  };
@@ -161,11 +171,11 @@ export const AudioPlayer: React.FC<AudioPlayerProps> = ({
161
171
  {!hide && (
162
172
  <button className="text-gray-400" onClick={togglePlayback} disabled={isLoading}>
163
173
  {isLoading ? (
164
- <Spinner size="25px" />
174
+ <Spinner size={size} />
165
175
  ) : isPlaying ? (
166
- <FaStopCircle size="25px" />
176
+ <FaStopCircle size={size} />
167
177
  ) : (
168
- <FaPlayCircle size="25px" />
178
+ <FaPlayCircle size={size} />
169
179
  )}
170
180
  </button>
171
181
  )}
@@ -2,6 +2,7 @@ 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';
5
6
  import Table from '@tiptap/extension-table';
6
7
  import TableCell from '@tiptap/extension-table-cell';
7
8
  import TableHeader from '@tiptap/extension-table-header';
@@ -24,10 +25,43 @@ import {
24
25
  } from 'react-icons/tb';
25
26
  import { GoListOrdered } from 'react-icons/go';
26
27
  import { AiOutlineUnorderedList } from 'react-icons/ai';
27
- import { LuClipboardPaste, LuHeading1, LuHeading2, LuHeading3, LuLink, LuUnlink } from 'react-icons/lu';
28
+ import {
29
+ LuClipboardPaste,
30
+ LuHeading1,
31
+ LuHeading2,
32
+ LuHeading3,
33
+ LuLink,
34
+ LuUnlink,
35
+ LuCopy,
36
+ LuCheck,
37
+ LuMaximize2,
38
+ LuMinimize2,
39
+ } from 'react-icons/lu';
28
40
  import { FaBold, FaCode, FaItalic, FaParagraph, FaStrikethrough } from 'react-icons/fa';
29
41
  import { ImageUploadExtension, triggerImageUpload } from './ImageUploadExtension';
30
42
 
43
+ // Extends TipTap's Paragraph to serialize empty paragraphs as <p></p>.
44
+ // Standard markdown collapses consecutive blank lines, losing empty paragraph nodes.
45
+ // Since tiptap-markdown enables html:true by default, <p></p> survives the round-trip.
46
+ const ParagraphPreserveEmpty = Paragraph.extend({
47
+ addStorage() {
48
+ return {
49
+ markdown: {
50
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
51
+ serialize(state: any, node: any) {
52
+ if (node.childCount === 0) {
53
+ state.write('<p></p>');
54
+ } else {
55
+ state.renderInline(node);
56
+ }
57
+ state.closeBlock(node);
58
+ },
59
+ parse: {},
60
+ },
61
+ };
62
+ },
63
+ });
64
+
31
65
  function getMarkdown(editor: Editor): string {
32
66
  return (editor.storage as { markdown: { getMarkdown: () => string } }).markdown.getMarkdown();
33
67
  }
@@ -207,9 +241,22 @@ interface MenuBarProps {
207
241
  onUpdate: (content: string) => void;
208
242
  uploadImage?: (pngBlob: Blob) => Promise<string | null>;
209
243
  labels: Required<EditorLabels>;
244
+ onCopy: () => void;
245
+ copied: boolean;
246
+ isFullscreen: boolean;
247
+ onToggleFullscreen: () => void;
210
248
  }
211
249
 
212
- const MenuBar = ({ editor, onUpdate, uploadImage, labels }: MenuBarProps): JSX.Element => {
250
+ const MenuBar = ({
251
+ editor,
252
+ onUpdate,
253
+ uploadImage,
254
+ labels,
255
+ onCopy,
256
+ copied,
257
+ isFullscreen,
258
+ onToggleFullscreen,
259
+ }: MenuBarProps): JSX.Element => {
213
260
  const [activePanel, setActivePanel] = useState<PanelType>(null);
214
261
 
215
262
  const toggle = (panel: PanelType): void => setActivePanel((prev) => (prev === panel ? null : panel));
@@ -430,8 +477,22 @@ const MenuBar = ({ editor, onUpdate, uploadImage, labels }: MenuBarProps): JSX.E
430
477
  className={tableBtnClass}
431
478
  title={labels.appendMarkdown}
432
479
  >
433
- <LuClipboardPaste size={18} />
480
+ <LuClipboardPaste size={18} style={{ transform: 'scaleX(-1)' }} />
434
481
  </button>
482
+
483
+ <div className={'ml-auto flex items-center gap-0.5 ' + (isFullscreen ? 'pr-10' : '')}>
484
+ <button type="button" onClick={onCopy} title="Copy as Markdown" className={tableBtnClass}>
485
+ {copied ? <LuCheck size={16} className="text-green-500" /> : <LuCopy size={16} />}
486
+ </button>
487
+ <button
488
+ type="button"
489
+ onClick={onToggleFullscreen}
490
+ title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
491
+ className={tableBtnClass}
492
+ >
493
+ {isFullscreen ? <LuMinimize2 size={16} /> : <LuMaximize2 size={16} />}
494
+ </button>
495
+ </div>
435
496
  </div>
436
497
 
437
498
  <InlinePanel
@@ -547,6 +608,8 @@ export const MarkdownEditor = ({
547
608
  const { storage } = useRimori();
548
609
  const mergedLabels: Required<EditorLabels> = { ...DEFAULT_LABELS, ...labels };
549
610
  const lastEmittedRef = useRef(content ?? '');
611
+ const [isFullscreen, setIsFullscreen] = useState(false);
612
+ const [copied, setCopied] = useState(false);
550
613
 
551
614
  const stableUpload = useCallback(
552
615
  async (pngBlob: Blob): Promise<string | null> => {
@@ -559,7 +622,8 @@ export const MarkdownEditor = ({
559
622
 
560
623
  const extensions = useMemo(
561
624
  () => [
562
- StarterKit,
625
+ StarterKit.configure({ paragraph: false }),
626
+ ParagraphPreserveEmpty,
563
627
  Table.configure({ resizable: false }),
564
628
  TableRow,
565
629
  TableHeader,
@@ -587,13 +651,22 @@ export const MarkdownEditor = ({
587
651
  },
588
652
  });
589
653
 
590
- // Sync external content changes (e.g. AI autofill) without triggering update loop
654
+ const handleCopy = useCallback(() => {
655
+ if (!editor) return;
656
+ navigator.clipboard.writeText(getMarkdown(editor)).then(() => {
657
+ setCopied(true);
658
+ setTimeout(() => setCopied(false), 2000);
659
+ });
660
+ }, [editor]);
661
+
662
+ // Sync external content changes (e.g. AI autofill) without triggering update loop.
663
+ // Pass false to setContent to prevent onUpdate from firing and re-normalizing the content.
591
664
  useEffect(() => {
592
665
  if (!editor) return;
593
666
  const incoming = content ?? '';
594
667
  if (incoming === lastEmittedRef.current) return;
595
668
  lastEmittedRef.current = incoming;
596
- editor.commands.setContent(incoming);
669
+ editor.commands.setContent(incoming, false);
597
670
  }, [editor, content]);
598
671
 
599
672
  // Sync editable prop
@@ -602,20 +675,28 @@ export const MarkdownEditor = ({
602
675
  editor.setEditable(editable);
603
676
  }, [editor, editable]);
604
677
 
678
+ const wrapperClass = isFullscreen
679
+ ? 'text-md overflow-hidden rounded-lg border border-border bg-card shadow-sm fixed inset-0 z-50 flex flex-col'
680
+ : 'text-md overflow-hidden rounded-lg ' +
681
+ (editable ? 'border border-border bg-card shadow-sm' : 'bg-transparent') +
682
+ ' ' +
683
+ (className ?? '');
684
+
605
685
  return (
606
- <div
607
- className={
608
- 'text-md overflow-hidden rounded-lg ' +
609
- (editable ? 'border border-border bg-card shadow-sm' : 'bg-transparent') +
610
- ' ' +
611
- (className ?? '')
612
- }
613
- onClick={onContentClick}
614
- >
686
+ <div className={wrapperClass} onClick={onContentClick}>
615
687
  {editor && editable && (
616
- <MenuBar editor={editor} onUpdate={onUpdate ?? (() => {})} uploadImage={stableUpload} labels={mergedLabels} />
688
+ <MenuBar
689
+ editor={editor}
690
+ onUpdate={onUpdate ?? (() => {})}
691
+ uploadImage={stableUpload}
692
+ labels={mergedLabels}
693
+ onCopy={handleCopy}
694
+ copied={copied}
695
+ isFullscreen={isFullscreen}
696
+ onToggleFullscreen={() => setIsFullscreen((v) => !v)}
697
+ />
617
698
  )}
618
- <EditorContent editor={editor} />
699
+ <EditorContent editor={editor} className={isFullscreen ? 'flex-1 overflow-y-auto' : ''} />
619
700
  </div>
620
701
  );
621
702
  };
@@ -4,16 +4,21 @@ export function useTheme(theme: Theme = 'system'): { isDark: boolean; theme: The
4
4
  const isDark = theme === 'system' ? systenUsesDarkMode() : theme === 'dark';
5
5
 
6
6
  const dom = document.documentElement;
7
- dom.style.background = 'hsl(var(--background))';
8
- dom.classList.add('text-gray-900', 'dark:text-gray-200', 'bg-gray-50', 'dark:bg-gray-950');
9
7
  dom.dataset.theme = isDark ? 'dark' : 'light';
10
8
  dom.style.colorScheme = isDark ? 'dark' : 'light';
11
9
 
12
- const root = document.querySelector('#root') as HTMLDivElement;
13
- root.style.background = 'hsl(var(--background))';
14
-
15
10
  dom.classList[isDark ? 'add' : 'remove']('dark');
16
11
 
12
+ const root = document.querySelector('#root') as HTMLDivElement;
13
+ root.classList.add(
14
+ 'dark:bg-gradient-to-br',
15
+ 'dark:from-gray-900',
16
+ 'dark:to-gray-800',
17
+ 'dark:text-white',
18
+ 'min-h-screen',
19
+ 'bg-fixed',
20
+ );
21
+
17
22
  return { isDark, theme };
18
23
  }
19
24
 
package/src/index.ts CHANGED
@@ -6,6 +6,8 @@ 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';
9
11
  export { VoiceRecorder } from './components/ai/EmbeddedAssistent/VoiceRecorder';
10
12
  export { MarkdownEditor } from './components/editor/MarkdownEditor';
11
13
  export type { MarkdownEditorProps, EditorLabels } from './components/editor/MarkdownEditor';
package/tsconfig.json CHANGED
@@ -14,5 +14,6 @@
14
14
  },
15
15
  "include": [
16
16
  "src/**/*"
17
- ]
17
+ ],
18
+ "exclude": ["node_modules", "dist", "build"]
18
19
  }