@rimori/client 1.0.4 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +13 -0
  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 +5 -3
  7. package/dist/components/ai/Avatar.js +14 -6
  8. package/dist/components/ai/EmbeddedAssistent/CircleAudioAvatar.d.ts +2 -1
  9. package/dist/components/ai/EmbeddedAssistent/CircleAudioAvatar.js +35 -14
  10. package/dist/components/ai/EmbeddedAssistent/TTS/MessageSender.d.ts +1 -0
  11. package/dist/components/ai/EmbeddedAssistent/TTS/MessageSender.js +3 -0
  12. package/dist/components/ai/EmbeddedAssistent/TTS/Player.d.ts +2 -0
  13. package/dist/components/ai/EmbeddedAssistent/TTS/Player.js +5 -0
  14. package/dist/components/ai/EmbeddedAssistent/VoiceRecoder.d.ts +3 -0
  15. package/dist/components/ai/EmbeddedAssistent/VoiceRecoder.js +41 -5
  16. package/dist/components/ai/utils.d.ts +1 -1
  17. package/dist/components.d.ts +1 -0
  18. package/dist/components.js +1 -0
  19. package/dist/controller/AIController.js +2 -1
  20. package/dist/controller/SettingsController.d.ts +18 -10
  21. package/dist/controller/SettingsController.js +28 -31
  22. package/dist/controller/SharedContentController.d.ts +58 -11
  23. package/dist/controller/SharedContentController.js +161 -26
  24. package/dist/controller/SidePluginController.d.ts +1 -12
  25. package/dist/core/components/ContextMenu.d.ts +10 -0
  26. package/dist/core/components/ContextMenu.js +93 -0
  27. package/dist/core.d.ts +2 -0
  28. package/dist/core.js +2 -0
  29. package/dist/hooks/UseChatHook.d.ts +1 -1
  30. package/dist/plugin/AccomplishmentHandler.d.ts +38 -0
  31. package/dist/plugin/AccomplishmentHandler.js +108 -0
  32. package/dist/plugin/ContextMenu.d.ts +17 -0
  33. package/dist/plugin/ContextMenu.js +45 -0
  34. package/dist/plugin/PluginController.js +9 -4
  35. package/dist/plugin/RimoriClient.d.ts +92 -65
  36. package/dist/plugin/RimoriClient.js +105 -75
  37. package/dist/plugin/ThemeSetter.js +4 -4
  38. package/dist/plugin/fromRimori/EventBus.d.ts +6 -3
  39. package/dist/plugin/fromRimori/EventBus.js +15 -9
  40. package/dist/plugin/fromRimori/PluginTypes.d.ts +51 -0
  41. package/dist/plugin/fromRimori/PluginTypes.js +1 -0
  42. package/dist/providers/PluginController.d.ts +21 -0
  43. package/dist/providers/PluginController.js +116 -0
  44. package/dist/providers/PluginProvider.js +26 -73
  45. package/dist/types/Actions.d.ts +4 -0
  46. package/dist/types/Actions.js +1 -0
  47. package/dist/utils/Language.d.ts +66 -0
  48. package/dist/utils/Language.js +67 -0
  49. package/dist/utils/difficultyConverter.d.ts +1 -0
  50. package/dist/utils/difficultyConverter.js +3 -0
  51. package/dist/worker/WorkerSetup.js +5 -4
  52. package/package.json +3 -3
  53. package/src/components/MarkdownEditor.tsx +78 -76
  54. package/src/components/ai/Assistant.tsx +1 -1
  55. package/src/components/ai/Avatar.tsx +65 -48
  56. package/src/components/ai/EmbeddedAssistent/CircleAudioAvatar.tsx +81 -58
  57. package/src/components/ai/EmbeddedAssistent/TTS/MessageSender.ts +4 -0
  58. package/src/components/ai/EmbeddedAssistent/TTS/Player.ts +6 -0
  59. package/src/components/ai/EmbeddedAssistent/VoiceRecoder.tsx +51 -8
  60. package/src/components/ai/utils.ts +1 -1
  61. package/src/components.ts +2 -1
  62. package/src/controller/AIController.ts +2 -1
  63. package/src/controller/SettingsController.ts +80 -75
  64. package/src/controller/SharedContentController.ts +214 -53
  65. package/src/controller/SidePluginController.ts +1 -13
  66. package/src/core/components/ContextMenu.tsx +123 -0
  67. package/src/core.ts +3 -1
  68. package/src/hooks/UseChatHook.ts +17 -17
  69. package/src/plugin/AccomplishmentHandler.ts +165 -0
  70. package/src/plugin/PluginController.ts +107 -100
  71. package/src/plugin/RimoriClient.ts +267 -250
  72. package/src/plugin/ThemeSetter.ts +4 -5
  73. package/src/plugin/fromRimori/EventBus.ts +23 -12
  74. package/src/plugin/fromRimori/PluginTypes.ts +67 -0
  75. package/src/providers/PluginProvider.tsx +63 -110
  76. package/src/types/Actions.ts +6 -0
  77. package/src/utils/Language.ts +70 -0
  78. package/src/utils/difficultyConverter.ts +4 -0
  79. package/src/worker/WorkerSetup.ts +5 -4
  80. package/dist/components/avatar/Assistant.d.ts +0 -9
  81. package/dist/components/avatar/Assistant.js +0 -59
  82. package/dist/components/avatar/Avatar.d.ts +0 -12
  83. package/dist/components/avatar/Avatar.js +0 -42
  84. package/dist/components/avatar/EmbeddedAssistent/AudioInputField.d.ts +0 -7
  85. package/dist/components/avatar/EmbeddedAssistent/AudioInputField.js +0 -38
  86. package/dist/components/avatar/EmbeddedAssistent/CircleAudioAvatar.d.ts +0 -7
  87. package/dist/components/avatar/EmbeddedAssistent/CircleAudioAvatar.js +0 -59
  88. package/dist/components/avatar/EmbeddedAssistent/TTS/MessageSender.d.ts +0 -19
  89. package/dist/components/avatar/EmbeddedAssistent/TTS/MessageSender.js +0 -84
  90. package/dist/components/avatar/EmbeddedAssistent/TTS/Player.d.ts +0 -25
  91. package/dist/components/avatar/EmbeddedAssistent/TTS/Player.js +0 -180
  92. package/dist/components/avatar/EmbeddedAssistent/VoiceRecoder.d.ts +0 -7
  93. package/dist/components/avatar/EmbeddedAssistent/VoiceRecoder.js +0 -45
  94. package/dist/components/avatar/utils.d.ts +0 -6
  95. package/dist/components/avatar/utils.js +0 -14
