@rimori/react-client 0.4.11 → 0.4.12-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.
- package/dist/components/ai/Avatar.d.ts +3 -1
- package/dist/components/ai/Avatar.js +8 -2
- package/dist/components/ai/BuddyAssistant.d.ts +21 -0
- package/dist/components/ai/BuddyAssistant.js +115 -0
- package/dist/components/audio/Playbutton.d.ts +5 -0
- package/dist/components/audio/Playbutton.js +5 -4
- package/dist/components/editor/MarkdownEditor.js +56 -10
- package/dist/hooks/ThemeSetter.js +2 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/package.json +4 -3
- package/src/components/ai/Avatar.tsx +11 -1
- package/src/components/ai/BuddyAssistant.tsx +239 -0
- package/src/components/audio/Playbutton.tsx +15 -5
- package/src/components/editor/MarkdownEditor.tsx +167 -95
- package/src/hooks/ThemeSetter.ts +10 -5
- package/src/index.ts +2 -0
- package/tsconfig.json +2 -1
|
@@ -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
|
|
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:
|
|
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,
|
|
27
|
+
import { LuClipboardPaste, LuHeading1, LuHeading2, LuHeading3, LuLink, 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,16 +126,24 @@ 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');
|
|
110
133
|
const isLink = editor.isActive('link');
|
|
111
134
|
const tableBtnClass = 'w-8 h-8 flex items-center justify-center rounded-md transition-colors duration-150 ' +
|
|
112
135
|
'text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-40 disabled:pointer-events-none';
|
|
113
|
-
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" }),
|
|
114
|
-
|
|
115
|
-
|
|
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" }), _jsx("button", { type: "button", onClick: () => {
|
|
137
|
+
if (isLink) {
|
|
138
|
+
editor.chain().focus().unsetLink().run();
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
toggle('link');
|
|
142
|
+
}
|
|
143
|
+
}, title: isLink ? labels.unsetLink : labels.setLink, className: 'w-8 h-8 flex items-center justify-center rounded-md transition-colors duration-150 ' +
|
|
144
|
+
(isLink
|
|
145
|
+
? 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground hover:bg-destructive/10 hover:text-destructive'
|
|
146
|
+
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'), children: _jsx(LuLink, { 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 }) }), inTable && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", onClick: () => editor.chain().focus().addColumnAfter().run(), className: tableBtnClass, title: labels.addColumnAfter, children: _jsx(TbColumnInsertRight, { size: 18 }) }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().addRowAfter().run(), className: tableBtnClass, title: labels.addRowAfter, children: _jsx(TbRowInsertBottom, { size: 18 }) }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().deleteColumn().run(), className: tableBtnClass, title: labels.deleteColumn, children: _jsx(TbColumnRemove, { size: 18 }) }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().deleteRow().run(), className: tableBtnClass, title: labels.deleteRow, children: _jsx(TbRowRemove, { size: 18 }) }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().mergeOrSplit().run(), className: tableBtnClass, title: labels.mergeOrSplit, children: _jsx(TbArrowMergeBoth, { size: 18 }) })] })), _jsxs("div", { className: 'ml-auto flex items-center gap-0.5 ' + (isFullscreen ? 'pr-10' : ''), children: [_jsx("button", { type: "button", onClick: () => toggle('markdown'), className: tableBtnClass, title: labels.appendMarkdown, children: _jsx(LuClipboardPaste, { size: 18, style: { transform: 'scaleX(-1)' } }) }), _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
147
|
};
|
|
117
148
|
const DEFAULT_LABELS = {
|
|
118
149
|
bold: 'Bold',
|
|
@@ -153,6 +184,8 @@ export const MarkdownEditor = ({ content, editable, className, onUpdate, labels,
|
|
|
153
184
|
const { storage } = useRimori();
|
|
154
185
|
const mergedLabels = Object.assign(Object.assign({}, DEFAULT_LABELS), labels);
|
|
155
186
|
const lastEmittedRef = useRef(content !== null && content !== void 0 ? content : '');
|
|
187
|
+
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
188
|
+
const [copied, setCopied] = useState(false);
|
|
156
189
|
const stableUpload = useCallback((pngBlob) => __awaiter(void 0, void 0, void 0, function* () {
|
|
157
190
|
const { data, error } = yield storage.uploadImage(pngBlob);
|
|
158
191
|
if (error)
|
|
@@ -160,7 +193,8 @@ export const MarkdownEditor = ({ content, editable, className, onUpdate, labels,
|
|
|
160
193
|
return data.url;
|
|
161
194
|
}), [storage]);
|
|
162
195
|
const extensions = useMemo(() => [
|
|
163
|
-
StarterKit,
|
|
196
|
+
StarterKit.configure({ paragraph: false }),
|
|
197
|
+
ParagraphPreserveEmpty,
|
|
164
198
|
Table.configure({ resizable: false }),
|
|
165
199
|
TableRow,
|
|
166
200
|
TableHeader,
|
|
@@ -184,7 +218,16 @@ export const MarkdownEditor = ({ content, editable, className, onUpdate, labels,
|
|
|
184
218
|
onUpdate === null || onUpdate === void 0 ? void 0 : onUpdate(markdown);
|
|
185
219
|
},
|
|
186
220
|
});
|
|
187
|
-
|
|
221
|
+
const handleCopy = useCallback(() => {
|
|
222
|
+
if (!editor)
|
|
223
|
+
return;
|
|
224
|
+
navigator.clipboard.writeText(getMarkdown(editor)).then(() => {
|
|
225
|
+
setCopied(true);
|
|
226
|
+
setTimeout(() => setCopied(false), 2000);
|
|
227
|
+
});
|
|
228
|
+
}, [editor]);
|
|
229
|
+
// Sync external content changes (e.g. AI autofill) without triggering update loop.
|
|
230
|
+
// Pass false to setContent to prevent onUpdate from firing and re-normalizing the content.
|
|
188
231
|
useEffect(() => {
|
|
189
232
|
if (!editor)
|
|
190
233
|
return;
|
|
@@ -192,7 +235,7 @@ export const MarkdownEditor = ({ content, editable, className, onUpdate, labels,
|
|
|
192
235
|
if (incoming === lastEmittedRef.current)
|
|
193
236
|
return;
|
|
194
237
|
lastEmittedRef.current = incoming;
|
|
195
|
-
editor.commands.setContent(incoming);
|
|
238
|
+
editor.commands.setContent(incoming, false);
|
|
196
239
|
}, [editor, content]);
|
|
197
240
|
// Sync editable prop
|
|
198
241
|
useEffect(() => {
|
|
@@ -200,8 +243,11 @@ export const MarkdownEditor = ({ content, editable, className, onUpdate, labels,
|
|
|
200
243
|
return;
|
|
201
244
|
editor.setEditable(editable);
|
|
202
245
|
}, [editor, editable]);
|
|
203
|
-
|
|
246
|
+
const wrapperClass = isFullscreen
|
|
247
|
+
? 'text-md overflow-hidden rounded-lg border border-border bg-card shadow-sm fixed inset-0 z-50 flex flex-col'
|
|
248
|
+
: 'text-md overflow-hidden rounded-lg ' +
|
|
204
249
|
(editable ? 'border border-border bg-card shadow-sm' : 'bg-transparent') +
|
|
205
250
|
' ' +
|
|
206
|
-
(className !== null && className !== void 0 ? className : '')
|
|
251
|
+
(className !== null && className !== void 0 ? className : '');
|
|
252
|
+
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
253
|
};
|
|
@@ -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.
|
|
3
|
+
"version": "0.4.12-next.1",
|
|
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": "
|
|
27
|
+
"@rimori/client": "2.5.23-next.1",
|
|
28
28
|
"react": "^18.1.0",
|
|
29
29
|
"react-dom": "^18.1.0"
|
|
30
30
|
},
|
|
@@ -32,6 +32,7 @@
|
|
|
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",
|
|
35
36
|
"@tiptap/extension-table": "^2.26.1",
|
|
36
37
|
"@tiptap/extension-table-cell": "^2.26.1",
|
|
37
38
|
"@tiptap/extension-table-header": "^2.26.1",
|
|
@@ -46,7 +47,7 @@
|
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"@eslint/js": "^9.37.0",
|
|
49
|
-
"@rimori/client": "
|
|
50
|
+
"@rimori/client": "2.5.23-next.1",
|
|
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
|
|
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=
|
|
174
|
+
<Spinner size={size} />
|
|
165
175
|
) : isPlaying ? (
|
|
166
|
-
<FaStopCircle size=
|
|
176
|
+
<FaStopCircle size={size} />
|
|
167
177
|
) : (
|
|
168
|
-
<FaPlayCircle size=
|
|
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,42 @@ 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 {
|
|
28
|
+
import {
|
|
29
|
+
LuClipboardPaste,
|
|
30
|
+
LuHeading1,
|
|
31
|
+
LuHeading2,
|
|
32
|
+
LuHeading3,
|
|
33
|
+
LuLink,
|
|
34
|
+
LuCopy,
|
|
35
|
+
LuCheck,
|
|
36
|
+
LuMaximize2,
|
|
37
|
+
LuMinimize2,
|
|
38
|
+
} from 'react-icons/lu';
|
|
28
39
|
import { FaBold, FaCode, FaItalic, FaParagraph, FaStrikethrough } from 'react-icons/fa';
|
|
29
40
|
import { ImageUploadExtension, triggerImageUpload } from './ImageUploadExtension';
|
|
30
41
|
|
|
42
|
+
// Extends TipTap's Paragraph to serialize empty paragraphs as <p></p>.
|
|
43
|
+
// Standard markdown collapses consecutive blank lines, losing empty paragraph nodes.
|
|
44
|
+
// Since tiptap-markdown enables html:true by default, <p></p> survives the round-trip.
|
|
45
|
+
const ParagraphPreserveEmpty = Paragraph.extend({
|
|
46
|
+
addStorage() {
|
|
47
|
+
return {
|
|
48
|
+
markdown: {
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
50
|
+
serialize(state: any, node: any) {
|
|
51
|
+
if (node.childCount === 0) {
|
|
52
|
+
state.write('<p></p>');
|
|
53
|
+
} else {
|
|
54
|
+
state.renderInline(node);
|
|
55
|
+
}
|
|
56
|
+
state.closeBlock(node);
|
|
57
|
+
},
|
|
58
|
+
parse: {},
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
31
64
|
function getMarkdown(editor: Editor): string {
|
|
32
65
|
return (editor.storage as { markdown: { getMarkdown: () => string } }).markdown.getMarkdown();
|
|
33
66
|
}
|
|
@@ -207,9 +240,22 @@ interface MenuBarProps {
|
|
|
207
240
|
onUpdate: (content: string) => void;
|
|
208
241
|
uploadImage?: (pngBlob: Blob) => Promise<string | null>;
|
|
209
242
|
labels: Required<EditorLabels>;
|
|
243
|
+
onCopy: () => void;
|
|
244
|
+
copied: boolean;
|
|
245
|
+
isFullscreen: boolean;
|
|
246
|
+
onToggleFullscreen: () => void;
|
|
210
247
|
}
|
|
211
248
|
|
|
212
|
-
const MenuBar = ({
|
|
249
|
+
const MenuBar = ({
|
|
250
|
+
editor,
|
|
251
|
+
onUpdate,
|
|
252
|
+
uploadImage,
|
|
253
|
+
labels,
|
|
254
|
+
onCopy,
|
|
255
|
+
copied,
|
|
256
|
+
isFullscreen,
|
|
257
|
+
onToggleFullscreen,
|
|
258
|
+
}: MenuBarProps): JSX.Element => {
|
|
213
259
|
const [activePanel, setActivePanel] = useState<PanelType>(null);
|
|
214
260
|
|
|
215
261
|
const toggle = (panel: PanelType): void => setActivePanel((prev) => (prev === panel ? null : panel));
|
|
@@ -322,30 +368,26 @@ const MenuBar = ({ editor, onUpdate, uploadImage, labels }: MenuBarProps): JSX.E
|
|
|
322
368
|
|
|
323
369
|
<div className="w-px h-5 bg-border mx-0.5" />
|
|
324
370
|
|
|
325
|
-
{/* Link
|
|
326
|
-
<
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
'
|
|
333
|
-
'text-muted-foreground hover:bg-accent hover:text-accent-foreground border-r border-border last:border-r-0' +
|
|
334
|
-
(isLink ? ' bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground' : '')
|
|
371
|
+
{/* Link button — opens panel when no link active, removes link when active */}
|
|
372
|
+
<button
|
|
373
|
+
type="button"
|
|
374
|
+
onClick={() => {
|
|
375
|
+
if (isLink) {
|
|
376
|
+
editor.chain().focus().unsetLink().run();
|
|
377
|
+
} else {
|
|
378
|
+
toggle('link');
|
|
335
379
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
</button>
|
|
348
|
-
</div>
|
|
380
|
+
}}
|
|
381
|
+
title={isLink ? labels.unsetLink : labels.setLink}
|
|
382
|
+
className={
|
|
383
|
+
'w-8 h-8 flex items-center justify-center rounded-md transition-colors duration-150 ' +
|
|
384
|
+
(isLink
|
|
385
|
+
? 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground hover:bg-destructive/10 hover:text-destructive'
|
|
386
|
+
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground')
|
|
387
|
+
}
|
|
388
|
+
>
|
|
389
|
+
<LuLink size={18} />
|
|
390
|
+
</button>
|
|
349
391
|
|
|
350
392
|
{/* YouTube */}
|
|
351
393
|
<button type="button" onClick={() => toggle('youtube')} className={tableBtnClass} title={labels.addYoutube}>
|
|
@@ -375,63 +417,73 @@ const MenuBar = ({ editor, onUpdate, uploadImage, labels }: MenuBarProps): JSX.E
|
|
|
375
417
|
>
|
|
376
418
|
<TbTable size={18} />
|
|
377
419
|
</button>
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
</button>
|
|
423
|
-
|
|
424
|
-
<div className="w-px h-5 bg-border mx-0.5" />
|
|
420
|
+
{inTable && (
|
|
421
|
+
<>
|
|
422
|
+
<button
|
|
423
|
+
type="button"
|
|
424
|
+
onClick={() => editor.chain().focus().addColumnAfter().run()}
|
|
425
|
+
className={tableBtnClass}
|
|
426
|
+
title={labels.addColumnAfter}
|
|
427
|
+
>
|
|
428
|
+
<TbColumnInsertRight size={18} />
|
|
429
|
+
</button>
|
|
430
|
+
<button
|
|
431
|
+
type="button"
|
|
432
|
+
onClick={() => editor.chain().focus().addRowAfter().run()}
|
|
433
|
+
className={tableBtnClass}
|
|
434
|
+
title={labels.addRowAfter}
|
|
435
|
+
>
|
|
436
|
+
<TbRowInsertBottom size={18} />
|
|
437
|
+
</button>
|
|
438
|
+
<button
|
|
439
|
+
type="button"
|
|
440
|
+
onClick={() => editor.chain().focus().deleteColumn().run()}
|
|
441
|
+
className={tableBtnClass}
|
|
442
|
+
title={labels.deleteColumn}
|
|
443
|
+
>
|
|
444
|
+
<TbColumnRemove size={18} />
|
|
445
|
+
</button>
|
|
446
|
+
<button
|
|
447
|
+
type="button"
|
|
448
|
+
onClick={() => editor.chain().focus().deleteRow().run()}
|
|
449
|
+
className={tableBtnClass}
|
|
450
|
+
title={labels.deleteRow}
|
|
451
|
+
>
|
|
452
|
+
<TbRowRemove size={18} />
|
|
453
|
+
</button>
|
|
454
|
+
<button
|
|
455
|
+
type="button"
|
|
456
|
+
onClick={() => editor.chain().focus().mergeOrSplit().run()}
|
|
457
|
+
className={tableBtnClass}
|
|
458
|
+
title={labels.mergeOrSplit}
|
|
459
|
+
>
|
|
460
|
+
<TbArrowMergeBoth size={18} />
|
|
461
|
+
</button>
|
|
462
|
+
</>
|
|
463
|
+
)}
|
|
425
464
|
|
|
426
|
-
{
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
465
|
+
<div className={'ml-auto flex items-center gap-0.5 ' + (isFullscreen ? 'pr-10' : '')}>
|
|
466
|
+
{/* Append raw markdown */}
|
|
467
|
+
<button
|
|
468
|
+
type="button"
|
|
469
|
+
onClick={() => toggle('markdown')}
|
|
470
|
+
className={tableBtnClass}
|
|
471
|
+
title={labels.appendMarkdown}
|
|
472
|
+
>
|
|
473
|
+
<LuClipboardPaste size={18} style={{ transform: 'scaleX(-1)' }} />
|
|
474
|
+
</button>
|
|
475
|
+
<button type="button" onClick={onCopy} title="Copy as Markdown" className={tableBtnClass}>
|
|
476
|
+
{copied ? <LuCheck size={16} className="text-green-500" /> : <LuCopy size={16} />}
|
|
477
|
+
</button>
|
|
478
|
+
<button
|
|
479
|
+
type="button"
|
|
480
|
+
onClick={onToggleFullscreen}
|
|
481
|
+
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
|
482
|
+
className={tableBtnClass}
|
|
483
|
+
>
|
|
484
|
+
{isFullscreen ? <LuMinimize2 size={16} /> : <LuMaximize2 size={16} />}
|
|
485
|
+
</button>
|
|
486
|
+
</div>
|
|
435
487
|
</div>
|
|
436
488
|
|
|
437
489
|
<InlinePanel
|
|
@@ -547,6 +599,8 @@ export const MarkdownEditor = ({
|
|
|
547
599
|
const { storage } = useRimori();
|
|
548
600
|
const mergedLabels: Required<EditorLabels> = { ...DEFAULT_LABELS, ...labels };
|
|
549
601
|
const lastEmittedRef = useRef(content ?? '');
|
|
602
|
+
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
603
|
+
const [copied, setCopied] = useState(false);
|
|
550
604
|
|
|
551
605
|
const stableUpload = useCallback(
|
|
552
606
|
async (pngBlob: Blob): Promise<string | null> => {
|
|
@@ -559,7 +613,8 @@ export const MarkdownEditor = ({
|
|
|
559
613
|
|
|
560
614
|
const extensions = useMemo(
|
|
561
615
|
() => [
|
|
562
|
-
StarterKit,
|
|
616
|
+
StarterKit.configure({ paragraph: false }),
|
|
617
|
+
ParagraphPreserveEmpty,
|
|
563
618
|
Table.configure({ resizable: false }),
|
|
564
619
|
TableRow,
|
|
565
620
|
TableHeader,
|
|
@@ -587,13 +642,22 @@ export const MarkdownEditor = ({
|
|
|
587
642
|
},
|
|
588
643
|
});
|
|
589
644
|
|
|
590
|
-
|
|
645
|
+
const handleCopy = useCallback(() => {
|
|
646
|
+
if (!editor) return;
|
|
647
|
+
navigator.clipboard.writeText(getMarkdown(editor)).then(() => {
|
|
648
|
+
setCopied(true);
|
|
649
|
+
setTimeout(() => setCopied(false), 2000);
|
|
650
|
+
});
|
|
651
|
+
}, [editor]);
|
|
652
|
+
|
|
653
|
+
// Sync external content changes (e.g. AI autofill) without triggering update loop.
|
|
654
|
+
// Pass false to setContent to prevent onUpdate from firing and re-normalizing the content.
|
|
591
655
|
useEffect(() => {
|
|
592
656
|
if (!editor) return;
|
|
593
657
|
const incoming = content ?? '';
|
|
594
658
|
if (incoming === lastEmittedRef.current) return;
|
|
595
659
|
lastEmittedRef.current = incoming;
|
|
596
|
-
editor.commands.setContent(incoming);
|
|
660
|
+
editor.commands.setContent(incoming, false);
|
|
597
661
|
}, [editor, content]);
|
|
598
662
|
|
|
599
663
|
// Sync editable prop
|
|
@@ -602,20 +666,28 @@ export const MarkdownEditor = ({
|
|
|
602
666
|
editor.setEditable(editable);
|
|
603
667
|
}, [editor, editable]);
|
|
604
668
|
|
|
669
|
+
const wrapperClass = isFullscreen
|
|
670
|
+
? 'text-md overflow-hidden rounded-lg border border-border bg-card shadow-sm fixed inset-0 z-50 flex flex-col'
|
|
671
|
+
: 'text-md overflow-hidden rounded-lg ' +
|
|
672
|
+
(editable ? 'border border-border bg-card shadow-sm' : 'bg-transparent') +
|
|
673
|
+
' ' +
|
|
674
|
+
(className ?? '');
|
|
675
|
+
|
|
605
676
|
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
|
-
>
|
|
677
|
+
<div className={wrapperClass} onClick={onContentClick}>
|
|
615
678
|
{editor && editable && (
|
|
616
|
-
<MenuBar
|
|
679
|
+
<MenuBar
|
|
680
|
+
editor={editor}
|
|
681
|
+
onUpdate={onUpdate ?? (() => {})}
|
|
682
|
+
uploadImage={stableUpload}
|
|
683
|
+
labels={mergedLabels}
|
|
684
|
+
onCopy={handleCopy}
|
|
685
|
+
copied={copied}
|
|
686
|
+
isFullscreen={isFullscreen}
|
|
687
|
+
onToggleFullscreen={() => setIsFullscreen((v) => !v)}
|
|
688
|
+
/>
|
|
617
689
|
)}
|
|
618
|
-
<EditorContent editor={editor} />
|
|
690
|
+
<EditorContent editor={editor} className={isFullscreen ? 'flex-1 overflow-y-auto' : ''} />
|
|
619
691
|
</div>
|
|
620
692
|
);
|
|
621
693
|
};
|
package/src/hooks/ThemeSetter.ts
CHANGED
|
@@ -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';
|