@rimori/client 1.2.0 → 1.3.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 (75) hide show
  1. package/README.md +61 -18
  2. package/dist/cli/scripts/init/dev-registration.js +0 -1
  3. package/dist/cli/scripts/init/main.d.ts +1 -1
  4. package/dist/cli/scripts/init/main.js +1 -0
  5. package/dist/components/LoggerExample.d.ts +6 -0
  6. package/dist/components/LoggerExample.js +79 -0
  7. package/dist/components/ai/Assistant.js +2 -2
  8. package/dist/components/ai/Avatar.js +2 -2
  9. package/dist/components/ai/EmbeddedAssistent/VoiceRecoder.js +41 -32
  10. package/dist/components/audio/Playbutton.js +2 -2
  11. package/dist/components/components/ContextMenu.js +48 -9
  12. package/dist/core/controller/AIController.js +202 -69
  13. package/dist/core/controller/AudioController.d.ts +0 -0
  14. package/dist/core/controller/AudioController.js +1 -0
  15. package/dist/core/controller/ObjectController.d.ts +2 -2
  16. package/dist/core/controller/ObjectController.js +8 -8
  17. package/dist/core/controller/SettingsController.d.ts +16 -0
  18. package/dist/core/controller/SharedContentController.d.ts +30 -2
  19. package/dist/core/controller/SharedContentController.js +74 -23
  20. package/dist/core/controller/VoiceController.d.ts +2 -3
  21. package/dist/core/controller/VoiceController.js +11 -4
  22. package/dist/core/core.d.ts +1 -0
  23. package/dist/fromRimori/EventBus.js +1 -1
  24. package/dist/fromRimori/PluginTypes.d.ts +7 -4
  25. package/dist/hooks/UseChatHook.js +6 -4
  26. package/dist/hooks/UseLogger.d.ts +30 -0
  27. package/dist/hooks/UseLogger.js +122 -0
  28. package/dist/index.d.ts +1 -0
  29. package/dist/index.js +1 -0
  30. package/dist/plugin/AudioController.d.ts +37 -0
  31. package/dist/plugin/AudioController.js +68 -0
  32. package/dist/plugin/Logger.d.ts +68 -0
  33. package/dist/plugin/Logger.js +256 -0
  34. package/dist/plugin/LoggerExample.d.ts +16 -0
  35. package/dist/plugin/LoggerExample.js +140 -0
  36. package/dist/plugin/PluginController.d.ts +15 -3
  37. package/dist/plugin/PluginController.js +162 -39
  38. package/dist/plugin/RimoriClient.d.ts +55 -13
  39. package/dist/plugin/RimoriClient.js +60 -23
  40. package/dist/plugin/StandaloneClient.d.ts +1 -0
  41. package/dist/plugin/StandaloneClient.js +16 -5
  42. package/dist/plugin/ThemeSetter.d.ts +2 -2
  43. package/dist/plugin/ThemeSetter.js +8 -5
  44. package/dist/providers/PluginProvider.d.ts +1 -1
  45. package/dist/providers/PluginProvider.js +36 -10
  46. package/dist/utils/audioFormats.d.ts +26 -0
  47. package/dist/utils/audioFormats.js +67 -0
  48. package/dist/worker/WorkerSetup.d.ts +3 -2
  49. package/dist/worker/WorkerSetup.js +22 -67
  50. package/package.json +7 -6
  51. package/src/cli/scripts/init/dev-registration.ts +0 -1
  52. package/src/cli/scripts/init/main.ts +1 -0
  53. package/src/components/ai/Assistant.tsx +2 -2
  54. package/src/components/ai/Avatar.tsx +2 -2
  55. package/src/components/ai/EmbeddedAssistent/VoiceRecoder.tsx +39 -32
  56. package/src/components/audio/Playbutton.tsx +2 -2
  57. package/src/components/components/ContextMenu.tsx +53 -9
  58. package/src/core/controller/AIController.ts +236 -75
  59. package/src/core/controller/ObjectController.ts +8 -8
  60. package/src/core/controller/SettingsController.ts +16 -0
  61. package/src/core/controller/SharedContentController.ts +87 -25
  62. package/src/core/controller/VoiceController.ts +24 -19
  63. package/src/core/core.ts +1 -0
  64. package/src/fromRimori/EventBus.ts +1 -1
  65. package/src/fromRimori/PluginTypes.ts +6 -4
  66. package/src/hooks/UseChatHook.ts +6 -4
  67. package/src/index.ts +1 -0
  68. package/src/plugin/AudioController.ts +58 -0
  69. package/src/plugin/Logger.ts +324 -0
  70. package/src/plugin/PluginController.ts +171 -43
  71. package/src/plugin/RimoriClient.ts +95 -30
  72. package/src/plugin/StandaloneClient.ts +22 -6
  73. package/src/plugin/ThemeSetter.ts +8 -5
  74. package/src/providers/PluginProvider.tsx +40 -10
  75. package/src/worker/WorkerSetup.ts +14 -63
