@rimori/client 1.0.5 → 1.1.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.
Files changed (101) hide show
  1. package/README.md +955 -28
  2. package/dist/components/MarkdownEditor.js +6 -4
  3. package/dist/components/PluginController.d.ts +21 -0
  4. package/dist/components/PluginController.js +116 -0
  5. package/dist/components/ai/Assistant.js +1 -1
  6. package/dist/components/ai/Avatar.d.ts +6 -4
  7. package/dist/components/ai/Avatar.js +14 -6
  8. package/dist/components/ai/EmbeddedAssistent/AudioInputField.js +1 -1
  9. package/dist/components/ai/EmbeddedAssistent/CircleAudioAvatar.d.ts +2 -1
  10. package/dist/components/ai/EmbeddedAssistent/CircleAudioAvatar.js +36 -15
  11. package/dist/components/ai/EmbeddedAssistent/TTS/MessageSender.d.ts +1 -0
  12. package/dist/components/ai/EmbeddedAssistent/TTS/MessageSender.js +3 -0
  13. package/dist/components/ai/EmbeddedAssistent/TTS/Player.d.ts +2 -0
  14. package/dist/components/ai/EmbeddedAssistent/TTS/Player.js +5 -0
  15. package/dist/components/ai/EmbeddedAssistent/VoiceRecoder.d.ts +3 -0
  16. package/dist/components/ai/EmbeddedAssistent/VoiceRecoder.js +41 -5
  17. package/dist/components/ai/utils.d.ts +1 -1
  18. package/dist/components.d.ts +1 -0
  19. package/dist/components.js +1 -0
  20. package/dist/controller/AIController.js +2 -1
  21. package/dist/controller/SettingsController.d.ts +15 -15
  22. package/dist/controller/SettingsController.js +15 -16
  23. package/dist/controller/SharedContentController.d.ts +58 -11
  24. package/dist/controller/SharedContentController.js +161 -26
  25. package/dist/controller/SidePluginController.d.ts +1 -12
  26. package/dist/controller/SidePluginController.js +2 -1
  27. package/dist/core/components/ContextMenu.d.ts +10 -0
  28. package/dist/core/components/ContextMenu.js +93 -0
  29. package/dist/core.d.ts +1 -4
  30. package/dist/core.js +1 -4
  31. package/dist/hooks/UseChatHook.d.ts +1 -1
  32. package/dist/index.d.ts +0 -8
  33. package/dist/index.js +0 -8
  34. package/dist/plugin/AccomplishmentHandler.d.ts +38 -0
  35. package/dist/plugin/AccomplishmentHandler.js +108 -0
  36. package/dist/plugin/ContextMenu.d.ts +17 -0
  37. package/dist/plugin/ContextMenu.js +45 -0
  38. package/dist/plugin/PluginController.js +6 -3
  39. package/dist/plugin/RimoriClient.d.ts +92 -65
  40. package/dist/plugin/RimoriClient.js +105 -75
  41. package/dist/plugin/ThemeSetter.js +2 -2
  42. package/dist/plugin/fromRimori/EventBus.d.ts +6 -3
  43. package/dist/plugin/fromRimori/EventBus.js +15 -9
  44. package/dist/plugin/fromRimori/PluginTypes.d.ts +48 -0
  45. package/dist/plugin/fromRimori/PluginTypes.js +1 -0
  46. package/dist/providers/PluginController.d.ts +21 -0
  47. package/dist/providers/PluginController.js +116 -0
  48. package/dist/providers/PluginProvider.js +26 -73
  49. package/dist/types/Actions.d.ts +4 -0
  50. package/dist/types/Actions.js +1 -0
  51. package/dist/utils/Language.d.ts +66 -0
  52. package/dist/utils/Language.js +67 -0
  53. package/dist/utils/difficultyConverter.d.ts +1 -0
  54. package/dist/utils/difficultyConverter.js +3 -0
  55. package/dist/worker/WorkerSetup.js +4 -4
  56. package/package.json +2 -3
  57. package/src/components/MarkdownEditor.tsx +78 -76
  58. package/src/components/ai/Assistant.tsx +1 -1
  59. package/src/components/ai/Avatar.tsx +66 -49
  60. package/src/components/ai/EmbeddedAssistent/AudioInputField.tsx +1 -1
  61. package/src/components/ai/EmbeddedAssistent/CircleAudioAvatar.tsx +82 -59
  62. package/src/components/ai/EmbeddedAssistent/TTS/MessageSender.ts +4 -0
  63. package/src/components/ai/EmbeddedAssistent/TTS/Player.ts +6 -0
  64. package/src/components/ai/EmbeddedAssistent/VoiceRecoder.tsx +51 -8
  65. package/src/components/ai/utils.ts +1 -1
  66. package/src/components.ts +2 -1
  67. package/src/controller/AIController.ts +2 -1
  68. package/src/controller/SettingsController.ts +83 -84
  69. package/src/controller/SharedContentController.ts +214 -53
  70. package/src/controller/SidePluginController.ts +3 -14
  71. package/src/core/components/ContextMenu.tsx +123 -0
  72. package/src/core.ts +1 -4
  73. package/src/hooks/UseChatHook.ts +17 -17
  74. package/src/index.ts +0 -8
  75. package/src/plugin/AccomplishmentHandler.ts +165 -0
  76. package/src/plugin/PluginController.ts +105 -103
  77. package/src/plugin/RimoriClient.ts +267 -250
  78. package/src/plugin/ThemeSetter.ts +2 -2
  79. package/src/plugin/fromRimori/EventBus.ts +23 -12
  80. package/src/plugin/fromRimori/PluginTypes.ts +64 -0
  81. package/src/providers/PluginProvider.tsx +63 -110
  82. package/src/types/Actions.ts +6 -0
  83. package/src/utils/Language.ts +70 -0
  84. package/src/utils/difficultyConverter.ts +4 -0
  85. package/src/worker/WorkerSetup.ts +4 -4
  86. package/dist/components/avatar/Assistant.d.ts +0 -9
  87. package/dist/components/avatar/Assistant.js +0 -59
  88. package/dist/components/avatar/Avatar.d.ts +0 -12
  89. package/dist/components/avatar/Avatar.js +0 -42
  90. package/dist/components/avatar/EmbeddedAssistent/AudioInputField.d.ts +0 -7
  91. package/dist/components/avatar/EmbeddedAssistent/AudioInputField.js +0 -38
  92. package/dist/components/avatar/EmbeddedAssistent/CircleAudioAvatar.d.ts +0 -7
  93. package/dist/components/avatar/EmbeddedAssistent/CircleAudioAvatar.js +0 -59
  94. package/dist/components/avatar/EmbeddedAssistent/TTS/MessageSender.d.ts +0 -19
  95. package/dist/components/avatar/EmbeddedAssistent/TTS/MessageSender.js +0 -84
  96. package/dist/components/avatar/EmbeddedAssistent/TTS/Player.d.ts +0 -25
  97. package/dist/components/avatar/EmbeddedAssistent/TTS/Player.js +0 -180
  98. package/dist/components/avatar/EmbeddedAssistent/VoiceRecoder.d.ts +0 -7
  99. package/dist/components/avatar/EmbeddedAssistent/VoiceRecoder.js +0 -45
  100. package/dist/components/avatar/utils.d.ts +0 -6
  101. package/dist/components/avatar/utils.js +0 -14
