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