@@ -1,71 +1,232 @@
1
- import { v4 as uuidv4 } from 'uuid';
2
- import { ObjectRequest } from "./ObjectController";
1
+ import { SupabaseClient } from '@supabase/supabase-js';
3
2
  import { RimoriClient } from "../plugin/RimoriClient";
3
+ import { ObjectRequest } from "./ObjectController";
4
+
5
+ export interface BasicAssignment<T> {
6
+ id: string;
7
+ createdAt: Date;
8
+ topic: string;
9
+ createdBy: string;
10
+ verified: boolean;
11
+ keywords: any;
12
+ data: T;
13
+ }
4
14
 
5
- export interface BasicAssignment {
6
- id: string;
7
- createdAt: Date;
8
- topic: string;
9
- createdBy: string;
10
- verified: boolean;
11
- keywords: any;
15
+ export interface SharedContentObjectRequest extends ObjectRequest {
16
+ fixedProperties?: Record<string, string | number | boolean>
12
17
  }
13
18
 
19
+ export type SharedContentFilter = Record<string, string | number | boolean>
20
+
14
21
  export class SharedContentController {
15
- private rimoriClient: RimoriClient;
22
+ private supabase: SupabaseClient;
23
+ private rimoriClient: RimoriClient;
24
+
25
+ constructor(supabase: SupabaseClient, rimoriClient: RimoriClient) {
26
+ this.supabase = supabase;
27
+ this.rimoriClient = rimoriClient;
28
+ }
29
+
30
+ /**
31
+ * Fetch new shared content for a given content type.
32
+ * @param contentType - The type of content to fetch.
33
+ * @param generatorInstructions - The instructions for the generator. The object needs to have a tool property with a topic and keywords property to let a new unique topic be generated.
34
+ * @param filter - An optional filter to apply to the query.
35
+ * @param privateTopic - An optional flag to indicate if the topic should be private and only be visible to the user.
36
+ * @returns The new shared content.
37
+ */
38
+ public async getNewSharedContent<T>(
39
+ contentType: string,
40
+ generatorInstructions: SharedContentObjectRequest,
41
+ //this filter is there if the content should be filtered additionally by a column and value
42
+ filter?: SharedContentFilter,
43
+ privateTopic?: boolean,
44
+ ): Promise<BasicAssignment<T>> {
45
+ const query = this.supabase.from("shared_content")
46
+ .select("*, scc:shared_content_completed(id)")
47
+ .eq('content_type', contentType)
48
+ .is('scc.id', null)
49
+ .limit(10);
50
+
51
+ if (filter) {
52
+ query.contains('data', filter);
53
+ }
54
+
55
+ const { data: newAssignments, error } = await query;
56
+
57
+ if (error) {
58
+ console.error('error fetching new assignments:', error);
59
+ throw new Error('error fetching new assignments');
60
+ }
61
+
62
+ console.log('newAssignments:', newAssignments);
63
+
64
+ if (newAssignments.length > 0) {
65
+ const index = Math.floor(Math.random() * newAssignments.length);
66
+ return newAssignments[index];
67
+ }
68
+
69
+ // generate new assignments
70
+ const fullInstructions = await this.getGeneratorInstructions(contentType, generatorInstructions, filter);
71
+
72
+ console.log('fullInstructions:', fullInstructions);
73
+
74
+ const instructions = await this.rimoriClient.llm.getObject(fullInstructions);
75
+
76
+ console.log('instructions:', instructions);
77
+
78
+ const { data: newAssignment, error: insertError } = await this.supabase.from("shared_content").insert({
79
+ private: privateTopic,
80
+ content_type: contentType,
81
+ topic: instructions.topic,
82
+ keywords: instructions.keywords.map(({ text }: { text: string }) => text),
83
+ data: { ...instructions, topic: undefined, keywords: undefined, ...generatorInstructions.fixedProperties },
84
+ }).select();
85
+
86
+ if (insertError) {
87
+ console.error('error inserting new assignment:', insertError);
88
+ throw new Error('error inserting new assignment');
89
+ }
90
+
91
+ return newAssignment[0];
92
+ }
16
93
 
17
- constructor(rimoriClient: RimoriClient) {
18
- this.rimoriClient = rimoriClient;
94
+ private async getGeneratorInstructions(contentType: string, generatorInstructions: ObjectRequest, filter?: SharedContentFilter): Promise<ObjectRequest> {
95
+ const completedTopics = await this.getCompletedTopics(contentType, filter);
96
+
97
+ generatorInstructions.instructions += `
98
+ The following topics are already taken: ${completedTopics.join(', ')}`;
99
+
100
+ generatorInstructions.tool.topic = {
101
+ type: "string",
102
+ description: "What the topic is about. Short. ",
103
+ }
104
+ generatorInstructions.tool.keywords = {
105
+ type: [{ text: { type: "string" } }],
106
+ description: "Keywords around the topic of the assignment.",
107
+ }
108
+ return generatorInstructions;
109
+ }
110
+
111
+ private async getCompletedTopics(contentType: string, filter?: SharedContentFilter): Promise<string[]> {
112
+ const query = this.supabase.from("shared_content")
113
+ .select("topic, keywords, scc:shared_content_completed(id)")
114
+ .eq('content_type', contentType)
115
+ .not('scc.id', 'is', null)
116
+
117
+ if (filter) {
118
+ query.contains('data', filter);
19
119
  }
20
120
 
21
- public async fetchNewSharedContent<T, R = T & BasicAssignment>(
22
- type: string,
23
- generatorInstructions: (reservedTopics: string[]) => Promise<ObjectRequest> | ObjectRequest,
24
- filter?: { column: string, value: string | number | boolean },
25
- ): Promise<R[]> {
26
- const queryParameter = { filter_column: filter?.column || null, filter_value: filter?.value || null, unread: true }
27
- const { data: newAssignments } = await this.rimoriClient.db.rpc(type + "_entries", queryParameter)
28
- console.log('newAssignments:', newAssignments);
29
-
30
- if ((newAssignments as any[]).length > 0) {
31
- return newAssignments as R[];
32
- }
33
- // generate new assignments
34
- const { data: oldAssignments } = await this.rimoriClient.db.rpc(type + "_entries", { ...queryParameter, unread: false })
35
- console.log('oldAssignments:', oldAssignments);
36
- const reservedTopics = this.getReservedTopics(oldAssignments as BasicAssignment[]);
37
-
38
- const request = await generatorInstructions(reservedTopics);
39
- if (!request.tool.keywords || !request.tool.topic) {
40
- throw new Error("topic or keywords not found in the request schema");
41
- }
42
- const instructions = await this.rimoriClient.llm.getObject(request);
43
- console.log('instructions:', instructions);
44
-
45
- const preparedData = {
46
- id: uuidv4(),
47
- ...instructions,
48
- keywords: this.purifyStringArray(instructions.keywords),
49
- };
50
- return await this.rimoriClient.db.from(type).insert(preparedData).then(() => [preparedData] as R[]);
121
+ const { data: oldAssignments, error } = await query;
122
+
123
+ if (error) {
124
+ console.error('error fetching old assignments:', error);
125
+ return [];
51
126
  }
127
+ return oldAssignments.map(({ topic, keywords }) => `${topic}(${keywords.join(',')})`);
128
+ }
52
129
 
53
- private getReservedTopics(oldAssignments: BasicAssignment[]) {
54
- return oldAssignments.map(({ topic, keywords }) => {
55
- const keywordTexts = this.purifyStringArray(keywords).join(',');
56
- return `${topic}(${keywordTexts})`;
57
- });
130
+ public async getSharedContent<T>(contentType: string, id: string): Promise<BasicAssignment<T>> {
131
+ const { data, error } = await this.supabase.from("shared_content").select().eq('content_type', contentType).eq('id', id).single();
132
+ if (error) {
133
+ console.error('error fetching shared content:', error);
134
+ throw new Error('error fetching shared content');
58
135
  }
136
+ return data;
137
+ }
138
+
139
+ public async completeSharedContent(contentType: string, assignmentId: string) {
140
+ await this.supabase.from("shared_content_completed").insert({ content_type: contentType, id: assignmentId });
141
+ }
142
+
143
+ /**
144
+ * Fetch shared content from the database based on optional filters.
145
+ * @param contentType - The type of content to fetch.
146
+ * @param filter - Optional filter to apply to the query.
147
+ * @param limit - Optional limit for the number of results.
148
+ * @returns Array of shared content matching the criteria.
149
+ */
150
+ public async getSharedContentList<T>(contentType: string, filter?: SharedContentFilter, limit?: number): Promise<BasicAssignment<T>[]> {
151
+ const query = this.supabase.from("shared_content").select("*").eq('content_type', contentType).limit(limit ?? 30);
59
152
 
60
- private purifyStringArray(array: { text: string }[]): string[] {
61
- return array.map(({ text }) => text);
153
+ if (filter) {
154
+ query.contains('data', filter);
62
155
  }
63
156
 
64
- public async getSharedContent<T extends BasicAssignment>(type: string, id: string): Promise<T> {
65
- return await this.rimoriClient.db.from(type).select().eq('id', id).single() as unknown as T;
157
+ const { data, error } = await query;
158
+
159
+ if (error) {
160
+ console.error('error fetching shared content:', error);
161
+ throw new Error('error fetching shared content');
66
162
  }
67
163
 
68
- public async completeSharedContent(type: string, assignmentId: string) {
69
- await this.rimoriClient.db.from(type + "_result").insert({ assignment_id: assignmentId });
164
+ return data;
165
+ }
166
+
167
+ /**
168
+ * Insert new shared content into the database.
169
+ * @param param
170
+ * @param param.contentType - The type of content to insert.
171
+ * @param param.topic - The topic of the content.
172
+ * @param param.keywords - Keywords associated with the content.
173
+ * @param param.data - The content data to store.
174
+ * @param param.privateTopic - Optional flag to indicate if the topic should be private.
175
+ * @returns The inserted shared content.
176
+ * @throws {Error} if insertion fails.
177
+ */
178
+ public async createSharedContent<T>({ contentType, topic, keywords, data, privateTopic }: SharedContent<T>): Promise<BasicAssignment<T>> {
179
+ const { data: newContent, error } = await this.supabase.from("shared_content").insert({
180
+ private: privateTopic,
181
+ content_type: contentType,
182
+ topic,
183
+ keywords,
184
+ data,
185
+ }).select();
186
+
187
+ if (error) {
188
+ console.error('error inserting shared content:', error);
189
+ throw new Error('error inserting shared content');
70
190
  }
191
+
192
+ return newContent[0];
193
+ }
194
+
195
+ /**
196
+ * Update existing shared content in the database.
197
+ * @param id - The ID of the content to update.
198
+ * @param updates - The updates to apply to the shared content.
199
+ * @returns The updated shared content.
200
+ * @throws {Error} if update fails.
201
+ */
202
+ public async updateSharedContent<T>(id: string, updates: Partial<SharedContent<T>>): Promise<BasicAssignment<T>> {
203
+ const updateData: any = {};
204
+
205
+ if (updates.contentType) updateData.content_type = updates.contentType;
206
+ if (updates.topic) updateData.topic = updates.topic;
207
+ if (updates.keywords) updateData.keywords = updates.keywords;
208
+ if (updates.data) updateData.data = updates.data;
209
+ if (updates.privateTopic !== undefined) updateData.private = updates.privateTopic;
210
+
211
+ const { data: updatedContent, error } = await this.supabase.from("shared_content").update(updateData).eq('id', id).select();
212
+
213
+ if (error) {
214
+ console.error('error updating shared content:', error);
215
+ throw new Error('error updating shared content');
216
+ }
217
+
218
+ if (!updatedContent || updatedContent.length === 0) {
219
+ throw new Error('shared content not found');
220
+ }
221
+
222
+ return updatedContent[0];
223
+ }
224
+ }
225
+
226
+ export interface SharedContent<T> {
227
+ contentType: string;
228
+ topic: string;
229
+ keywords: string[];
230
+ data: T;
231
+ privateTopic?: boolean;
71
232
  }
@@ -1,17 +1,5 @@
1
1
  import { SupabaseClient } from '@supabase/supabase-js';
2
-
3
- export interface Plugin {
4
- id: string;
5
- title: string;
6
- icon_url: string;
7
- website: string;
8
- context_menu_actions: string;
9
- plugin_pages: string;
10
- sidebar_pages: string;
11
- settings_page: string;
12
- version: string;
13
- external_hosted_url: string;
14
- }
2
+ import { Plugin } from '../plugin/fromRimori/PluginTypes';
15
3
 
16
4
  export async function getPlugins(supabase: SupabaseClient): Promise<Plugin[]> {
17
5
  let { data, error } = await supabase.from('plugins').select('*');
@@ -0,0 +1,123 @@
1
+ import React, { useState, useEffect, useRef } from "react";
2
+ import { EventBus } from "../../plugin/fromRimori/EventBus";
3
+ import { RimoriClient } from "../../plugin/RimoriClient";
4
+ import { MenuEntry } from "../../plugin/fromRimori/PluginTypes";
5
+
6
+ export interface Position {
7
+ x: number,
8
+ y: number,
9
+ text?: string
10
+ }
11
+
12
+ const ContextMenu = ({ client }: { client: RimoriClient }) => {
13
+ const [isOpen, setIsOpen] = useState<boolean>(false);
14
+ const [actions, setActions] = useState<MenuEntry[]>([]);
15
+ const [position, setPosition] = useState<Position>({ x: 0, y: 0 });
16
+ const [openOnTextSelect, setOpenOnTextSelect] = useState(false);
17
+ const menuRef = useRef<HTMLDivElement>(null);
18
+
19
+ useEffect(() => {
20
+ client.plugin.getInstalled().then(plugins => {
21
+ setActions(plugins.flatMap(p => p.context_menu_actions).filter(Boolean));
22
+ });
23
+
24
+ client.plugin.getUserInfo().then((userInfo) => {
25
+ setOpenOnTextSelect(userInfo.context_menu_on_select);
26
+ })
27
+
28
+ EventBus.on<{ actions: MenuEntry[] }>("global.contextMenu.createActions", ({ data }) => {
29
+ setActions([...data.actions, ...actions]);
30
+ });
31
+ }, []);
32
+
33
+ useEffect(() => {
34
+ // Track mouse position globally
35
+ const handleMouseMove = (e: MouseEvent) => {
36
+ const selectedText = window.getSelection()?.toString().trim();
37
+ if (isOpen && selectedText === position.text) return;
38
+ setPosition({ x: e.clientX, y: e.clientY, text: selectedText });
39
+ };
40
+
41
+ const handleMouseUp = (e: MouseEvent) => {
42
+ const selectedText = window.getSelection()?.toString().trim();
43
+ // Check if click is inside the context menu
44
+ if (menuRef.current && menuRef.current.contains(e.target as Node)) {
45
+ // Don't close the menu if clicking inside
46
+ return;
47
+ }
48
+
49
+ // Prevent context menu on textarea or text input selection
50
+ const target = e.target as HTMLElement;
51
+ const isTextInput = target && (
52
+ (target.tagName === 'TEXTAREA') ||
53
+ (target.tagName === 'INPUT' && (target as HTMLInputElement).type === 'text')
54
+ );
55
+ if (isTextInput) {
56
+ setIsOpen(false);
57
+ return;
58
+ }
59
+
60
+ if (e.button === 0 && isOpen) {
61
+ setIsOpen(false);
62
+ window.getSelection()?.removeAllRanges();
63
+ } else if (selectedText && (openOnTextSelect || e.button === 2)) {
64
+ if (e.button === 2) {
65
+ e.preventDefault();
66
+ }
67
+ setPosition({ x: e.clientX, y: e.clientY, text: selectedText });
68
+ setIsOpen(true);
69
+ } else {
70
+ setIsOpen(false);
71
+ }
72
+ };
73
+
74
+ // Add selectionchange listener to close menu if selection is cleared
75
+ const handleSelectionChange = () => {
76
+ const selectedText = window.getSelection()?.toString().trim();
77
+ if (!selectedText && isOpen) {
78
+ setIsOpen(false);
79
+ }
80
+ };
81
+
82
+ document.addEventListener("mouseup", handleMouseUp);
83
+ window.addEventListener("mousemove", handleMouseMove);
84
+ document.addEventListener("contextmenu", handleMouseUp);
85
+ document.addEventListener("selectionchange", handleSelectionChange);
86
+
87
+ return () => {
88
+ document.removeEventListener("mouseup", handleMouseUp);
89
+ window.removeEventListener("mousemove", handleMouseMove);
90
+ document.removeEventListener("contextmenu", handleMouseUp);
91
+ document.removeEventListener("selectionchange", handleSelectionChange);
92
+ };
93
+ }, [openOnTextSelect, isOpen, position.text]);
94
+
95
+ if (!isOpen) {
96
+ return null;
97
+ }
98
+
99
+ return (
100
+ <div
101
+ ref={menuRef}
102
+ className="fixed bg-gray-400 dark:bg-gray-700 shadow-lg border border-gray-400 rounded-md overflow-hidden dark:text-white z-50"
103
+ style={{ top: position.y, left: position.x }}>
104
+ {actions.map((action, index) => (
105
+ <MenuEntryItem key={index} icon={action.icon} text={action.text} onClick={() => {
106
+ setIsOpen(false);
107
+ window.getSelection()?.removeAllRanges();
108
+ client.event.emitSidebarAction(action.pluginId, action.actionKey, position.text);
109
+ }} />
110
+ ))}
111
+ </div>
112
+ );
113
+ };
114
+
115
+ function MenuEntryItem(props: { icon: React.ReactNode, text: string, onClick: () => void }) {
116
+ return <button onClick={props.onClick} className="px-4 py-2 text-left hover:bg-gray-500 dark:hover:bg-gray-600 w-full flex flex-row">
117
+ <span className="flex-grow">{props.icon}</span>
118
+ <span className="flex-grow">{props.text}</span>
119
+ {/* <span className="text-sm">Ctrl+Shift+xxxx</span> */}
120
+ </button>
121
+ }
122
+
123
+ export default ContextMenu;
package/src/core.ts CHANGED
@@ -7,4 +7,6 @@ export * from "./plugin/PluginController";
7
7
  export * from "./utils/difficultyConverter";
8
8
  export * from "./utils/PluginUtils";
9
9
  export * from "./worker/WorkerSetup";
10
- export * from "./plugin/fromRimori/EventBus";
10
+ export * from "./plugin/fromRimori/EventBus";
11
+ export * from "./plugin/AccomplishmentHandler";
12
+ export * from "./utils/Language";
@@ -1,25 +1,25 @@
1
1
  import React from "react";
2
+ import { Message, Tool, ToolInvocation } from "../controller/AIController";
2
3
  import { usePlugin } from "../providers/PluginProvider";
3
- import { ToolInvocation, Tool, Message } from "../controller/AIController";
4
4
 
5
5
  export function useChat(tools?: Tool[]) {
6
- const [messages, setMessages] = React.useState<Message[]>([]);
7
- const [isLoading, setIsLoading] = React.useState(false);
8
- const { llm } = usePlugin();
6
+ const [messages, setMessages] = React.useState<Message[]>([]);
7
+ const [isLoading, setIsLoading] = React.useState(false);
8
+ const { llm } = usePlugin();
9
9
 
10
- const append = (appendMessages: Message[]) => {
11
- llm.getSteamedText([...messages, ...appendMessages], (id, message, finished: boolean, toolInvocations?: ToolInvocation[]) => {
12
- const lastMessage = messages[messages.length - 1];
13
- setIsLoading(!finished);
10
+ const append = (appendMessages: Message[]) => {
11
+ llm.getSteamedText([...messages, ...appendMessages], (id, message, finished: boolean, toolInvocations?: ToolInvocation[]) => {
12
+ const lastMessage = messages[messages.length - 1];
13
+ setIsLoading(!finished);
14
14
 
15
- if (lastMessage?.id === id) {
16
- lastMessage.content = message;
17
- setMessages([...messages, lastMessage]);
18
- } else {
19
- setMessages([...messages, ...appendMessages, { id, role: 'assistant', content: message, toolInvocations }]);
20
- }
21
- }, tools);
22
- };
15
+ if (lastMessage?.id === id) {
16
+ lastMessage.content = message;
17
+ setMessages([...messages, lastMessage]);
18
+ } else {
19
+ setMessages([...messages, ...appendMessages, { id, role: 'assistant', content: message, toolInvocations }]);
20
+ }
21
+ }, tools);
22
+ };
23
23
 
24
- return { messages, append, isLoading, setMessages, lastMessage: messages[messages.length - 1] as Message | undefined };
24
+ return { messages, append, isLoading, setMessages, lastMessage: messages[messages.length - 1] as Message | undefined };
25
25
  }
@@ -0,0 +1,165 @@
1
+ import { EventBus, EventBusMessage } from "./fromRimori/EventBus";
2
+
3
+ export type AccomplishmentMessage = EventBusMessage<MicroAccomplishmentPayload>;
4
+
5
+ export const skillCategories = ["reading", "listening", "speaking", "writing", "learning", "community"] as const;
6
+
7
+ interface BaseAccomplishmentPayload {
8
+ type: "micro" | "macro";
9
+ skillCategory: (typeof skillCategories)[number];
10
+ /*
11
+ what is the accomplishment? e.g. chapter, flashcard, story, etc.
12
+ only one keyword per skill category, written in lowercase without spaces, numbers, or special characters
13
+ */
14
+ accomplishmentKeyword: string;
15
+ // the human readable description of the accomplishment. Important for other plugin developers to understand the accomplishment.
16
+ description: string;
17
+ meta?: {
18
+ //the key of the meta data in snake_case
19
+ key: string;
20
+ //the value of the meta data
21
+ value: string | number | boolean;
22
+ //the human readable description of the meta data. Important for other plugin developers to understand the meta data.
23
+ description: string;
24
+ }[];
25
+ }
26
+
27
+ export interface MicroAccomplishmentPayload extends BaseAccomplishmentPayload {
28
+ type: "micro";
29
+ }
30
+
31
+ export interface MacroAccomplishmentPayload extends BaseAccomplishmentPayload {
32
+ type: "macro";
33
+ errorRatio: number;
34
+ durationMinutes: number;
35
+ }
36
+
37
+ export type AccomplishmentPayload = MicroAccomplishmentPayload | MacroAccomplishmentPayload;
38
+
39
+ export class AccomplishmentHandler {
40
+ private pluginId: string;
41
+
42
+ public constructor(pluginId: string) {
43
+ this.pluginId = pluginId;
44
+ }
45
+
46
+ emitAccomplishment(payload: Omit<AccomplishmentPayload, "type">) {
47
+ const accomplishmentPayload = { ...payload, type: "durationMinutes" in payload ? "macro" : "micro" } as AccomplishmentPayload;
48
+
49
+ this.validateAccomplishment(accomplishmentPayload);
50
+
51
+ const sanitizedPayload = this.sanitizeAccomplishment(accomplishmentPayload);
52
+
53
+ const topic = "global.accomplishment.trigger" + (accomplishmentPayload.type === "macro" ? "Macro" : "Micro");
54
+
55
+ EventBus.emit(this.pluginId, topic, sanitizedPayload);
56
+ }
57
+
58
+ private validateAccomplishment(payload: AccomplishmentPayload) {
59
+ if (!skillCategories.includes(payload.skillCategory)) {
60
+ throw new Error(`Invalid skill category: ${payload.skillCategory}`);
61
+ }
62
+
63
+ //regex validate accomplishmentKeyword
64
+ if (!/^[a-z_-]+$/.test(payload.accomplishmentKeyword)) {
65
+ throw new Error(`The accomplishment keyword: ${payload.accomplishmentKeyword} is invalid. Only lowercase letters, minuses and underscores are allowed`);
66
+ }
67
+
68
+ //description is required
69
+ if (payload.description.length < 10) {
70
+ throw new Error("Description is too short");
71
+ }
72
+
73
+ //check that the type is valid
74
+ if (!["micro", "macro"].includes(payload.type)) {
75
+ throw new Error("Invalid accomplishment type " + payload.type);
76
+ }
77
+
78
+ //durationMinutes is required
79
+ if (payload.type === "macro" && payload.durationMinutes < 4) {
80
+ throw new Error("The duration must be at least 4 minutes");
81
+ }
82
+
83
+ //errorRatio is required
84
+ if (payload.type === "macro" && (payload.errorRatio < 0 || payload.errorRatio > 1)) {
85
+ throw new Error("The error ratio must be between 0 and 1");
86
+ }
87
+
88
+ //regex check meta data key
89
+ if (payload.meta) {
90
+ payload.meta.forEach(meta => {
91
+ if (!/^[a-z_]+$/.test(meta.key)) {
92
+ throw new Error("Invalid meta data key " + meta.key + ", only lowercase letters and underscores are allowed");
93
+ }
94
+ });
95
+ }
96
+ }
97
+
98
+ private sanitizeAccomplishment(payload: AccomplishmentPayload) {
99
+ payload.description = payload.description.replace(/[^\x20-\x7E]/g, "");
100
+
101
+ payload.meta?.forEach((meta) => {
102
+ meta.description = meta.description.replace(/[^\x20-\x7E]/g, "");
103
+ });
104
+
105
+ return payload;
106
+ }
107
+
108
+ private getDecoupledTopic(topic: string) {
109
+ const [plugin, skillCategory, accomplishmentKeyword] = topic.split(".");
110
+
111
+ return { plugin: plugin || "*", skillCategory: skillCategory || "*", accomplishmentKeyword: accomplishmentKeyword || "*" };
112
+ }
113
+
114
+ /**
115
+ * Subscribe to accomplishment events
116
+ * @param accomplishmentTopic - The topic of the accomplishment event. The pattern can be any pattern of plugin.skillCategory.accomplishmentKeyword or an * as wildcard for any plugin, skill category or accomplishment keyword
117
+ * @param callback - The callback function to be called when the accomplishment event is triggered
118
+ */
119
+ subscribe(accomplishmentTopics = "*" as string | string[], callback: (payload: EventBusMessage<AccomplishmentPayload>) => void) {
120
+ if (typeof accomplishmentTopics === "string") {
121
+ accomplishmentTopics = [accomplishmentTopics];
122
+ }
123
+
124
+ accomplishmentTopics.forEach((accomplishmentTopic) => {
125
+ const topicLength = accomplishmentTopic.split(".").length
126
+ if (topicLength === 1) {
127
+ accomplishmentTopic += ".*.*"
128
+ } else if (topicLength === 2) {
129
+ accomplishmentTopic += ".*"
130
+ } else if (topicLength !== 3) {
131
+ throw new Error("Invalid accomplishment topic pattern. The pattern must be plugin.skillCategory.accomplishmentKeyword or an * as wildcard for any plugin, skill category or accomplishment keyword");
132
+ }
133
+
134
+ EventBus.on<AccomplishmentPayload>(["global.accomplishment.triggerMicro", "global.accomplishment.triggerMacro"], (event) => {
135
+ const { plugin, skillCategory, accomplishmentKeyword } = this.getDecoupledTopic(accomplishmentTopic);
136
+
137
+ if (plugin !== "*" && event.sender !== plugin) return;
138
+ if (skillCategory !== "*" && event.data.skillCategory !== skillCategory) return;
139
+ if (accomplishmentKeyword !== "*" && event.data.accomplishmentKeyword !== accomplishmentKeyword) return;
140
+
141
+ callback(event);
142
+ }, [this.pluginId]);
143
+ });
144
+ }
145
+ }
146
+
147
+ // const accomplishmentHandler = AccomplishmentHandler.getInstance("my-plugin");
148
+
149
+ // accomplishmentHandler.subscribe("*", (payload) => {
150
+ // console.log(payload);
151
+ // });
152
+
153
+ // accomplishmentHandler.emitAccomplishment({
154
+ // skillCategory: "reading",
155
+ // accomplishmentKeyword: "chapter",
156
+ // description: "Read chapter 1 of the book",
157
+ // durationMinutes: 10,
158
+ // meta: [
159
+ // {
160
+ // key: "book",
161
+ // value: "The Great Gatsby",
162
+ // description: "The book I read",
163
+ // },
164
+ // ],
165
+ // });