@@ -11,101 +11,103 @@ import { FaBold, FaCode, FaItalic, FaParagraph, FaStrikethrough } from "react-ic
11
11
  // This inplementation is rooted in the Tiptap editor basic example https://codesandbox.io/p/devbox/editor-9x9dkd
12
12
 
13
13
  interface EditorButtonProps {
14
- action: string;
15
- isActive?: boolean;
16
- label: string | React.ReactNode;
17
- disabled?: boolean;
14
+ action: string;
15
+ isActive?: boolean;
16
+ label: string | React.ReactNode;
17
+ disabled?: boolean;
18
18
  }
19
19
 
20
20
  const EditorButton = ({ action, isActive, label, disabled }: EditorButtonProps) => {
21
- const { editor } = useCurrentEditor() as any;
21
+ const { editor } = useCurrentEditor() as any;
22
22
 
23
- if (!editor) {
24
- return null;
25
- }
26
-
27
- if (action.includes("heading")) {
28
- const level = parseInt(action[action.length - 1]);
29
- return (
30
- <button
31
- onClick={() => editor.chain().focus().toggleHeading({ level: level }).run()}
32
- className={`pl-2 ${isActive ? "is-active" : ""}`}
33
- >
34
- {label}
35
- </button>
36
- );
37
- }
23
+ if (!editor) {
24
+ return null;
25
+ }
38
26
 
27
+ if (action.includes("heading")) {
28
+ const level = parseInt(action[action.length - 1]);
39
29
  return (
40
- <button
41
- onClick={() => editor.chain().focus()[action]().run()}
42
- disabled={disabled ? !editor.can().chain().focus()[action]().run() : false}
43
- className={`pl-2 ${isActive ? "is-active" : ""}`}
44
- >
45
- {label}
46
- </button>
30
+ <button
31
+ onClick={() => editor.chain().focus().toggleHeading({ level: level }).run()}
32
+ className={`pl-2 ${isActive ? "is-active" : ""}`}
33
+ >
34
+ {label}
35
+ </button>
47
36
  );
37
+ }
38
+
39
+ return (
40
+ <button
41
+ onClick={() => editor.chain().focus()[action]().run()}
42
+ disabled={disabled ? !editor.can().chain().focus()[action]().run() : false}
43
+ className={`pl-2 ${isActive ? "is-active" : ""}`}
44
+ >
45
+ {label}
46
+ </button>
47
+ );
48
48
  };