@@ -22,7 +22,11 @@ export class SharedContentController {
22
22
  * @param contentType - The type of content to fetch.
23
23
  * @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.
24
24
  * @param filter - An optional filter to apply to the query.
25
- * @param privateTopic - An optional flag to indicate if the topic should be private and only be visible to the user.
25
+ * @param options - Optional options.
26
+ * @param options.privateTopic - If the topic should be private and only be visible to the user.
27
+ * @param options.skipDbSave - If true, do not persist a newly generated content to the DB (default false).
28
+ * @param options.alwaysGenerateNew - If true, always generate a new content even if there is already a content with the same filter.
29
+ * @param options.excludeIds - Optional list of shared_content ids to exclude from selection.
26
30
  * @returns The new shared content.
27
31
  */
28
32
  public async getNewSharedContent<T>(
@@ -30,56 +34,66 @@ export class SharedContentController {
30
34
  generatorInstructions: SharedContentObjectRequest,
31
35
  //this filter is there if the content should be filtered additionally by a column and value
32
36
  filter?: SharedContentFilter,
33
- privateTopic?: boolean,
37
+ options?: { privateTopic?: boolean, skipDbSave?: boolean, alwaysGenerateNew?: boolean, excludeIds?: string[] },
34
38
  ): Promise<SharedContent<T>> {
35
- const query = this.supabase.from("shared_content")
36
- .select("*, scc:shared_content_completed(id)")
39
+ let query = this.supabase.from("shared_content")
40
+ .select("*, scc:shared_content_completed(id, state)")
37
41
  .eq('content_type', contentType)
38
- .is('scc.id', null)
39
- .is('deleted_at', null)
40
- .limit(10);
42
+ .not('scc.state', 'in', '("completed","ongoing","hidden")')
43
+ .is('deleted_at', null);
44
+
45
+ if (options?.excludeIds && options.excludeIds.length > 0) {
46
+ const excludeIds = options.excludeIds.filter((id) => !id.startsWith('internal-temp-id-'));
47
+ // Supabase expects raw PostgREST syntax like '("id1","id2")'.
48
+ const excludeList = `(${excludeIds.map((id) => `"${id}"`).join(',')})`;
49
+ query = query.not('id', 'in', excludeList);
50
+ }
41
51
 
42
52
  if (filter) {
43
53
  query.contains('data', filter);
44
54
  }
45
55
 
46
- const { data: newAssignments, error } = await query;
56
+ const { data: newAssignments, error } = await query.limit(30);
47
57
 
48
58
  if (error) {
49
59
  console.error('error fetching new assignments:', error);
50
60
  throw new Error('error fetching new assignments');
51
61
  }
52
62
 
53
- console.log('newAssignments:', newAssignments);
63
+ // console.log('newAssignments:', newAssignments);
54
64
 
55
- if (newAssignments.length > 0) {
65
+ if (!(options?.alwaysGenerateNew) && newAssignments.length > 0) {
56
66
  const index = Math.floor(Math.random() * newAssignments.length);
57
67
  return newAssignments[index];
58
68
  }
59
69
 
60
- // generate new assignments
61
- const fullInstructions = await this.getGeneratorInstructions(contentType, generatorInstructions, filter);
62
-
63
- console.log('fullInstructions:', fullInstructions);
64
-
65
- const instructions = await this.rimoriClient.ai.getObject(fullInstructions);
70
+ const instructions = await this.generateNewAssignment(contentType, generatorInstructions, filter);
66
71
 
67
72
  console.log('instructions:', instructions);
68
73
 
69
- const { data: newAssignment, error: insertError } = await this.supabase.from("shared_content").insert({
70
- private: privateTopic,
71
- content_type: contentType,
74
+ //create the shared content object
75
+ const data: SharedContent<T> = {
76
+ id: "internal-temp-id-" + Math.random().toString(36).substring(2, 15),
77
+ contentType,
72
78
  title: instructions.title,
73
79
  keywords: instructions.keywords.map(({ text }: { text: string }) => text),
74
80
  data: { ...instructions, title: undefined, keywords: undefined, ...generatorInstructions.fixedProperties },
75
- }).select();
81
+ privateTopic: options?.privateTopic,
82
+ }
76
83
 
77
- if (insertError) {
78
- console.error('error inserting new assignment:', insertError);
79
- throw new Error('error inserting new assignment');
84
+ if (options?.skipDbSave) {
85
+ return data;
80
86
  }
81
87
 
82
- return newAssignment[0];
88
+ return await this.createSharedContent(data);
89
+ }
90
+
91
+ private async generateNewAssignment(contentType: string, generatorInstructions: SharedContentObjectRequest, filter?: SharedContentFilter): Promise<any> {
92
+ const fullInstructions = await this.getGeneratorInstructions(contentType, generatorInstructions, filter);
93
+
94
+ console.log('fullInstructions:', fullInstructions);
95
+
96
+ return await this.rimoriClient.ai.getObject(fullInstructions);
83
97
  }
84
98
 
85
99
  private async getGeneratorInstructions(contentType: string, generatorInstructions: ObjectRequest, filter?: SharedContentFilter): Promise<ObjectRequest> {
@@ -129,7 +143,55 @@ export class SharedContentController {
129
143
  }
130
144
 
131
145
  public async completeSharedContent(contentType: string, assignmentId: string) {
132
- await this.supabase.from("shared_content_completed").insert({ content_type: contentType, id: assignmentId });
146
+ // Idempotent completion: upsert on (id, user_id) so repeated calls don't fail
147
+ const { error } = await this.supabase
148
+ .from("shared_content_completed")
149
+ .upsert({ content_type: contentType, id: assignmentId } as any, { onConflict: 'id' });
150
+
151
+ if (error) {
152
+ console.error('error completing shared content:', error);
153
+ throw new Error('error completing shared content');
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Update state details for a shared content entry in shared_content_completed.
159
+ * Assumes table has columns: state ('completed'|'ongoing'|'hidden'), reaction ('liked'|'disliked'|null), bookmarked boolean.
160
+ * Upserts per (id, content_type, user).
161
+ * @param param
162
+ * @param param.contentType - The content type.
163
+ * @param param.id - The shared content id.
164
+ * @param param.state - The state to set.
165
+ * @param param.reaction - Optional reaction.
166
+ * @param param.bookmarked - Optional bookmark flag.
167
+ */
168
+ public async updateSharedContentState({
169
+ contentType,
170
+ id,
171
+ state,
172
+ reaction,
173
+ bookmarked,
174
+ }: {
175
+ contentType: string
176
+ id: string
177
+ state?: 'completed' | 'ongoing' | 'hidden'
178
+ reaction?: 'liked' | 'disliked' | null
179
+ bookmarked?: boolean
180
+ }): Promise<void> {
181
+ const payload: Record<string, unknown> = { content_type: contentType, id };
182
+ if (state !== undefined) payload.state = state;
183
+ if (reaction !== undefined) payload.reaction = reaction;
184
+ if (bookmarked !== undefined) payload.bookmarked = bookmarked;
185
+
186
+ // Prefer upsert, fall back to insert/update if upsert not allowed
187
+ const { error } = await this.supabase
188
+ .from('shared_content_completed')
189
+ .upsert(payload as any, { onConflict: 'id' });
190
+
191
+ if (error) {
192
+ console.error('error updating shared content state:', error);
193
+ throw new Error('error updating shared content state');
194
+ }
133
195
  }
134
196
 
135
197
  /**
@@ -1,26 +1,31 @@
1
- import { SupabaseClient } from "@supabase/supabase-js";
1
+ export async function getSTTResponse(backendUrl: string, audio: Blob, token: string) {
2
+ const formData = new FormData();
3
+ formData.append('file', audio);
2
4
 
3
- export async function getSTTResponse(supabase: SupabaseClient, audio: Blob) {
4
- const formData = new FormData();
5
- formData.append('file', audio);
6
-
7
- return await supabase.functions.invoke('speech', { method: 'POST', body: formData }).then(({ data }) => data.text);
5
+ return await fetch(`${backendUrl}/voice/stt`, {
6
+ method: 'POST',
7
+ headers: { 'Authorization': `Bearer ${token}` },
8
+ body: formData,
9
+ }).then(r => r.json()).then(r => {
10
+ // console.log("STT response: ", r);
11
+ return r.text;
12
+ });
8
13
  }
9
14
 
10
- export async function getTTSResponse(supabaseUrl: string, request: TTSRequest, token: string) {
11
- return await fetch(`${supabaseUrl}/functions/v1/speech`, {
12
- method: 'POST',
13
- headers: {
14
- 'Content-Type': 'application/json',
15
- 'Authorization': `Bearer ${token}`
16
- },
17
- body: JSON.stringify(request),
18
- }).then(r => r.blob());
15
+ export async function getTTSResponse(backendUrl: string, request: TTSRequest, token: string) {
16
+ return await fetch(`${backendUrl}/voice/tts`, {
17
+ method: 'POST',
18
+ headers: {
19
+ 'Content-Type': 'application/json',
20
+ 'Authorization': `Bearer ${token}`
21
+ },
22
+ body: JSON.stringify(request),
23
+ }).then(r => r.blob());
19
24
  }
20
25
 
21
26
  interface TTSRequest {
22
- input: string;
23
- voice: string;
24
- speed: number;
25
- language?: string;
27
+ input: string;
28
+ voice: string;
29
+ speed: number;
30
+ language?: string;
26
31
  }
package/src/core/core.ts CHANGED
@@ -12,4 +12,5 @@ export { SharedContent } from "./controller/SharedContentController";
12
12
  export { Message, OnLLMResponse, ToolInvocation } from "./controller/AIController";
13
13
  export { MacroAccomplishmentPayload, MicroAccomplishmentPayload } from "../plugin/AccomplishmentHandler";
14
14
  export { Tool } from "../fromRimori/PluginTypes";
15
+ export { SharedContentObjectRequest } from "./controller/SharedContentController";
15
16
 
@@ -39,7 +39,7 @@ export class EventBusHandler {
39
39
  private listeners: Map<string, Set<Listeners<EventPayload>>> = new Map();
40
40
  private responseResolvers: Map<number, (value: EventBusMessage<unknown>) => void> = new Map();
41
41
  private static instance: EventBusHandler | null = null;
42
- private debugEnabled: boolean = true;
42
+ private debugEnabled: boolean = false;
43
43
  private evName: string = "";
44
44
 
45
45
  private constructor() {
@@ -1,5 +1,5 @@
1
1
  // whole configuration of a plugin (from the database)
2
- export type Plugin = Omit<RimoriPluginConfig, 'context_menu_actions'> & {
2
+ export type Plugin<T extends {} = {}> = Omit<RimoriPluginConfig<T>, 'context_menu_actions'> & {
3
3
  version: string;
4
4
  endpoint: string;
5
5
  assetEndpoint: string;
@@ -7,6 +7,8 @@ export type Plugin = Omit<RimoriPluginConfig, 'context_menu_actions'> & {
7
7
  release_channel: "alpha" | "beta" | "stable";
8
8
  }
9
9
 
10
+ export type ActivePlugin = Plugin<{ active?: boolean }>
11
+
10
12
  // browsable page of a plugin
11
13
  export interface PluginPage {
12
14
  id: string;
@@ -70,7 +72,7 @@ export interface ContextMenuAction {
70
72
  * Rimori plugin structure representing the complete configuration
71
73
  * of a Rimori plugin with all metadata and configuration options.
72
74
  */
73
- export interface RimoriPluginConfig {
75
+ export interface RimoriPluginConfig<T extends {} = {}> {
74
76
  id: string;
75
77
  /**
76
78
  * Basic information about the plugin including branding and core details.
@@ -92,9 +94,9 @@ export interface RimoriPluginConfig {
92
94
  /** Optional external URL where the plugin is hosted instead of the default CDN */
93
95
  external_hosted_url?: string;
94
96
  /** Array of main plugin pages that appear in the application's main navigation (can be disabled using the 'show' flag) */
95
- main: PluginPage[];
97
+ main: (PluginPage & T)[];
96
98
  /** Array of sidebar pages that appear in the sidebar for quick access (can be disabled using the 'show' flag) */
97
- sidebar: SidebarPage[];
99
+ sidebar: (SidebarPage & T)[];
98
100
  /** Optional path to the plugin's settings/configuration page */
99
101
  settings?: string;
100
102
  /** Optional array of event topics the plugin pages can listen to for cross-plugin communication */
@@ -1,15 +1,17 @@
1
1
  import React from "react";
2
2
  import { Tool } from "../fromRimori/PluginTypes";
3
- import { usePlugin } from "../providers/PluginProvider";
3
+ import { useRimori } from "../providers/PluginProvider";
4
4
  import { Message, ToolInvocation } from "../core/controller/AIController";
5
5
 
6
6
  export function useChat(tools?: Tool[]) {
7
7
  const [messages, setMessages] = React.useState<Message[]>([]);
8
8
  const [isLoading, setIsLoading] = React.useState(false);
9
- const { ai } = usePlugin();
9
+ const { ai } = useRimori();
10
10
 
11
11
  const append = (appendMessages: Message[]) => {
12
- ai.getSteamedText([...messages, ...appendMessages], (id, message, finished: boolean, toolInvocations?: ToolInvocation[]) => {
12
+ const allMessages = [...messages, ...appendMessages];
13
+ setMessages(allMessages);
14
+ ai.getSteamedText(allMessages, (id, message, finished: boolean, toolInvocations?: ToolInvocation[]) => {
13
15
  const lastMessage = messages[messages.length - 1];
14
16
  setIsLoading(!finished);
15
17
 
@@ -17,7 +19,7 @@ export function useChat(tools?: Tool[]) {
17
19
  lastMessage.content = message;
18
20
  setMessages([...messages, lastMessage]);
19
21
  } else {
20
- setMessages([...messages, ...appendMessages, { id, role: 'assistant', content: message, toolCalls: toolInvocations }]);
22
+ setMessages([...allMessages, { id, role: 'assistant', content: message, toolCalls: toolInvocations }]);
21
23
  }
22
24
  }, tools);
23
25
  };
package/src/index.ts CHANGED
@@ -9,3 +9,4 @@ export * from "./utils/PluginUtils";
9
9
  export * from "./utils/Language";
10
10
  export * from "./fromRimori/PluginTypes";
11
11
  export { FirstMessages } from "./components/ai/utils";
12
+ export { AudioController } from "./plugin/AudioController";
@@ -0,0 +1,58 @@
1
+ import { EventBus } from "../fromRimori/EventBus";
2
+
3
+ /**
4
+ * AudioController is a class that provides methods to record audio. It is a wrapper around the Capacitor Voice Recorder plugin. For more information, see https://github.com/tchvu3/capacitor-voice-recorder.
5
+ *
6
+ * @example
7
+ * const audioController = new AudioController();
8
+ * await audioController.startRecording();
9
+ */
10
+ export class AudioController {
11
+ private pluginId: string;
12
+
13
+ constructor(pluginId: string) {
14
+ this.pluginId = pluginId;
15
+ }
16
+
17
+ /**
18
+ * Start the recording.
19
+ *
20
+ * @example
21
+ * const audioController = new AudioController();
22
+ * await audioController.startRecording();
23
+ * @returns void
24
+ */
25
+ public async startRecording(): Promise<void> {
26
+ EventBus.emit(this.pluginId, "global.microphone.triggerStartRecording");
27
+ }
28
+
29
+ /**
30
+ * Stop the recording and return the audio data.
31
+ * @returns The audio data.
32
+ *
33
+ * @example
34
+ * const audioRef = new Audio(`data:${mimeType};base64,${base64Sound}`)
35
+ * audioRef.oncanplaythrough = () => audioRef.play()
36
+ * audioRef.load()
37
+ */
38
+ public async stopRecording(): Promise<{ recording: Blob, msDuration: number, mimeType: string }> {
39
+ const result = await EventBus.request<{ recording: Blob, msDuration: number, mimeType: string }>(this.pluginId, "global.microphone.triggerStopRecording");
40
+
41
+ return result.data;
42
+ }
43
+
44
+ public async pauseRecording(): Promise<boolean> {
45
+ const result = await EventBus.request<boolean>(this.pluginId, "global.microphone.triggerPauseRecording");
46
+ return result.data;
47
+ }
48
+
49
+ public async resumeRecording(): Promise<boolean> {
50
+ const result = await EventBus.request<boolean>(this.pluginId, "global.microphone.triggerResumeRecording");
51
+ return result.data;
52
+ }
53
+
54
+ public async getCurrentStatus(): Promise<"RECORDING" | "PAUSED" | "NONE"> {
55
+ const result = await EventBus.request<"RECORDING" | "PAUSED" | "NONE">(this.pluginId, "global.microphone.triggerGetCurrentStatus");
56
+ return result.data;
57
+ }
58
+ }
@@ -0,0 +1,324 @@
1
+ import { RimoriClient } from './RimoriClient';
2
+ import html2canvas from 'html2canvas';
3
+
4
+ type LogLevel = 'debug' | 'info' | 'warn' | 'error';
5
+
6
+ interface LogEntry {
7
+ id: string;
8
+ timestamp: string;
9
+ level: LogLevel;
10
+ message: string;
11
+ data?: any;
12
+ context?: {
13
+ url: string;
14
+ userAgent: string;
15
+ browserInfo: BrowserInfo;
16
+ screenshot?: string;
17
+ mousePosition?: MousePosition;
18
+ };
19
+ }
20
+
21
+ interface BrowserInfo {
22
+ userAgent: string;
23
+ language: string;
24
+ cookieEnabled: boolean;
25
+ onLine: boolean;
26
+ screenResolution: string;
27
+ windowSize: string;
28
+ timestamp: string;
29
+ }
30
+
31
+ interface MousePosition {
32
+ x: number;
33
+ y: number;
34
+ timestamp: string;
35
+ }
36
+
37
+ /**
38
+ * Singleton Logger class for Rimori client plugins.
39
+ * Handles all logging levels, production filtering, and log transmission to Rimori.
40
+ * Overrides console methods globally for seamless integration.
41
+ */
42
+ export class Logger {
43
+ private static instance: Logger;
44
+ private isProduction: boolean;
45
+ private logs: LogEntry[] = [];
46
+ private logIdCounter = 0;
47
+ private originalConsole: {
48
+ log: typeof console.log;
49
+ info: typeof console.info;
50
+ warn: typeof console.warn;
51
+ error: typeof console.error;
52
+ debug: typeof console.debug;
53
+ };
54
+ private mousePosition: MousePosition | null = null;
55
+
56
+ private constructor(rimori: RimoriClient, isProduction?: boolean) {
57
+ this.isProduction = this.validateIsProduction(isProduction);
58
+
59
+ // Store original console methods
60
+ this.originalConsole = {
61
+ log: console.log,
62
+ info: console.info,
63
+ warn: console.warn,
64
+ error: console.error,
65
+ debug: console.debug
66
+ };
67
+
68
+ // Override console methods globally
69
+ this.overrideConsoleMethods();
70
+
71
+ // Track mouse position
72
+ this.trackMousePosition();
73
+
74
+ // Expose logs to global scope for DevTools access
75
+ this.exposeToDevTools();
76
+
77
+ // Set up navigation clearing
78
+ this.setupNavigationClearing();
79
+
80
+ rimori.event.respond('logging.requestPluginLogs', async () => {
81
+ this.addLogEntry(await this.createLogEntry('info', 'Screenshot capture', undefined, true));
82
+ const logs = {
83
+ logs: this.logs,
84
+ pluginId: rimori.plugin.pluginId,
85
+ timestamp: new Date().toISOString()
86
+ }
87
+ this.logs = [];
88
+ this.logIdCounter = 0;
89
+ return logs;
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Initialize the Logger singleton and override console methods globally.
95
+ * @param rimori - Rimori client instance
96
+ * @param isProduction - Whether the environment is production
97
+ * @returns Logger instance
98
+ */
99
+ public static getInstance(rimori: RimoriClient, isProduction?: boolean): Logger {
100
+ if (!Logger.instance) {
101
+ Logger.instance = new Logger(rimori, isProduction);
102
+ }
103
+ return Logger.instance;
104
+ }
105
+
106
+ private validateIsProduction(isProduction?: boolean): boolean {
107
+ if (isProduction !== undefined) {
108
+ return isProduction;
109
+ }
110
+ if (typeof window !== 'undefined' && window.location.href) {
111
+ return !window.location.href.includes('localhost');
112
+ }
113
+ return true;
114
+ }
115
+ /**
116
+ * Expose log access to global scope for DevTools console access.
117
+ */
118
+ private exposeToDevTools(): void {
119
+ if (typeof window !== 'undefined') {
120
+ // Expose a global function to access logs from DevTools console
121
+ (window as any).getRimoriLogs = () => this.logs;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Set up navigation event listeners to clear logs on page changes.
127
+ */
128
+ private setupNavigationClearing(): void {
129
+ if (typeof window === 'undefined' || typeof history === 'undefined') return;
130
+
131
+ // Clear logs on browser back/forward
132
+ window.addEventListener('popstate', () => this.logs = []);
133
+
134
+ // Override history methods to clear logs on programmatic navigation
135
+ const originalPushState = history.pushState;
136
+ const originalReplaceState = history.replaceState;
137
+
138
+ history.pushState = (...args) => {
139
+ originalPushState.apply(history, args);
140
+ this.logs = [];
141
+ };
142
+
143
+ history.replaceState = (...args) => {
144
+ originalReplaceState.apply(history, args);
145
+ this.logs = [];
146
+ };
147
+
148
+ // Listen for URL changes (works with React Router and other SPAs)
149
+ let currentUrl = window.location.href;
150
+ const checkUrlChange = () => {
151
+ if (window.location.href !== currentUrl) {
152
+ currentUrl = window.location.href;
153
+ this.logs = [];
154
+ }
155
+ };
156
+
157
+ // Check for URL changes periodically
158
+ setInterval(checkUrlChange, 100);
159
+
160
+ // Also listen for hash changes (for hash-based routing)
161
+ window.addEventListener('hashchange', () => this.logs = []);
162
+ }
163
+
164
+ /**
165
+ * Override console methods globally to capture all console calls.
166
+ */
167
+ private overrideConsoleMethods(): void {
168
+ // Override console.log
169
+ console.log = (...args: any[]) => {
170
+ this.originalConsole.log(...args);
171
+ this.handleConsoleCall('info', args);
172
+ };
173
+
174
+ // Override console.info
175
+ console.info = (...args: any[]) => {
176
+ this.originalConsole.info(...args);
177
+ this.handleConsoleCall('info', args);
178
+ };
179
+
180
+ // Override console.warn
181
+ console.warn = (...args: any[]) => {
182
+ this.originalConsole.warn(...args);
183
+ this.handleConsoleCall('warn', args);
184
+ };
185
+
186
+ // Override console.error
187
+ console.error = (...args: any[]) => {
188
+ this.originalConsole.error(...args);
189
+ this.handleConsoleCall('error', args);
190
+ };
191
+
192
+ // Override console.debug
193
+ console.debug = (...args: any[]) => {
194
+ this.originalConsole.debug(...args);
195
+ this.handleConsoleCall('debug', args);
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Track mouse position for screenshot context.
201
+ */
202
+ private trackMousePosition(): void {
203
+ if (typeof window !== 'undefined') {
204
+ const updateMousePosition = (event: MouseEvent) => {
205
+ this.mousePosition = {
206
+ x: event.clientX,
207
+ y: event.clientY,
208
+ timestamp: new Date().toISOString()
209
+ };
210
+ };
211
+
212
+ window.addEventListener('mousemove', updateMousePosition);
213
+ window.addEventListener('click', updateMousePosition);
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Handle console method calls and create log entries.
219
+ * @param level - Log level
220
+ * @param args - Console arguments
221
+ */
222
+ private async handleConsoleCall(level: LogLevel, args: any[]): Promise<void> {
223
+ // Skip if this is a production log that shouldn't be stored
224
+ if (this.isProduction && (level === 'debug' || level === 'info')) {
225
+ return;
226
+ }
227
+
228
+ // Convert console arguments to message and data
229
+ const message = args.map(arg =>
230
+ typeof arg === 'object' ? JSON.stringify(arg) : String(arg)
231
+ ).join(' ');
232
+
233
+ const data = args.length > 1 ? args.slice(1) : undefined;
234
+
235
+ const entry = await this.createLogEntry(level, message, data);
236
+ this.addLogEntry(entry);
237
+ }
238
+
239
+ /**
240
+ * Get browser and system information for debugging.
241
+ * @returns Object with browser and system information
242
+ */
243
+ private getBrowserInfo(): BrowserInfo {
244
+ return {
245
+ userAgent: navigator.userAgent,
246
+ language: navigator.language,
247
+ cookieEnabled: navigator.cookieEnabled,
248
+ onLine: navigator.onLine,
249
+ screenResolution: `${screen.width}x${screen.height}`,
250
+ windowSize: `${window.innerWidth}x${window.innerHeight}`,
251
+ timestamp: new Date().toISOString()
252
+ };
253
+ }
254
+
255
+ /**
256
+ * Capture a screenshot of the current page.
257
+ * @returns Promise resolving to base64 screenshot or null if failed
258
+ */
259
+ private async captureScreenshot(): Promise<string | null> {
260
+ if (typeof window !== 'undefined' && typeof document !== 'undefined') {
261
+ const canvas = await html2canvas(document.body);
262
+ const screenshot = canvas.toDataURL('image/png');
263
+ // this.originalConsole.log("screenshot captured", screenshot)
264
+ return screenshot;
265
+ }
266
+ return null;
267
+ }
268
+
269
+ /**
270
+ * Create a log entry with context information.
271
+ * @param level - Log level
272
+ * @param message - Log message
273
+ * @param data - Additional data
274
+ * @returns Log entry
275
+ */
276
+ private async createLogEntry(level: LogLevel, message: string, data?: any, forceScreenshot?: boolean): Promise<LogEntry> {
277
+ const context: Partial<LogEntry['context']> = {};
278
+
279
+ // Add URL if available
280
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
281
+ return {
282
+ id: `log_${++this.logIdCounter}_${Date.now()}`,
283
+ timestamp: new Date().toISOString(),
284
+ level,
285
+ message,
286
+ data,
287
+ }
288
+ }
289
+
290
+ context.url = window.location.href;
291
+
292
+ // Add browser info (this method now handles worker context internally)
293
+ context.browserInfo = this.getBrowserInfo();
294
+ context.userAgent = context.browserInfo.userAgent;
295
+
296
+ // Add screenshot and mouse position if level is error or warn
297
+ if (level === 'error' || level === 'warn' || forceScreenshot) {
298
+ context.screenshot = await this.captureScreenshot() || undefined;
299
+ context.mousePosition = this.mousePosition || undefined;
300
+ }
301
+
302
+ return {
303
+ id: `log_${++this.logIdCounter}_${Date.now()}`,
304
+ timestamp: new Date().toISOString(),
305
+ level,
306
+ message,
307
+ data,
308
+ context: context as LogEntry['context']
309
+ };
310
+ }
311
+
312
+ /**
313
+ * Add a log entry to the internal log array.
314
+ * @param entry - Log entry to add
315
+ */
316
+ private addLogEntry(entry: LogEntry): void {
317
+ this.logs.push(entry);
318
+
319
+ // Maintain log size limit (1000 entries)
320
+ if (this.logs.length > 1000) {
321
+ this.logs = this.logs.slice(-1000);
322
+ }
323
+ }
324
+ }