49
49
 
50
50
  const MenuBar = () => {
51
- const { editor } = useCurrentEditor();
51
+ const { editor } = useCurrentEditor();
52
52
 
53
- if (!editor) {
54
- return null;
55
- }
53
+ if (!editor) {
54
+ return null;
55
+ }
56
56
 
57
- return (
58
- <div className="bg-gray-400 dark:bg-gray-800 dark:text-white text-lg flex flex-row flex-wrap items-center p-1">
59
- <EditorButton action="toggleBold" isActive={editor.isActive("bold")} label={<FaBold />} disabled />
60
- <EditorButton action="toggleItalic" isActive={editor.isActive("italic")} label={<FaItalic />} disabled />
61
- <EditorButton action="toggleStrike" isActive={editor.isActive("strike")} label={<FaStrikethrough />} disabled />
62
- <EditorButton action="toggleCode" isActive={editor.isActive("code")} label={<FaCode />} disabled />
63
- <EditorButton action="setParagraph" isActive={editor.isActive("paragraph")} label={<FaParagraph />} />
64
- <EditorButton action='setHeading1' isActive={editor.isActive("heading", { level: 1 })} label={<LuHeading1 size={"24px"} />} />
65
- <EditorButton action='setHeading2' isActive={editor.isActive("heading", { level: 2 })} label={<LuHeading2 size={"24px"} />} />
66
- <EditorButton action='setHeading3' isActive={editor.isActive("heading", { level: 3 })} label={<LuHeading3 size={"24px"} />} />
67
- <EditorButton action="toggleBulletList" isActive={editor.isActive("bulletList")} label={<AiOutlineUnorderedList size={"24px"} />} />
68
- <EditorButton action="toggleOrderedList" isActive={editor.isActive("orderedList")} label={<GoListOrdered size={"24px"} />} />
69
- <EditorButton action="toggleCodeBlock" isActive={editor.isActive("codeBlock")} label={<PiCodeBlock size={"24px"} />} />
70
- <EditorButton action="toggleBlockquote" isActive={editor.isActive("blockquote")} label={<TbBlockquote size={"24px"} />} />
71
- </div>
72
- );
57
+ return (
58
+ <div className="bg-gray-400 dark:bg-gray-800 dark:text-white text-lg flex flex-row flex-wrap items-center p-1">
59
+ <EditorButton action="toggleBold" isActive={editor.isActive("bold")} label={<FaBold />} disabled />
60
+ <EditorButton action="toggleItalic" isActive={editor.isActive("italic")} label={<FaItalic />} disabled />
61
+ <EditorButton action="toggleStrike" isActive={editor.isActive("strike")} label={<FaStrikethrough />} disabled />
62
+ <EditorButton action="toggleCode" isActive={editor.isActive("code")} label={<FaCode />} disabled />
63
+ <EditorButton action="setParagraph" isActive={editor.isActive("paragraph")} label={<FaParagraph />} />
64
+ <EditorButton action='setHeading1' isActive={editor.isActive("heading", { level: 1 })} label={<LuHeading1 size={"24px"} />} />
65
+ <EditorButton action='setHeading2' isActive={editor.isActive("heading", { level: 2 })} label={<LuHeading2 size={"24px"} />} />
66
+ <EditorButton action='setHeading3' isActive={editor.isActive("heading", { level: 3 })} label={<LuHeading3 size={"24px"} />} />
67
+ <EditorButton action="toggleBulletList" isActive={editor.isActive("bulletList")} label={<AiOutlineUnorderedList size={"24px"} />} />
68
+ <EditorButton action="toggleOrderedList" isActive={editor.isActive("orderedList")} label={<GoListOrdered size={"24px"} />} />
69
+ <EditorButton action="toggleCodeBlock" isActive={editor.isActive("codeBlock")} label={<PiCodeBlock size={"24px"} />} />
70
+ <EditorButton action="toggleBlockquote" isActive={editor.isActive("blockquote")} label={<TbBlockquote size={"24px"} />} />
71
+ </div>
72
+ );
73
73
  };
74
74
 
75
75
  const extensions = [
76
- StarterKit.configure({
77
- bulletList: {
78
- keepMarks: true,
79
- keepAttributes: false,
80
- },
81
- orderedList: {
82
- keepMarks: true,
83
- keepAttributes: false,
84
- },
85
- }),
86
- Markdown,
76
+ StarterKit.configure({
77
+ bulletList: {
78
+ HTMLAttributes: {
79
+ class: "list-disc list-inside dark:text-white p-1 mt-1 [&_li]:mb-1 [&_p]:inline m-0",
80
+ },
81
+ },
82
+ orderedList: {
83
+ HTMLAttributes: {
84
+ className: "list-decimal list-inside dark:text-white p-1 mt-1 [&_li]:mb-1 [&_p]:inline m-0",
85
+ },
86
+ },
87
+ }),
88
+ Markdown,
87
89
  ];
88
90
 
89
91
  interface Props {
90
- content?: string;
91
- editable: boolean;
92
- className?: string;
93
- onUpdate?: (content: string) => void;
92
+ content?: string;
93
+ editable: boolean;
94
+ className?: string;
95
+ onUpdate?: (content: string) => void;
94
96
  }
95
97
 
96
98
  export const MarkdownEditor = (props: Props) => {
97
- return (
98
- <div className={"text-md border border-gray-800 overflow-hidden " + props.className} style={{ borderWidth: props.editable ? 1 : 0 }}>
99
- <EditorProvider
100
- key={(props.editable ? "editable" : "readonly") + props.content}
101
- slotBefore={props.editable ? <MenuBar /> : null}
102
- extensions={extensions}
103
- content={props.content}
104
- editable={props.editable}
105
- onUpdate={(e) => {
106
- props.onUpdate && props.onUpdate(e.editor.storage.markdown.getMarkdown());
107
- }}
108
- ></EditorProvider>
109
- </div>
110
- );
99
+ return (
100
+ <div className={"text-md border border-gray-800 overflow-hidden " + props.className} style={{ borderWidth: props.editable ? 1 : 0 }}>
101
+ <EditorProvider
102
+ key={(props.editable ? "editable" : "readonly") + props.content}
103
+ slotBefore={props.editable ? <MenuBar /> : null}
104
+ extensions={extensions}
105
+ content={props.content}
106
+ editable={props.editable}
107
+ onUpdate={(e) => {
108
+ props.onUpdate && props.onUpdate(e.editor.storage.markdown.getMarkdown());
109
+ }}
110
+ ></EditorProvider>
111
+ </div>
112
+ );
111
113
  };
@@ -23,7 +23,7 @@ export function AssistantChat({ avatarImageUrl, voiceId, onComplete, autoStartCo
23
23
  const lastAssistantMessage = [...messages].filter((m) => m.role === 'assistant').pop()?.content;
24
24
 
25
25
  useEffect(() => {
26
- sender.setOnLoudnessChange((value: number) => event.emit('self.avatar.triggerLoudness', value));
26
+ sender.setOnLoudnessChange((value: number) => event.emit('self.avatar.triggerLoudness', { loudness: value }));
27
27
 
28
28
  if (!autoStartConversation) {
29
29
  return;
@@ -1,61 +1,78 @@
1
- import { Tool } from '../../core';
2
- import { useEffect, useMemo } from 'react';
1
+ import { useEffect, useMemo, useState } from 'react';
3
2
  import { VoiceRecorder } from './EmbeddedAssistent/VoiceRecoder';
4
3
  import { MessageSender } from './EmbeddedAssistent/TTS/MessageSender';
5
4
  import { CircleAudioAvatar } from './EmbeddedAssistent/CircleAudioAvatar';
5
+ import { Tool } from '../../controller/AIController';
6
6
  import { useChat } from '../../hooks/UseChatHook';
7
7
  import { usePlugin } from '../../components';
8
8
  import { getFirstMessages } from './utils';
9
9
  import { FirstMessages } from './utils';
10
10
 
11
11
  interface Props {
12
- title?: string;
13
- voiceId: any;
14
- avatarImageUrl: string;
15
- agentTools: Tool[];
16
- autoStartConversation?: FirstMessages;
12
+ voiceId: any;
13
+ agentTools: Tool[];
14
+ avatarImageUrl: string;
15
+ circleSize?: string;
16
+ isDarkTheme?: boolean;
17
+ children?: React.ReactNode;
18
+ autoStartConversation?: FirstMessages;
17
19
  }
18
20
 
19
- export function Avatar({ avatarImageUrl, voiceId, title, agentTools, autoStartConversation }: Props) {
20
- const { llm, event } = usePlugin();
21
- const sender = useMemo(() => new MessageSender(llm.getVoice, voiceId), []);
22
- const { messages, append, isLoading, lastMessage, setMessages } = useChat(agentTools);
23
-
24
- useEffect(() => {
25
- console.log("messages", messages);
26
- }, [messages]);
27
-
28
- useEffect(() => {
29
- sender.setOnLoudnessChange((value: number) => event.emit('self.avatar.triggerLoudness', value));
30
-
31
- if (!autoStartConversation) return;
32
-
33
- setMessages(getFirstMessages(autoStartConversation));
34
- // append([{ role: 'user', content: autoStartConversation.userMessage }]);
35
-
36
- if (autoStartConversation.assistantMessage) {
37
- // console.log("autostartmessages", { autoStartConversation, isLoading });
38
- sender.handleNewText(autoStartConversation.assistantMessage, isLoading);
39
- } else if (autoStartConversation.userMessage) {
40
- append([{ role: 'user', content: autoStartConversation.userMessage, id: messages.length.toString() }]);
41
- }
42
- }, []);
43
-
44
- useEffect(() => {
45
- if (lastMessage?.role === 'assistant') {
46
- sender.handleNewText(lastMessage.content, isLoading);
47
- }
48
- }, [lastMessage, isLoading]);
49
-
50
- return (
51
- <div className='pb-8'>
52
- {title && <p className="text-center mt-5 w-3/4 mx-auto rounded-lg dark:text-gray-100">{title}</p>}
53
- <CircleAudioAvatar imageUrl={avatarImageUrl} width={"250px"} className='mx-auto' />
54
- <div className='w-16 h-16 flex text-4xl shadow-lg flex-row justify-center items-center rounded-full mx-auto bg-gray-400 dark:bg-gray-800'>
55
- <VoiceRecorder className='w-7' iconSize='300' onVoiceRecorded={(message) => {
56
- append([{ role: 'user', content: "Message(" + Math.floor((messages.length + 1) / 2) + "): " + message, id: messages.length.toString() }]);
57
- }} />
58
- </div>
59
- </div>
60
- );
21
+ export function Avatar({ avatarImageUrl, voiceId, agentTools, autoStartConversation, children, isDarkTheme = false, circleSize = "300px" }: Props) {
22
+ const { llm, event } = usePlugin();
23
+ const [agentReplying, setAgentReplying] = useState(false);
24
+ const [isProcessingMessage, setIsProcessingMessage] = useState(false);
25
+ const sender = useMemo(() => new MessageSender(llm.getVoice, voiceId), []);
26
+ const { messages, append, isLoading, lastMessage, setMessages } = useChat(agentTools);
27
+
28
+ useEffect(() => {
29
+ console.log("messages", messages);
30
+ }, [messages]);
31
+
32
+ useEffect(() => {
33
+ if (!isLoading) setIsProcessingMessage(false);
34
+ }, [isLoading]);
35
+
36
+ useEffect(() => {
37
+ sender.setOnLoudnessChange((value) => event.emit('self.avatar.triggerLoudness', { loudness: value }));
38
+ sender.setOnEndOfSpeech(() => setAgentReplying(false));
39
+
40
+ if (!autoStartConversation) return;
41
+
42
+ setMessages(getFirstMessages(autoStartConversation));
43
+ // append([{ role: 'user', content: autoStartConversation.userMessage }]);
44
+
45
+ if (autoStartConversation.assistantMessage) {
46
+ // console.log("autostartmessages", { autoStartConversation, isLoading });
47
+ sender.handleNewText(autoStartConversation.assistantMessage, isLoading);
48
+ } else if (autoStartConversation.userMessage) {
49
+ append([{ role: 'user', content: autoStartConversation.userMessage, id: messages.length.toString() }]);
50
+ }
51
+ }, []);
52
+
53
+ useEffect(() => {
54
+ if (lastMessage?.role === 'assistant') {
55
+ sender.handleNewText(lastMessage.content, isLoading);
56
+ }
57
+ }, [lastMessage, isLoading]);
58
+
59
+ return (
60
+ <div className='pb-8'>
61
+ <CircleAudioAvatar
62
+ width={circleSize}
63
+ className='mx-auto'
64
+ imageUrl={avatarImageUrl}
65
+ isDarkTheme={isDarkTheme} />
66
+ {children}
67
+ <VoiceRecorder
68
+ iconSize='300'
69
+ disabled={agentReplying}
70
+ loading={isProcessingMessage}
71
+ onVoiceRecorded={(message) => {
72
+ setAgentReplying(true);
73
+ append([{ role: 'user', content: "Message(" + Math.floor((messages.length + 1) / 2) + "): " + message, id: messages.length.toString() }]);
74
+ }}
75
+ onRecordingStatusChange={(running) => !running && setIsProcessingMessage(true)} />
76
+ </div>
77
+ );
61
78
  };
@@ -43,7 +43,7 @@ export function AudioInputField({ onSubmit, onAudioControl, blockSubmission = fa
43
43
  className="cursor-default">
44
44
  {audioEnabled ? <HiMiniSpeakerWave className='w-9 h-9 cursor-pointer' /> : <HiMiniSpeakerXMark className='w-9 h-9 cursor-pointer' />}
45
45
  </button>}
46
- <VoiceRecorder onVoiceRecorded={(m: string) => {
46
+ <VoiceRecorder onRecordingStatusChange={() => {}} onVoiceRecorded={(m: string) => {
47
47
  console.log('onVoiceRecorded', m);
48
48
  handleSubmit(m);
49
49
  }}
@@ -1,75 +1,98 @@
1
- import React, { useEffect, useRef } from 'react';
2
- import { EventBus, EventBusMessage } from '../../../core';
1
+ import { useEffect, useRef } from 'react';
2
+ import { EventBus, EventBusMessage } from '../../../plugin/fromRimori/EventBus';
3
3
 
4
4
  interface CircleAudioAvatarProps {
5
- width?: string;
6
- imageUrl: string;
7
- className?: string;
5
+ width?: string;
6
+ imageUrl: string;
7
+ className?: string;
8
+ isDarkTheme?: boolean;
8
9
  }
9
10
 
11
+ export function CircleAudioAvatar({ imageUrl, className, isDarkTheme = false, width = "150px" }: CircleAudioAvatarProps) {
12
+ const canvasRef = useRef<HTMLCanvasElement>(null);
13
+ const currentLoudnessRef = useRef(0);
14
+ const targetLoudnessRef = useRef(0);
15
+ const animationFrameRef = useRef<number | null>(null);
10
16
 
11
- export function CircleAudioAvatar({ imageUrl, className, width = "150px" }: CircleAudioAvatarProps) {
12
- const canvasRef = useRef<HTMLCanvasElement>(null);
17
+ useEffect(() => {
18
+ const canvas = canvasRef.current;
19
+ if (canvas) {
20
+ const ctx = canvas.getContext('2d');
21
+ if (ctx) {
22
+ const image = new Image();
23
+ image.src = imageUrl;
24
+ let isMounted = true;
13
25
 
14
- useEffect(() => {
15
- const canvas = canvasRef.current;
16
- if (canvas) {
17
- const ctx = canvas.getContext('2d');
18
- if (ctx) {
19
- const image = new Image();
20
- image.src = imageUrl;
21
- image.onload = () => {
22
- draw(ctx, canvas, image, 0);
23
- };
26
+ image.onload = () => {
27
+ if (!isMounted) return;
28
+ draw(ctx, canvas, image, 0);
29
+ const animate = () => {
30
+ const decayRate = 0.06;
31
+ if (currentLoudnessRef.current > targetLoudnessRef.current) {
32
+ currentLoudnessRef.current = Math.max(
33
+ targetLoudnessRef.current,
34
+ currentLoudnessRef.current - decayRate * currentLoudnessRef.current
35
+ );
36
+ } else {
37
+ currentLoudnessRef.current = targetLoudnessRef.current;
38
+ }
39
+ draw(ctx, canvas, image, currentLoudnessRef.current);
40
+ animationFrameRef.current = requestAnimationFrame(animate);
41
+ };
42
+ animationFrameRef.current = requestAnimationFrame(animate);
43
+ };
24
44
 
25
- const handleLoudness = (event: EventBusMessage) => {
26
- draw(ctx, canvas, image, event.data.loudness);
27
- };
45
+ const handleLoudness = ({ data }: EventBusMessage) => {
46
+ const newLoudness = data.loudness;
47
+ if (newLoudness > currentLoudnessRef.current) {
48
+ currentLoudnessRef.current = newLoudness;
49
+ }
50
+ targetLoudnessRef.current = newLoudness;
51
+ };
28
52
 
29
- // Subscribe to loudness changes
30
- const listenerId = EventBus.on('self.avatar.triggerLoudness', handleLoudness);
53
+ const listener = EventBus.on('self.avatar.triggerLoudness', handleLoudness);
31
54
 
32
- return () => {
33
- EventBus.off(listenerId);
34
- };
35
- }
36
- }
37
- }, [imageUrl]);
55
+ return () => {
56
+ isMounted = false;
57
+ listener.off();
58
+ if (animationFrameRef.current) {
59
+ cancelAnimationFrame(animationFrameRef.current);
60
+ }
61
+ };
62
+ }
63
+ }
64
+ }, [imageUrl]);
38
65
 
39
- // Function to draw on the canvas
40
- const draw = (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, image: HTMLImageElement, loudness: number) => {
41
- if (canvas && ctx) {
42
- ctx.clearRect(0, 0, canvas.width, canvas.height);
66
+ const draw = (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, image: HTMLImageElement, loudness: number) => {
67
+ if (canvas && ctx) {
68
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
43
69
 
44
- // Draw pulsing circle
45
- const radius = Math.min(canvas.width, canvas.height) / 3;
46
- const centerX = canvas.width / 2;
47
- const centerY = canvas.height / 2;
48
- const pulseRadius = radius + loudness / 2.5; // Adjust the divisor for sensitivity
49
- ctx.beginPath();
50
- ctx.arc(centerX, centerY, pulseRadius, 0, Math.PI * 2, true);
51
- ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)';
52
- ctx.lineWidth = 5;
53
- ctx.stroke();
70
+ const radius = Math.min(canvas.width, canvas.height) / 3;
71
+ const centerX = canvas.width / 2;
72
+ const centerY = canvas.height / 2;
73
+ const pulseRadius = radius + loudness / 2.5;
74
+ ctx.beginPath();
75
+ ctx.arc(centerX, centerY, pulseRadius, 0, Math.PI * 2, true);
76
+ ctx.strokeStyle = isDarkTheme ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)';
77
+ ctx.lineWidth = 5;
78
+ ctx.stroke();
54
79
 
55
- // Draw image circle
56
- ctx.save();
57
- ctx.beginPath();
58
- ctx.arc(centerX, centerY, radius, 0, Math.PI * 2, true);
59
- ctx.closePath();
60
- ctx.clip();
61
- ctx.drawImage(image, centerX - radius, centerY - radius, radius * 2, radius * 2);
62
- ctx.restore();
80
+ ctx.save();
81
+ ctx.beginPath();
82
+ ctx.arc(centerX, centerY, radius, 0, Math.PI * 2, true);
83
+ ctx.closePath();
84
+ ctx.clip();
85
+ ctx.drawImage(image, centerX - radius, centerY - radius, radius * 2, radius * 2);
86
+ ctx.restore();
63
87
 
64
- // Draw circular frame around the image
65
- ctx.beginPath();
66
- ctx.arc(centerX, centerY, radius, 0, Math.PI * 2, true);
67
- ctx.strokeStyle = 'rgba(20,20, 20, 0.9)';
68
- ctx.lineWidth = 5; // Adjust the width of the frame as needed
69
- ctx.stroke();
70
- }
71
- };
88
+ ctx.beginPath();
89
+ ctx.arc(centerX, centerY, radius, 0, Math.PI * 2, true);
90
+ ctx.strokeStyle = isDarkTheme ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.9)';
91
+ ctx.lineWidth = 5;
92
+ ctx.stroke();
93
+ }
94
+ };
72
95
 
73
- return <canvas ref={canvasRef} className={className} width={500} height={500} style={{ width }} />;
96
+ return <canvas ref={canvasRef} className={className} width={500} height={500} style={{ width }} />;
74
97
  };
75
98
 
@@ -88,4 +88,8 @@ export class MessageSender {
88
88
  callback(loudness);
89
89
  });
90
90
  }
91
+
92
+ public setOnEndOfSpeech(callback: () => void) {
93
+ this.player.setOnEndOfSpeech(callback);
94
+ }
91
95
  }
@@ -12,6 +12,7 @@ export class ChunkedAudioPlayer {
12
12
  private loudnessCallback: (value: number) => void = () => { };
13
13
  private currentIndex = 0;
14
14
  private startedPlaying = false;
15
+ private onEndOfSpeech: () => void = () => { };
15
16
 
16
17
  constructor() {
17
18
  this.init();
@@ -139,6 +140,7 @@ export class ChunkedAudioPlayer {
139
140
  // console.log('Loudness monitoring stopped.');
140
141
  cancelAnimationFrame(this.handle);
141
142
  this.loudnessCallback(0);
143
+ this.onEndOfSpeech();
142
144
  return;
143
145
  }
144
146
 
@@ -189,4 +191,8 @@ export class ChunkedAudioPlayer {
189
191
  this.isPlaying = false;
190
192
  this.init();
191
193
  }
194
+
195
+ public setOnEndOfSpeech(callback: () => void) {
196
+ this.onEndOfSpeech = callback;
197
+ }
192
198
  }
@@ -1,21 +1,32 @@
1
- import { useState, useRef, forwardRef, useImperativeHandle } from 'react';
2
- import { FaMicrophone } from 'react-icons/fa6';
1
+ import { useState, useRef, forwardRef, useImperativeHandle, useEffect } from 'react';
2
+ import { FaMicrophone, FaSpinner } from 'react-icons/fa6';
3
3
  import { usePlugin } from '../../../components';
4
4
 
5
5
  interface Props {
6
6
  iconSize?: string;
7
7
  className?: string;
8
+ disabled?: boolean;
9
+ loading?: boolean;
10
+ onRecordingStatusChange: (running: boolean) => void;
8
11
  onVoiceRecorded: (message: string) => void;
9
12
  }
10
13
 
11
- export const VoiceRecorder = forwardRef(({ onVoiceRecorded, iconSize, className }: Props, ref) => {
14
+ export const VoiceRecorder = forwardRef(({ onVoiceRecorded, iconSize, className, disabled, loading, onRecordingStatusChange }: Props, ref) => {
12
15
  const [isRecording, setIsRecording] = useState(false);
13
16
  const mediaRecorderRef = useRef<MediaRecorder | null>(null);
14
17
  const audioChunksRef = useRef<Blob[]>([]);
18
+ const mediaStreamRef = useRef<MediaStream | null>(null);
15
19
  const { llm } = usePlugin();
16
20
 
21
+ // Ref for latest onVoiceRecorded callback
22
+ const onVoiceRecordedRef = useRef(onVoiceRecorded);
23
+ useEffect(() => {
24
+ onVoiceRecordedRef.current = onVoiceRecorded;
25
+ }, [onVoiceRecorded]);
26
+
17
27
  const startRecording = async () => {
18
28
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
29
+ mediaStreamRef.current = stream;
19
30
  const mediaRecorder = new MediaRecorder(stream);
20
31
  mediaRecorderRef.current = mediaRecorder;
21
32
 
@@ -27,17 +38,23 @@ export const VoiceRecorder = forwardRef(({ onVoiceRecorded, iconSize, className
27
38
  const audioBlob = new Blob(audioChunksRef.current);
28
39
  audioChunksRef.current = [];
29
40
 
30
- onVoiceRecorded(await llm.getTextFromVoice(audioBlob));
41
+ onVoiceRecordedRef.current(await llm.getTextFromVoice(audioBlob));
31
42
  };
32
43
 
33
44
  mediaRecorder.start();
34
45
  setIsRecording(true);
46
+ onRecordingStatusChange(true);
35
47
  };
36
48
 
37
49
  const stopRecording = () => {
38
50
  if (mediaRecorderRef.current) {
39
51
  mediaRecorderRef.current.stop();
40
52
  setIsRecording(false);
53
+ onRecordingStatusChange(false);
54
+ }
55
+ if (mediaStreamRef.current) {
56
+ mediaStreamRef.current.getTracks().forEach(track => track.stop());
57
+ mediaStreamRef.current = null;
41
58
  }
42
59
  };
43
60
 
@@ -46,11 +63,37 @@ export const VoiceRecorder = forwardRef(({ onVoiceRecorded, iconSize, className
46
63
  stopRecording,
47
64
  }));
48
65
 
66
+ // push to talk feature
67
+ const spacePressedRef = useRef(false);
68
+
69
+ useEffect(() => {
70
+ const handleKeyDown = async (event: KeyboardEvent) => {
71
+ if (event.code === 'Space' && !spacePressedRef.current) {
72
+ spacePressedRef.current = true;
73
+ await startRecording();
74
+ }
75
+ };
76
+ const handleKeyUp = (event: KeyboardEvent) => {
77
+ if (event.code === 'Space' && spacePressedRef.current) {
78
+ spacePressedRef.current = false;
79
+ stopRecording();
80
+ }
81
+ };
82
+ window.addEventListener('keydown', handleKeyDown);
83
+ window.addEventListener('keyup', handleKeyUp);
84
+ return () => {
85
+ window.removeEventListener('keydown', handleKeyDown);
86
+ window.removeEventListener('keyup', handleKeyUp);
87
+ };
88
+ }, []);
89
+
49
90
  return (
50
- <div className={className}>
51
- <button onClick={isRecording ? stopRecording : startRecording}>
91
+ <button className={"w-16 h-16 flex text-4xl shadow-lg flex-row justify-center items-center rounded-full mx-auto bg-gray-400 dark:bg-gray-800 pl-[6px] disabled:opacity-50 " + className}
92
+ onClick={isRecording ? stopRecording : startRecording}
93
+ disabled={disabled || loading}>
94
+ {loading ? <FaSpinner className="animate-spin mr-[6px]" /> :
52
95
  <FaMicrophone size={iconSize} className={"h-7 w-7 mr-2 " + (isRecording ? "text-red-600" : "")} />
53
- </button>
54
- </div>
96
+ }
97
+ </button>
55
98
  );
56
99
  });
@@ -1,6 +1,6 @@
1
1
  export interface FirstMessages {
2
2
  instructions?: string;
3
- userMessage: string;
3
+ userMessage?: string;
4
4
  assistantMessage?: string;
5
5
  }
6
6
 
package/src/components.ts CHANGED
@@ -7,4 +7,5 @@ export * from "./hooks/UseChatHook";
7
7
  export * from "./plugin/ThemeSetter";
8
8
  export * from "./providers/PluginProvider";
9
9
  export * from "./components/ai/Avatar";
10
- export * from "./components/ai/Assistant";
10
+ export * from "./components/ai/Assistant";
11
+ export * from "./types/Actions";