@rimori/client 2.0.0 → 2.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.
- package/README.md +140 -1122
- package/dist/{rimori-client/src/cli → cli}/scripts/init/main.js +0 -0
- package/dist/{rimori-client/src/cli → cli}/scripts/release/release.js +0 -0
- package/dist/{rimori-client/src/index.d.ts → index.d.ts} +7 -1
- package/dist/{rimori-client/src/index.js → index.js} +1 -0
- package/dist/{rimori-client/src/plugin → plugin}/CommunicationHandler.d.ts +1 -0
- package/dist/{rimori-client/src/plugin → plugin}/CommunicationHandler.js +19 -2
- package/dist/{rimori-client/src/plugin → plugin}/RimoriClient.d.ts +1 -1
- package/dist/{rimori-client/src/worker → worker}/WorkerSetup.js +1 -1
- package/package.json +2 -1
- package/src/index.ts +7 -1
- package/src/plugin/CommunicationHandler.ts +22 -6
- package/src/plugin/RimoriClient.ts +5 -3
- package/src/worker/WorkerSetup.ts +1 -1
- package/tsconfig.json +1 -0
- package/dist/react-client/plugin/ThemeSetter.d.ts +0 -2
- package/dist/react-client/plugin/ThemeSetter.js +0 -19
- package/dist/react-client/src/plugin/ThemeSetter.d.ts +0 -2
- package/dist/react-client/src/plugin/ThemeSetter.js +0 -19
- package/dist/react-client/src/utils/FullscreenUtils.d.ts +0 -2
- package/dist/react-client/src/utils/FullscreenUtils.js +0 -23
- package/dist/react-client/src/utils/PluginUtils.d.ts +0 -2
- package/dist/react-client/src/utils/PluginUtils.js +0 -23
- /package/dist/{rimori-client/src/cli → cli}/scripts/init/dev-registration.d.ts +0 -0
- /package/dist/{rimori-client/src/cli → cli}/scripts/init/dev-registration.js +0 -0
- /package/dist/{rimori-client/src/cli → cli}/scripts/init/env-setup.d.ts +0 -0
- /package/dist/{rimori-client/src/cli → cli}/scripts/init/env-setup.js +0 -0
- /package/dist/{rimori-client/src/cli → cli}/scripts/init/file-operations.d.ts +0 -0
- /package/dist/{rimori-client/src/cli → cli}/scripts/init/file-operations.js +0 -0
- /package/dist/{rimori-client/src/cli → cli}/scripts/init/html-cleaner.d.ts +0 -0
- /package/dist/{rimori-client/src/cli → cli}/scripts/init/html-cleaner.js +0 -0
- /package/dist/{rimori-client/src/cli → cli}/scripts/init/main.d.ts +0 -0
- /package/dist/{rimori-client/src/cli → cli}/scripts/init/package-setup.d.ts +0 -0
- /package/dist/{rimori-client/src/cli → cli}/scripts/init/package-setup.js +0 -0
- /package/dist/{rimori-client/src/cli → cli}/scripts/init/router-transformer.d.ts +0 -0
- /package/dist/{rimori-client/src/cli → cli}/scripts/init/router-transformer.js +0 -0
- /package/dist/{rimori-client/src/cli → cli}/scripts/init/tailwind-config.d.ts +0 -0
- /package/dist/{rimori-client/src/cli → cli}/scripts/init/tailwind-config.js +0 -0
- /package/dist/{rimori-client/src/cli → cli}/scripts/init/vite-config.d.ts +0 -0
- /package/dist/{rimori-client/src/cli → cli}/scripts/init/vite-config.js +0 -0
- /package/dist/{rimori-client/src/cli → cli}/scripts/release/detect-translation-languages.d.ts +0 -0
- /package/dist/{rimori-client/src/cli → cli}/scripts/release/detect-translation-languages.js +0 -0
- /package/dist/{rimori-client/src/cli → cli}/scripts/release/release-config-upload.d.ts +0 -0
- /package/dist/{rimori-client/src/cli → cli}/scripts/release/release-config-upload.js +0 -0
- /package/dist/{rimori-client/src/cli → cli}/scripts/release/release-db-update.d.ts +0 -0
- /package/dist/{rimori-client/src/cli → cli}/scripts/release/release-db-update.js +0 -0
- /package/dist/{rimori-client/src/cli → cli}/scripts/release/release-file-upload.d.ts +0 -0
- /package/dist/{rimori-client/src/cli → cli}/scripts/release/release-file-upload.js +0 -0
- /package/dist/{rimori-client/src/cli → cli}/scripts/release/release.d.ts +0 -0
- /package/dist/{rimori-client/src/cli → cli}/types/DatabaseTypes.d.ts +0 -0
- /package/dist/{rimori-client/src/cli → cli}/types/DatabaseTypes.js +0 -0
- /package/dist/{rimori-client/src/controller → controller}/AIController.d.ts +0 -0
- /package/dist/{rimori-client/src/controller → controller}/AIController.js +0 -0
- /package/dist/{rimori-client/src/controller → controller}/AccomplishmentController.d.ts +0 -0
- /package/dist/{rimori-client/src/controller → controller}/AccomplishmentController.js +0 -0
- /package/dist/{rimori-client/src/controller → controller}/AudioController.d.ts +0 -0
- /package/dist/{rimori-client/src/controller → controller}/AudioController.js +0 -0
- /package/dist/{rimori-client/src/controller → controller}/ExerciseController.d.ts +0 -0
- /package/dist/{rimori-client/src/controller → controller}/ExerciseController.js +0 -0
- /package/dist/{rimori-client/src/controller → controller}/ObjectController.d.ts +0 -0
- /package/dist/{rimori-client/src/controller → controller}/ObjectController.js +0 -0
- /package/dist/{rimori-client/src/controller → controller}/SettingsController.d.ts +0 -0
- /package/dist/{rimori-client/src/controller → controller}/SettingsController.js +0 -0
- /package/dist/{rimori-client/src/controller → controller}/SharedContentController.d.ts +0 -0
- /package/dist/{rimori-client/src/controller → controller}/SharedContentController.js +0 -0
- /package/dist/{rimori-client/src/controller → controller}/TranslationController.d.ts +0 -0
- /package/dist/{rimori-client/src/controller → controller}/TranslationController.js +0 -0
- /package/dist/{rimori-client/src/controller → controller}/VoiceController.d.ts +0 -0
- /package/dist/{rimori-client/src/controller → controller}/VoiceController.js +0 -0
- /package/dist/{rimori-client/src/fromRimori → fromRimori}/EventBus.d.ts +0 -0
- /package/dist/{rimori-client/src/fromRimori → fromRimori}/EventBus.js +0 -0
- /package/dist/{rimori-client/src/fromRimori → fromRimori}/PluginTypes.d.ts +0 -0
- /package/dist/{rimori-client/src/fromRimori → fromRimori}/PluginTypes.js +0 -0
- /package/dist/{rimori-client/src/plugin → plugin}/Logger.d.ts +0 -0
- /package/dist/{rimori-client/src/plugin → plugin}/Logger.js +0 -0
- /package/dist/{rimori-client/src/plugin → plugin}/RimoriClient.js +0 -0
- /package/dist/{rimori-client/src/plugin → plugin}/StandaloneClient.d.ts +0 -0
- /package/dist/{rimori-client/src/plugin → plugin}/StandaloneClient.js +0 -0
- /package/dist/{rimori-client/src/utils → utils}/difficultyConverter.d.ts +0 -0
- /package/dist/{rimori-client/src/utils → utils}/difficultyConverter.js +0 -0
- /package/dist/{rimori-client/src/utils → utils}/endpoint.d.ts +0 -0
- /package/dist/{rimori-client/src/utils → utils}/endpoint.js +0 -0
- /package/dist/{rimori-client/src/worker → worker}/WorkerSetup.d.ts +0 -0
package/README.md
CHANGED
|
@@ -1,23 +1,35 @@
|
|
|
1
1
|
# Rimori Client Package
|
|
2
2
|
|
|
3
|
-
The
|
|
3
|
+
The `@rimori/client` package is the framework-agnostic runtime and CLI that powers Rimori plugins. Use it inside plugin iframes, workers, and build scripts to access Rimori platform features such as database access, AI, shared content, and the event bus. All React-specific helpers and UI components are now published separately in `@rimori/react-client`.
|
|
4
4
|
|
|
5
5
|
## Table of Contents
|
|
6
|
-
|
|
6
|
+
- [Overview](#overview)
|
|
7
7
|
- [Installation](#installation)
|
|
8
|
-
- [
|
|
9
|
-
- [Releasing Your Plugin to Rimori](#releasing-your-plugin-to-rimori)
|
|
8
|
+
- [Relationship to @rimori/react-client](#relationship-to-rimori-react-client)
|
|
10
9
|
- [Quick Start](#quick-start)
|
|
11
|
-
- [
|
|
12
|
-
- [
|
|
13
|
-
- [
|
|
14
|
-
- [
|
|
15
|
-
- [
|
|
16
|
-
- [
|
|
17
|
-
- [
|
|
10
|
+
- [CLI Tooling](#cli-tooling)
|
|
11
|
+
- [Runtime API](#runtime-api)
|
|
12
|
+
- [Bootstrapping](#bootstrapping)
|
|
13
|
+
- [Plugin Interface](#plugin-interface)
|
|
14
|
+
- [Database Access](#database-access)
|
|
15
|
+
- [AI & Voice](#ai--voice)
|
|
16
|
+
- [Event Bus & Actions](#event-bus--actions)
|
|
17
|
+
- [Community Content](#community-content)
|
|
18
|
+
- [Workers & Standalone Development](#workers--standalone-development)
|
|
18
19
|
- [Utilities](#utilities)
|
|
19
20
|
- [TypeScript Support](#typescript-support)
|
|
20
|
-
- [
|
|
21
|
+
- [Example Integration](#example-integration)
|
|
22
|
+
- [Troubleshooting](#troubleshooting)
|
|
23
|
+
|
|
24
|
+
## Overview
|
|
25
|
+
|
|
26
|
+
`@rimori/client` gives you direct, typed access to the Rimori platform:
|
|
27
|
+
- Bootstrap authenticated plugin sessions and fetch Rimori context.
|
|
28
|
+
- Run Supabase queries against your plugin's dedicated schema.
|
|
29
|
+
- Call AI services for text, structured data, or voice.
|
|
30
|
+
- Communicate with Rimori and other plugins through the event bus.
|
|
31
|
+
- Share content with the community and emit accomplishments.
|
|
32
|
+
- Ship and upgrade plugins by using the bundled CLI.
|
|
21
33
|
|
|
22
34
|
## Installation
|
|
23
35
|
|
|
@@ -27,1190 +39,196 @@ npm install @rimori/client
|
|
|
27
39
|
yarn add @rimori/client
|
|
28
40
|
```
|
|
29
41
|
|
|
30
|
-
##
|
|
31
|
-
|
|
32
|
-
The Rimori Client package includes powerful CLI tools to eliminate the tedious setup process and get you building your plugin fast. The initialization script handles authentication, plugin registration, environment setup, and all necessary boilerplate configuration.
|
|
33
|
-
|
|
34
|
-
### Prerequisites
|
|
35
|
-
|
|
36
|
-
Before initializing your plugin, ensure you have:
|
|
37
|
-
|
|
38
|
-
1. **Node.js and yarn/npm installed**
|
|
39
|
-
2. **A Rimori account** - You'll need to login during initialization to receive your access token
|
|
42
|
+
## Relationship to @rimori/react-client
|
|
40
43
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
Open Lovable and vibe code the look of your plugin.
|
|
44
|
-
|
|
45
|
-
Then connect it to your Github account.
|
|
46
|
-
|
|
47
|
-
Clone the git repository:
|
|
44
|
+
If you are building a React-based plugin UI, install the companion package:
|
|
48
45
|
|
|
49
46
|
```bash
|
|
50
|
-
|
|
51
|
-
cd my-awesome-plugin
|
|
52
|
-
|
|
53
|
-
# Initialize with Rimori Client (this handles everything!)
|
|
54
|
-
npx @rimori/client rimori-init
|
|
47
|
+
npm install @rimori/react-client
|
|
55
48
|
```
|
|
56
49
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
The `rimori-init` command automates the entire plugin setup process:
|
|
60
|
-
|
|
61
|
-
1. **🔐 Authentication**: Prompts for your Rimori credentials and authenticates with the platform
|
|
62
|
-
2. **🚀 Plugin Registration**: Automatically registers your plugin and generates a unique plugin ID
|
|
63
|
-
3. **🔑 Access Token**: Provides you with an access token for future plugin releases
|
|
64
|
-
4. **📦 Package Configuration**: Updates `package.json` with plugin-specific settings
|
|
65
|
-
5. **⚙️ Environment Setup**: Creates `.env` files with your credentials
|
|
66
|
-
6. **📁 File Structure**: Copies all necessary boilerplate files and examples
|
|
67
|
-
7. **🎨 Configuration**: Sets up Vite, Tailwind, and router configurations
|
|
68
|
-
8. **📖 Documentation**: Provides example documentation and getting started guides
|
|
50
|
+
`@rimori/react-client` wraps this core runtime with React context, hooks, and prebuilt UI components. Use it for UI concerns (`PluginProvider`, `useRimori`, `useChat`, widgets). Keep importing non-UI functionality such as `RimoriClient`, `StandaloneClient`, `setupWorker`, or the CLI directly from `@rimori/client`.
|
|
69
51
|
|
|
70
|
-
|
|
52
|
+
## Quick Start
|
|
71
53
|
|
|
72
|
-
|
|
54
|
+
Instantiate the client once in your application entry point and reuse it everywhere:
|
|
73
55
|
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
```
|
|
56
|
+
```ts
|
|
57
|
+
import { RimoriClient } from "@rimori/client";
|
|
77
58
|
|
|
78
|
-
|
|
59
|
+
async function bootstrap() {
|
|
60
|
+
const client = await RimoriClient.getInstance("your-plugin-id");
|
|
79
61
|
|
|
80
|
-
|
|
62
|
+
const user = client.plugin.getUserInfo();
|
|
63
|
+
const { data } = await client.db
|
|
64
|
+
.from("notes")
|
|
65
|
+
.select("*")
|
|
66
|
+
.eq("user_id", user.profile_id);
|
|
81
67
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
yarn dev
|
|
68
|
+
console.log("Loaded notes", data);
|
|
69
|
+
}
|
|
85
70
|
|
|
86
|
-
|
|
87
|
-
# http://localhost:3000 (or your chosen port)
|
|
71
|
+
bootstrap().catch(console.error);
|
|
88
72
|
```
|
|
89
73
|
|
|
90
|
-
|
|
74
|
+
The instance exposes high-level controllers grouped under properties such as `plugin`, `db`, `ai`, `event`, `community`, `runtime`, and `navigation`.
|
|
91
75
|
|
|
92
|
-
|
|
93
|
-
- ✅ **TypeScript support** with full type safety
|
|
94
|
-
- ✅ **TailwindCSS** for modern styling
|
|
95
|
-
- ✅ **React Router** for navigation
|
|
96
|
-
- ✅ **Example components** and documentation
|
|
76
|
+
## CLI Tooling
|
|
97
77
|
|
|
98
|
-
|
|
78
|
+
Two CLI commands ship with the package (also available through `npx`):
|
|
99
79
|
|
|
100
|
-
|
|
80
|
+
### `rimori-init`
|
|
101
81
|
|
|
102
|
-
|
|
82
|
+
- Authenticates against Rimori using your developer credentials.
|
|
83
|
+
- Registers the plugin and writes the plugin ID (`r_id`) into `package.json`.
|
|
84
|
+
- Provisions environment files, Vite/Tailwind scaffolding, worker configuration, and sample assets.
|
|
103
85
|
|
|
104
|
-
|
|
105
|
-
2. **Build your plugin** - Ensure your plugin is built and the output is in the `dist/` directory
|
|
106
|
-
3. **Environment configured** - Your `.env` file should contain `RIMORI_TOKEN` (set during initialization)
|
|
107
|
-
|
|
108
|
-
### Quick Release (Recommended)
|
|
109
|
-
|
|
110
|
-
During plugin initialization, convenient release scripts are automatically added to your `package.json`. These scripts handle building and releasing in one command:
|
|
86
|
+
Usage:
|
|
111
87
|
|
|
112
88
|
```bash
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
yarn release:beta # Build and release to beta channel
|
|
116
|
-
yarn release:stable # Build and release to stable channel
|
|
89
|
+
npx @rimori/client rimori-init
|
|
90
|
+
npx @rimori/client rimori-init --upgrade # refresh config without changing the plugin ID
|
|
117
91
|
```
|
|
118
92
|
|
|
119
|
-
###
|
|
93
|
+
### `rimori-release`
|
|
120
94
|
|
|
121
|
-
|
|
95
|
+
- Builds (optionally) and uploads the plugin bundle to Rimori.
|
|
96
|
+
- Updates release metadata, database migrations, and activates the chosen channel (`alpha`, `beta`, `stable`).
|
|
97
|
+
|
|
98
|
+
Usage:
|
|
122
99
|
|
|
123
100
|
```bash
|
|
124
|
-
# Build your plugin first
|
|
125
101
|
yarn build
|
|
126
|
-
|
|
127
|
-
# Then release to different channels
|
|
128
|
-
yarn rimori-release alpha # For alpha testing
|
|
129
|
-
yarn rimori-release beta # For beta releases
|
|
130
|
-
yarn rimori-release stable # For production releases
|
|
102
|
+
npx @rimori/client rimori-release alpha
|
|
131
103
|
```
|
|
132
104
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
### What the Release Script Does
|
|
136
|
-
|
|
137
|
-
The `rimori-release` command performs a complete release workflow:
|
|
138
|
-
|
|
139
|
-
1. **📋 Configuration Upload**: Sends plugin metadata and configuration to the platform
|
|
140
|
-
2. **🗄️ Database Updates**: Updates plugin information and release records
|
|
141
|
-
3. **📁 File Upload**: Uploads all files from your `dist/` directory to the platform
|
|
142
|
-
4. **🚀 Plugin Activation**: Activates the new version on the specified release channel
|
|
143
|
-
|
|
144
|
-
### Release Channels
|
|
145
|
-
|
|
146
|
-
- **`alpha`**: Early development releases for internal testing
|
|
147
|
-
- **`beta`**: Pre-release versions for beta testers
|
|
148
|
-
- **`stable`**: Production-ready releases for all users
|
|
105
|
+
During initialization, convenience scripts (`release:alpha`, `release:beta`, `release:stable`) are added to your project automatically.
|
|
149
106
|
|
|
150
|
-
|
|
107
|
+
## Runtime API
|
|
151
108
|
|
|
152
|
-
|
|
109
|
+
### Bootstrapping
|
|
153
110
|
|
|
154
|
-
- `
|
|
155
|
-
- `
|
|
156
|
-
-
|
|
157
|
-
- **Build configuration**: TypeScript checking, Vite setup, and worker builds
|
|
158
|
-
|
|
159
|
-
### Troubleshooting
|
|
160
|
-
|
|
161
|
-
If you encounter release issues:
|
|
162
|
-
|
|
163
|
-
1. **Missing token**: Ensure `RIMORI_TOKEN` is in your `.env` file
|
|
164
|
-
2. **No plugin ID**: Verify `r_id` exists in your `package.json`
|
|
165
|
-
3. **Build errors**: Run `yarn build` successfully before releasing
|
|
166
|
-
4. **Authentication**: Your token may have expired - re-run `rimori-init` if needed
|
|
167
|
-
|
|
168
|
-
#### Worker Process Errors
|
|
169
|
-
|
|
170
|
-
If you encounter errors like:
|
|
171
|
-
|
|
172
|
-
```
|
|
173
|
-
ReferenceError: process is not defined
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
**Root Cause**: Your plugin is importing a library that tries to access `process` or `process.env` in the worker context. Web workers don't have access to Node.js globals like `process`.
|
|
177
|
-
|
|
178
|
-
**Debugging Steps**:
|
|
179
|
-
|
|
180
|
-
1. **Check your imports**: Look through the files used in the worker which might import libraries that might access `process.env`:
|
|
181
|
-
- React libraries (React Router, React Query, etc.)
|
|
182
|
-
- UI component libraries (Material-UI, Ant Design, etc.)
|
|
183
|
-
- Utility libraries that check for environment variables
|
|
184
|
-
|
|
185
|
-
2. **Verify Rimori Client imports**: Ensure you're importing from `@rimori/client/core` and not from `@rimori/client`:
|
|
186
|
-
|
|
187
|
-
```typescript
|
|
188
|
-
// ✅ Correct - import from rimori client
|
|
189
|
-
import { RimoriClient } from '@rimori/client/core';
|
|
190
|
-
|
|
191
|
-
// ❌ Incorrect - direct imports that might access process.env directly or through their dependencies
|
|
192
|
-
import { Avatar } from '@rimori/client';
|
|
193
|
-
import { useQuery } from '@tanstack/react-query';
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
3. **Check your dependencies**: Look at your `package.json` for libraries that might be bundled into the worker:
|
|
197
|
-
- UI frameworks (React, Vue, etc.)
|
|
198
|
-
- State management libraries
|
|
199
|
-
- Routing libraries
|
|
200
|
-
- Any library that checks `process.env.NODE_ENV`
|
|
201
|
-
|
|
202
|
-
**Solution**: Use only `@rimori/client/core` exports in your worker code. The Rimori client provides all necessary functionality without requiring Node.js globals.
|
|
203
|
-
|
|
204
|
-
**Note**: The worker bundling order is determined by the build system and cannot be controlled. Libraries that access `process.env` will always cause this error in the worker context.
|
|
205
|
-
|
|
206
|
-
````
|
|
207
|
-
|
|
208
|
-
## Core API - usePlugin Hook
|
|
209
|
-
|
|
210
|
-
The `useRimori()` hook is the main interface for accessing Rimori platform features:
|
|
211
|
-
|
|
212
|
-
```typescript
|
|
213
|
-
import { useRimori } from "@rimori/client";
|
|
214
|
-
|
|
215
|
-
const MyComponent = () => {
|
|
216
|
-
const client = useRimori();
|
|
217
|
-
|
|
218
|
-
// Access all client features
|
|
219
|
-
const { db, llm, event, community, plugin } = client;
|
|
220
|
-
|
|
221
|
-
return <div>My Plugin Content</div>;
|
|
222
|
-
};
|
|
223
|
-
````
|
|
111
|
+
- `RimoriClient.getInstance(pluginId)` – connect the sandboxed iframe to Rimori.
|
|
112
|
+
- `StandaloneClient` – authenticate when developing a plugin outside Rimori.
|
|
113
|
+
- `setupWorker()` – register worker scripts that need the Rimori runtime.
|
|
224
114
|
|
|
225
115
|
### Plugin Interface
|
|
226
116
|
|
|
227
|
-
|
|
228
|
-
const { plugin } = useRimori();
|
|
117
|
+
Access metadata and settings through `client.plugin`:
|
|
229
118
|
|
|
230
|
-
|
|
231
|
-
plugin.
|
|
232
|
-
plugin.
|
|
233
|
-
plugin.
|
|
234
|
-
plugin.
|
|
235
|
-
plugin.getUserInfo(): Promise<UserInfo> // Get current user information
|
|
236
|
-
```
|
|
119
|
+
- `plugin.pluginId` – current plugin identifier.
|
|
120
|
+
- `plugin.getSettings(defaults)` / `plugin.setSettings(settings)` – persist configuration.
|
|
121
|
+
- `plugin.getPluginInfo()` – read active/installed plugin information.
|
|
122
|
+
- `plugin.getUserInfo()` – obtain user profile details (language, name, guild, etc.).
|
|
123
|
+
- `plugin.getTranslator()` – lazily initialize the translator for manual i18n.
|
|
237
124
|
|
|
238
|
-
|
|
125
|
+
### Database Access
|
|
239
126
|
|
|
240
|
-
|
|
241
|
-
interface FlashcardSettings {
|
|
242
|
-
dailyGoal: number;
|
|
243
|
-
reviewInterval: 'easy' | 'medium' | 'hard';
|
|
244
|
-
showAnswerDelay: number;
|
|
245
|
-
enableAudioPronunciation: boolean;
|
|
246
|
-
difficultyAlgorithm: 'spaced-repetition' | 'random' | 'progressive';
|
|
247
|
-
}
|
|
127
|
+
`client.db` wraps the Supabase client that is scoped to your plugin tables:
|
|
248
128
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const loadSettings = async () => {
|
|
255
|
-
const defaultSettings: FlashcardSettings = {
|
|
256
|
-
dailyGoal: 20,
|
|
257
|
-
reviewInterval: 'medium',
|
|
258
|
-
showAnswerDelay: 3,
|
|
259
|
-
enableAudioPronunciation: true,
|
|
260
|
-
difficultyAlgorithm: 'spaced-repetition'
|
|
261
|
-
};
|
|
262
|
-
|
|
263
|
-
const currentSettings = await plugin.getSettings(defaultSettings);
|
|
264
|
-
setSettings(currentSettings);
|
|
265
|
-
};
|
|
266
|
-
|
|
267
|
-
loadSettings();
|
|
268
|
-
}, []);
|
|
269
|
-
|
|
270
|
-
const updateSettings = async (newSettings: Partial<FlashcardSettings>) => {
|
|
271
|
-
const updated = { ...settings, ...newSettings };
|
|
272
|
-
await plugin.setSettings(updated);
|
|
273
|
-
setSettings(updated);
|
|
274
|
-
};
|
|
275
|
-
|
|
276
|
-
return (
|
|
277
|
-
<div className="flashcard-settings">
|
|
278
|
-
<label>
|
|
279
|
-
Daily Goal: {settings?.dailyGoal} cards
|
|
280
|
-
<input
|
|
281
|
-
type="range"
|
|
282
|
-
min="5"
|
|
283
|
-
max="100"
|
|
284
|
-
value={settings?.dailyGoal}
|
|
285
|
-
onChange={(e) => updateSettings({ dailyGoal: parseInt(e.target.value) })}
|
|
286
|
-
/>
|
|
287
|
-
</label>
|
|
288
|
-
|
|
289
|
-
<label>
|
|
290
|
-
Review Interval:
|
|
291
|
-
<select
|
|
292
|
-
value={settings?.reviewInterval}
|
|
293
|
-
onChange={(e) => updateSettings({ reviewInterval: e.target.value as any })}
|
|
294
|
-
>
|
|
295
|
-
<option value="easy">Easy (longer intervals)</option>
|
|
296
|
-
<option value="medium">Medium</option>
|
|
297
|
-
<option value="hard">Hard (shorter intervals)</option>
|
|
298
|
-
</select>
|
|
299
|
-
</label>
|
|
300
|
-
|
|
301
|
-
<label>
|
|
302
|
-
<input
|
|
303
|
-
type="checkbox"
|
|
304
|
-
checked={settings?.enableAudioPronunciation}
|
|
305
|
-
onChange={(e) => updateSettings({ enableAudioPronunciation: e.target.checked })}
|
|
306
|
-
/>
|
|
307
|
-
Enable Audio Pronunciation
|
|
308
|
-
</label>
|
|
309
|
-
</div>
|
|
310
|
-
);
|
|
311
|
-
};
|
|
312
|
-
```
|
|
313
|
-
|
|
314
|
-
## Database Integration
|
|
315
|
-
|
|
316
|
-
Access your plugin's dedicated database tables with full TypeScript support:
|
|
317
|
-
|
|
318
|
-
```typescript
|
|
319
|
-
const { db } = useRimori();
|
|
320
|
-
|
|
321
|
-
// Database interface
|
|
322
|
-
db.from(tableName) // Query builder for tables/views - supports ALL Supabase operations
|
|
323
|
-
db.storage // File storage access
|
|
324
|
-
db.tablePrefix: string // Your plugin's table prefix
|
|
325
|
-
db.getTableName(table: string): string // Get full table name with prefix
|
|
326
|
-
```
|
|
327
|
-
|
|
328
|
-
The `db.from()` method provides access to the complete Supabase PostgREST API, supporting all database operations including:
|
|
329
|
-
|
|
330
|
-
- **CRUD Operations**: `insert()`, `select()`, `update()`, `delete()`, `upsert()`
|
|
331
|
-
- **Filtering**: `eq()`, `neq()`, `gt()`, `gte()`, `lt()`, `lte()`, `like()`, `ilike()`, `is()`, `in()`, `contains()`, `containedBy()`, `rangeLt()`, `rangeGt()`, `rangeGte()`, `rangeLte()`, `rangeAdjacent()`, `overlaps()`, `textSearch()`, `match()`, `not()`, `or()`, `filter()`
|
|
332
|
-
- **Modifiers**: `order()`, `limit()`, `range()`, `single()`, `maybe_single()`, `csv()`, `geojson()`, `explain()`
|
|
333
|
-
- **Aggregations**: `count()`, `sum()`, `avg()`, `min()`, `max()`
|
|
334
|
-
- **Advanced Features**: Row Level Security (RLS), real-time subscriptions, stored procedures, and custom functions
|
|
335
|
-
|
|
336
|
-
All operations automatically use your plugin's table prefix for security and isolation.
|
|
337
|
-
|
|
338
|
-
**Column deprecation and removal (short policy)**
|
|
339
|
-
|
|
340
|
-
- To remove a column, use a two-step process:
|
|
341
|
-
1. Mark the column with `deprecated: true` in your `rimori/db.config.ts` and release. The backend renames the column to `<name>_old`.
|
|
342
|
-
2. In a subsequent release, remove the column from the config. The backend drops `<name>_old`.
|
|
343
|
-
|
|
344
|
-
**Example: CRUD Operations**
|
|
345
|
-
|
|
346
|
-
```typescript
|
|
347
|
-
interface StudySession {
|
|
348
|
-
id?: string;
|
|
349
|
-
user_id: string;
|
|
350
|
-
topic: string;
|
|
351
|
-
duration: number;
|
|
352
|
-
completed_at: string;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
const StudySessionManager = () => {
|
|
356
|
-
const { db } = useRimori();
|
|
357
|
-
|
|
358
|
-
// Create a new study session
|
|
359
|
-
const createSession = async (session: Omit<StudySession, 'id'>) => {
|
|
360
|
-
const { data, error } = await db
|
|
361
|
-
.from('study_sessions') // Automatically prefixed
|
|
362
|
-
.insert(session)
|
|
363
|
-
.select()
|
|
364
|
-
.single();
|
|
365
|
-
|
|
366
|
-
if (error) throw error;
|
|
367
|
-
return data;
|
|
368
|
-
};
|
|
369
|
-
|
|
370
|
-
// Get user's study sessions
|
|
371
|
-
const getUserSessions = async (userId: string) => {
|
|
372
|
-
const { data, error } = await db
|
|
373
|
-
.from('study_sessions')
|
|
374
|
-
.select('*')
|
|
375
|
-
.eq('user_id', userId)
|
|
376
|
-
.order('completed_at', { ascending: false });
|
|
377
|
-
|
|
378
|
-
if (error) throw error;
|
|
379
|
-
return data;
|
|
380
|
-
};
|
|
381
|
-
|
|
382
|
-
// Update session
|
|
383
|
-
const updateSession = async (id: string, updates: Partial<StudySession>) => {
|
|
384
|
-
const { data, error } = await db
|
|
385
|
-
.from('study_sessions')
|
|
386
|
-
.update(updates)
|
|
387
|
-
.eq('id', id)
|
|
388
|
-
.select()
|
|
389
|
-
.single();
|
|
390
|
-
|
|
391
|
-
if (error) throw error;
|
|
392
|
-
return data;
|
|
393
|
-
};
|
|
394
|
-
|
|
395
|
-
return (
|
|
396
|
-
<div>
|
|
397
|
-
{/* Your component UI */}
|
|
398
|
-
</div>
|
|
399
|
-
);
|
|
400
|
-
};
|
|
401
|
-
```
|
|
402
|
-
|
|
403
|
-
**File Storage Example**
|
|
404
|
-
|
|
405
|
-
```typescript
|
|
406
|
-
const FileManager = () => {
|
|
407
|
-
const { db } = useRimori();
|
|
408
|
-
|
|
409
|
-
const uploadFile = async (file: File) => {
|
|
410
|
-
const fileName = `uploads/${Date.now()}-${file.name}`;
|
|
411
|
-
|
|
412
|
-
const { data, error } = await db.storage
|
|
413
|
-
.from('plugin-files')
|
|
414
|
-
.upload(fileName, file);
|
|
415
|
-
|
|
416
|
-
if (error) throw error;
|
|
417
|
-
return data;
|
|
418
|
-
};
|
|
419
|
-
|
|
420
|
-
const downloadFile = async (filePath: string) => {
|
|
421
|
-
const { data, error } = await db.storage
|
|
422
|
-
.from('plugin-files')
|
|
423
|
-
.download(filePath);
|
|
424
|
-
|
|
425
|
-
if (error) throw error;
|
|
426
|
-
return data;
|
|
427
|
-
};
|
|
428
|
-
|
|
429
|
-
return <div>File Manager UI</div>;
|
|
430
|
-
};
|
|
129
|
+
```ts
|
|
130
|
+
const { data, error } = await client.db
|
|
131
|
+
.from("study_sessions")
|
|
132
|
+
.select("*")
|
|
133
|
+
.order("completed_at", { ascending: false });
|
|
431
134
|
```
|
|
432
135
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
Powerful AI/Language Model capabilities built-in:
|
|
436
|
-
|
|
437
|
-
```typescript
|
|
438
|
-
const { llm } = useRimori();
|
|
439
|
-
|
|
440
|
-
// Text generation
|
|
441
|
-
llm.getText(messages: Message[], tools?: Tool[]): Promise<string>
|
|
442
|
-
|
|
443
|
-
// Streaming text generation
|
|
444
|
-
llm.getSteamedText(messages: Message[], onMessage: OnLLMResponse, tools?: Tool[]): void
|
|
136
|
+
Helpers:
|
|
445
137
|
|
|
446
|
-
|
|
447
|
-
|
|
138
|
+
- `db.tablePrefix` – plugin-specific prefix applied to all tables.
|
|
139
|
+
- `db.getTableName("notes")` – resolve the fully qualified table name.
|
|
140
|
+
- Supabase query builder methods (`insert`, `update`, `delete`, `eq`, `limit`, etc.) are available out of the box.
|
|
448
141
|
|
|
449
|
-
|
|
450
|
-
llm.getVoice(text: string, voice?: string, speed?: number, language?: string): Promise<Blob>
|
|
142
|
+
### AI & Voice
|
|
451
143
|
|
|
452
|
-
|
|
453
|
-
llm.getTextFromVoice(file: Blob): Promise<string>
|
|
454
|
-
```
|
|
455
|
-
|
|
456
|
-
**Example: AI Chat Assistant**
|
|
144
|
+
The `client.ai` controller surfaces AI capabilities:
|
|
457
145
|
|
|
458
|
-
|
|
459
|
-
|
|
146
|
+
- `getText(messages, tools?)` – chat completion (string result).
|
|
147
|
+
- `getSteamedText(messages, onMessage, tools?)` – streamed responses.
|
|
148
|
+
- `getObject(request)` – structured JSON generation.
|
|
149
|
+
- `getVoice(text, voice?, speed?, language?)` – text-to-speech (returns `Blob`).
|
|
150
|
+
- `getTextFromVoice(file)` – speech-to-text transcription.
|
|
460
151
|
|
|
461
|
-
|
|
462
|
-
const { messages, append, isLoading } = useChat();
|
|
463
|
-
const [input, setInput] = useState('');
|
|
152
|
+
Use `client.runtime.fetchBackend` for authenticated calls to Rimori-managed HTTP endpoints.
|
|
464
153
|
|
|
465
|
-
|
|
466
|
-
if (!input.trim()) return;
|
|
154
|
+
### Event Bus & Actions
|
|
467
155
|
|
|
468
|
-
|
|
469
|
-
role: 'user',
|
|
470
|
-
content: input
|
|
471
|
-
}]);
|
|
156
|
+
`client.event` lets you collaborate with Rimori and other plugins:
|
|
472
157
|
|
|
473
|
-
|
|
474
|
-
|
|
158
|
+
- `emit(topic, data?)`, `request(topic, data?)` – publish and request data.
|
|
159
|
+
- `on(topic, handler)` / `once(topic, handler)` / `respond(topic, handler)` – subscribe and reply (each call returns an object with `off()` for cleanup).
|
|
160
|
+
- `emitAccomplishment(payload)` / `onAccomplishment(topic, handler)` – report learning milestones.
|
|
161
|
+
- `emitSidebarAction(pluginId, actionKey, text?)` – trigger sidebar plugins.
|
|
162
|
+
- `onMainPanelAction(handler, actionsToListen?)` – react to dashboard actions.
|
|
163
|
+
- `client.navigation.toDashboard()` – navigate the user back to Rimori.
|
|
475
164
|
|
|
476
|
-
|
|
477
|
-
<div className="chat-container">
|
|
478
|
-
<div className="messages">
|
|
479
|
-
{messages.map((message, index) => (
|
|
480
|
-
<div key={index} className={`message ${message.role}`}>
|
|
481
|
-
{message.content}
|
|
482
|
-
</div>
|
|
483
|
-
))}
|
|
484
|
-
{isLoading && <div className="message assistant">Thinking...</div>}
|
|
485
|
-
</div>
|
|
486
|
-
|
|
487
|
-
<div className="input-area">
|
|
488
|
-
<input
|
|
489
|
-
value={input}
|
|
490
|
-
onChange={(e) => setInput(e.target.value)}
|
|
491
|
-
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
|
|
492
|
-
placeholder="Ask anything..."
|
|
493
|
-
/>
|
|
494
|
-
<button onClick={sendMessage} disabled={isLoading}>
|
|
495
|
-
Send
|
|
496
|
-
</button>
|
|
497
|
-
</div>
|
|
498
|
-
</div>
|
|
499
|
-
);
|
|
500
|
-
};
|
|
501
|
-
```
|
|
502
|
-
|
|
503
|
-
**Example: Structured Data Generation**
|
|
504
|
-
|
|
505
|
-
```typescript
|
|
506
|
-
const QuizGenerator = () => {
|
|
507
|
-
const { llm } = useRimori();
|
|
508
|
-
|
|
509
|
-
const generateQuiz = async (topic: string) => {
|
|
510
|
-
const quiz = await llm.getObject({
|
|
511
|
-
schema: {
|
|
512
|
-
type: "object",
|
|
513
|
-
properties: {
|
|
514
|
-
title: { type: "string" },
|
|
515
|
-
questions: {
|
|
516
|
-
type: "array",
|
|
517
|
-
items: {
|
|
518
|
-
type: "object",
|
|
519
|
-
properties: {
|
|
520
|
-
question: { type: "string" },
|
|
521
|
-
options: { type: "array", items: { type: "string" } },
|
|
522
|
-
correctAnswer: { type: "number" },
|
|
523
|
-
explanation: { type: "string" }
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
},
|
|
529
|
-
prompt: `Create a quiz about ${topic} with 5 multiple choice questions.`
|
|
530
|
-
});
|
|
531
|
-
|
|
532
|
-
return quiz;
|
|
533
|
-
};
|
|
534
|
-
|
|
535
|
-
return <div>Quiz Generator UI</div>;
|
|
536
|
-
};
|
|
537
|
-
```
|
|
165
|
+
### Community Content
|
|
538
166
|
|
|
539
|
-
|
|
167
|
+
`client.community.sharedContent` exposes helpers to share or consume content:
|
|
540
168
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
169
|
+
- `get(contentType, id)`
|
|
170
|
+
- `getList(contentType, filter?, limit?)`
|
|
171
|
+
- `getNew(contentType, instructions, filter?, options?)`
|
|
172
|
+
- `create(payload)`
|
|
173
|
+
- `update(id, payload)`
|
|
174
|
+
- `remove(id)`
|
|
175
|
+
- `complete(contentType, assignmentId)`
|
|
544
176
|
|
|
545
|
-
|
|
546
|
-
const audioBlob = await llm.getVoice(text, "alloy", 1, "en");
|
|
547
|
-
const audioUrl = URL.createObjectURL(audioBlob);
|
|
548
|
-
const audio = new Audio(audioUrl);
|
|
549
|
-
audio.play();
|
|
550
|
-
};
|
|
177
|
+
The controller handles topic generation, metadata, and completion tracking automatically.
|
|
551
178
|
|
|
552
|
-
|
|
553
|
-
const transcript = await llm.getTextFromVoice(audioFile);
|
|
554
|
-
return transcript;
|
|
555
|
-
};
|
|
179
|
+
### Workers & Standalone Development
|
|
556
180
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
## Event System
|
|
562
|
-
|
|
563
|
-
Robust inter-plugin communication and platform integration:
|
|
564
|
-
|
|
565
|
-
```typescript
|
|
566
|
-
const { event } = useRimori();
|
|
567
|
-
|
|
568
|
-
// Event methods
|
|
569
|
-
event.emit(topic: string, data?: any, eventId?: number): void
|
|
570
|
-
event.request<T>(topic: string, data?: any): Promise<EventBusMessage<T>>
|
|
571
|
-
event.on<T>(topic: string | string[], callback: EventHandler<T>): string[]
|
|
572
|
-
event.once<T>(topic: string, callback: EventHandler<T>): void
|
|
573
|
-
event.respond<T>(topic: string, data: EventPayload | Function): void
|
|
574
|
-
|
|
575
|
-
// Accomplishments
|
|
576
|
-
event.emitAccomplishment(payload: AccomplishmentPayload): void
|
|
577
|
-
event.onAccomplishment(topic: string, callback: Function): void
|
|
578
|
-
|
|
579
|
-
// Sidebar actions
|
|
580
|
-
event.emitSidebarAction(pluginId: string, actionKey: string, text?: string): void
|
|
581
|
-
```
|
|
582
|
-
|
|
583
|
-
**Example: Plugin Communication**
|
|
584
|
-
|
|
585
|
-
```typescript
|
|
586
|
-
const PluginCommunicator = () => {
|
|
587
|
-
const { event } = useRimori();
|
|
588
|
-
|
|
589
|
-
useEffect(() => {
|
|
590
|
-
// Listen for messages from other plugins
|
|
591
|
-
const unsubscribe = event.on('flashcards.newCard', (message) => {
|
|
592
|
-
console.log('New flashcard created:', message.data);
|
|
593
|
-
});
|
|
594
|
-
|
|
595
|
-
// Listen for global events
|
|
596
|
-
event.on('global.userProgress', (message) => {
|
|
597
|
-
console.log('User progress updated:', message.data);
|
|
598
|
-
});
|
|
599
|
-
|
|
600
|
-
return () => {
|
|
601
|
-
// Cleanup subscriptions
|
|
602
|
-
unsubscribe.forEach(id => event.off(id));
|
|
603
|
-
};
|
|
604
|
-
}, []);
|
|
605
|
-
|
|
606
|
-
const shareData = () => {
|
|
607
|
-
// Emit data to other plugins
|
|
608
|
-
event.emit('studyplan.dataUpdate', {
|
|
609
|
-
type: 'session_completed',
|
|
610
|
-
sessionId: '123',
|
|
611
|
-
score: 85
|
|
612
|
-
});
|
|
613
|
-
};
|
|
614
|
-
|
|
615
|
-
const requestData = async () => {
|
|
616
|
-
// Request data from another plugin
|
|
617
|
-
const response = await event.request('flashcards.getStats', {
|
|
618
|
-
timeframe: 'week'
|
|
619
|
-
});
|
|
620
|
-
|
|
621
|
-
console.log('Flashcard stats:', response.data);
|
|
622
|
-
};
|
|
623
|
-
|
|
624
|
-
return (
|
|
625
|
-
<div>
|
|
626
|
-
<button onClick={shareData}>Share Progress</button>
|
|
627
|
-
<button onClick={requestData}>Get Flashcard Stats</button>
|
|
628
|
-
</div>
|
|
629
|
-
);
|
|
630
|
-
};
|
|
631
|
-
```
|
|
632
|
-
|
|
633
|
-
**Example: Accomplishment System**
|
|
634
|
-
|
|
635
|
-
```typescript
|
|
636
|
-
const AccomplishmentTracker = () => {
|
|
637
|
-
const { event } = useRimori();
|
|
638
|
-
|
|
639
|
-
const trackAccomplishment = () => {
|
|
640
|
-
event.emitAccomplishment({
|
|
641
|
-
type: 'study_milestone',
|
|
642
|
-
title: 'Study Streak',
|
|
643
|
-
description: 'Completed 7 days of studying',
|
|
644
|
-
points: 100,
|
|
645
|
-
metadata: {
|
|
646
|
-
streakDays: 7,
|
|
647
|
-
subject: 'Spanish'
|
|
648
|
-
}
|
|
649
|
-
});
|
|
650
|
-
};
|
|
651
|
-
|
|
652
|
-
useEffect(() => {
|
|
653
|
-
// Listen for accomplishments from this plugin
|
|
654
|
-
event.onAccomplishment('study_milestone', (accomplishment) => {
|
|
655
|
-
console.log('New accomplishment:', accomplishment);
|
|
656
|
-
// Show notification, update UI, etc.
|
|
657
|
-
});
|
|
658
|
-
}, []);
|
|
659
|
-
|
|
660
|
-
return <div>Accomplishment Tracker UI</div>;
|
|
661
|
-
};
|
|
662
|
-
```
|
|
663
|
-
|
|
664
|
-
**Example: Sidebar Integration**
|
|
665
|
-
|
|
666
|
-
```typescript
|
|
667
|
-
const SidebarIntegration = () => {
|
|
668
|
-
const { event } = useRimori();
|
|
669
|
-
|
|
670
|
-
const openTranslator = (text: string) => {
|
|
671
|
-
// Trigger translator plugin in sidebar
|
|
672
|
-
event.emitSidebarAction('translator', 'translate', text);
|
|
673
|
-
};
|
|
674
|
-
|
|
675
|
-
const openFlashcards = () => {
|
|
676
|
-
// Open flashcards plugin
|
|
677
|
-
event.emitSidebarAction('flashcards', 'review');
|
|
678
|
-
};
|
|
679
|
-
|
|
680
|
-
return (
|
|
681
|
-
<div>
|
|
682
|
-
<button onClick={() => openTranslator('Hello world')}>
|
|
683
|
-
Translate "Hello world"
|
|
684
|
-
</button>
|
|
685
|
-
<button onClick={openFlashcards}>
|
|
686
|
-
Review Flashcards
|
|
687
|
-
</button>
|
|
688
|
-
</div>
|
|
689
|
-
);
|
|
690
|
-
};
|
|
691
|
-
```
|
|
692
|
-
|
|
693
|
-
## Community Features
|
|
694
|
-
|
|
695
|
-
Share and discover content created by other users:
|
|
696
|
-
|
|
697
|
-
```typescript
|
|
698
|
-
const { community } = useRimori();
|
|
699
|
-
|
|
700
|
-
// Shared content methods
|
|
701
|
-
community.sharedContent.get<T>(contentType: string, id: string): Promise<BasicAssignment<T>>
|
|
702
|
-
community.sharedContent.getList<T>(contentType: string, filter?: SharedContentFilter, limit?: number): Promise<BasicAssignment<T>[]>
|
|
703
|
-
community.sharedContent.getNew<T>(contentType: string, instructions: SharedContentObjectRequest, filter?: SharedContentFilter, privateTopic?: boolean): Promise<BasicAssignment<T>>
|
|
704
|
-
community.sharedContent.create<T>(content: SharedContent<T>): Promise<BasicAssignment<T>>
|
|
705
|
-
community.sharedContent.update<T>(id: string, content: Partial<SharedContent<T>>): Promise<BasicAssignment<T>>
|
|
706
|
-
community.sharedContent.remove(id: string): Promise<BasicAssignment<any>>
|
|
707
|
-
community.sharedContent.complete(contentType: string, assignmentId: string): Promise<void>
|
|
708
|
-
```
|
|
709
|
-
|
|
710
|
-
**Example: Exercise Sharing Platform**
|
|
711
|
-
|
|
712
|
-
```typescript
|
|
713
|
-
interface Exercise {
|
|
714
|
-
title: string;
|
|
715
|
-
description: string;
|
|
716
|
-
difficulty: 'beginner' | 'intermediate' | 'advanced';
|
|
717
|
-
questions: Array<{
|
|
718
|
-
question: string;
|
|
719
|
-
answer: string;
|
|
720
|
-
hints?: string[];
|
|
721
|
-
}>;
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
const ExerciseManager = () => {
|
|
725
|
-
const { community } = useRimori();
|
|
726
|
-
const [exercises, setExercises] = useState<BasicAssignment<Exercise>[]>([]);
|
|
727
|
-
|
|
728
|
-
// Load community exercises
|
|
729
|
-
const loadExercises = async () => {
|
|
730
|
-
const exerciseList = await community.sharedContent.getList<Exercise>(
|
|
731
|
-
'grammar_exercises',
|
|
732
|
-
{ column: 'difficulty', value: 'beginner' },
|
|
733
|
-
10
|
|
734
|
-
);
|
|
735
|
-
setExercises(exerciseList);
|
|
736
|
-
};
|
|
737
|
-
|
|
738
|
-
// Create new exercise
|
|
739
|
-
const createExercise = async (exercise: Exercise) => {
|
|
740
|
-
const newExercise = await community.sharedContent.create({
|
|
741
|
-
content_type: 'grammar_exercises',
|
|
742
|
-
content: exercise,
|
|
743
|
-
metadata: {
|
|
744
|
-
difficulty: exercise.difficulty,
|
|
745
|
-
questionCount: exercise.questions.length
|
|
746
|
-
}
|
|
747
|
-
});
|
|
748
|
-
|
|
749
|
-
return newExercise;
|
|
750
|
-
};
|
|
751
|
-
|
|
752
|
-
// Generate AI exercise
|
|
753
|
-
const generateExercise = async (topic: string) => {
|
|
754
|
-
const aiExercise = await community.sharedContent.getNew<Exercise>(
|
|
755
|
-
'grammar_exercises',
|
|
756
|
-
{
|
|
757
|
-
prompt: `Create a grammar exercise about ${topic}`,
|
|
758
|
-
schema: {
|
|
759
|
-
type: "object",
|
|
760
|
-
properties: {
|
|
761
|
-
title: { type: "string" },
|
|
762
|
-
description: { type: "string" },
|
|
763
|
-
difficulty: { type: "string", enum: ["beginner", "intermediate", "advanced"] },
|
|
764
|
-
questions: {
|
|
765
|
-
type: "array",
|
|
766
|
-
items: {
|
|
767
|
-
type: "object",
|
|
768
|
-
properties: {
|
|
769
|
-
question: { type: "string" },
|
|
770
|
-
answer: { type: "string" },
|
|
771
|
-
hints: { type: "array", items: { type: "string" } }
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
},
|
|
778
|
-
{ column: 'difficulty', value: 'beginner' }
|
|
779
|
-
);
|
|
780
|
-
|
|
781
|
-
return aiExercise;
|
|
782
|
-
};
|
|
783
|
-
|
|
784
|
-
// Complete exercise
|
|
785
|
-
const completeExercise = async (exerciseId: string) => {
|
|
786
|
-
await community.sharedContent.complete('grammar_exercises', exerciseId);
|
|
787
|
-
// Exercise is now marked as completed for the user
|
|
788
|
-
};
|
|
789
|
-
|
|
790
|
-
return (
|
|
791
|
-
<div>
|
|
792
|
-
<button onClick={loadExercises}>Load Exercises</button>
|
|
793
|
-
<button onClick={() => generateExercise('present tense')}>
|
|
794
|
-
Generate Present Tense Exercise
|
|
795
|
-
</button>
|
|
796
|
-
{/* Exercise list UI */}
|
|
797
|
-
</div>
|
|
798
|
-
);
|
|
799
|
-
};
|
|
800
|
-
```
|
|
801
|
-
|
|
802
|
-
## Components
|
|
803
|
-
|
|
804
|
-
Pre-built React components for common functionality:
|
|
805
|
-
|
|
806
|
-
### MarkdownEditor
|
|
807
|
-
|
|
808
|
-
Rich text editor with markdown support:
|
|
809
|
-
|
|
810
|
-
```typescript
|
|
811
|
-
import { MarkdownEditor } from "@rimori/client";
|
|
812
|
-
|
|
813
|
-
const EditorExample = () => {
|
|
814
|
-
const [content, setContent] = useState('');
|
|
815
|
-
|
|
816
|
-
return (
|
|
817
|
-
<MarkdownEditor
|
|
818
|
-
value={content}
|
|
819
|
-
onChange={setContent}
|
|
820
|
-
placeholder="Start writing..."
|
|
821
|
-
/>
|
|
822
|
-
);
|
|
823
|
-
};
|
|
824
|
-
```
|
|
825
|
-
|
|
826
|
-
### PlayButton
|
|
827
|
-
|
|
828
|
-
Audio playback component:
|
|
829
|
-
|
|
830
|
-
```typescript
|
|
831
|
-
import { PlayButton } from "@rimori/client";
|
|
832
|
-
|
|
833
|
-
const AudioPlayer = () => {
|
|
834
|
-
return (
|
|
835
|
-
<PlayButton
|
|
836
|
-
audioUrl="https://example.com/audio.mp3"
|
|
837
|
-
onPlay={() => console.log('Audio playing')}
|
|
838
|
-
onPause={() => console.log('Audio paused')}
|
|
839
|
-
/>
|
|
840
|
-
);
|
|
841
|
-
};
|
|
842
|
-
```
|
|
843
|
-
|
|
844
|
-
### AI Components
|
|
845
|
-
|
|
846
|
-
```typescript
|
|
847
|
-
import { Avatar, Assistant } from "@rimori/client";
|
|
848
|
-
|
|
849
|
-
const AIInterface = () => {
|
|
850
|
-
return (
|
|
851
|
-
<div>
|
|
852
|
-
<Avatar
|
|
853
|
-
name="AI Assistant"
|
|
854
|
-
status="online"
|
|
855
|
-
size="large"
|
|
856
|
-
/>
|
|
857
|
-
|
|
858
|
-
<Assistant
|
|
859
|
-
onMessage={(message) => console.log('AI message:', message)}
|
|
860
|
-
placeholder="Ask the AI assistant..."
|
|
861
|
-
/>
|
|
862
|
-
</div>
|
|
863
|
-
);
|
|
864
|
-
};
|
|
865
|
-
```
|
|
866
|
-
|
|
867
|
-
## Hooks
|
|
868
|
-
|
|
869
|
-
### useChat
|
|
870
|
-
|
|
871
|
-
Manage AI chat conversations:
|
|
872
|
-
|
|
873
|
-
```typescript
|
|
874
|
-
import { useChat } from "@rimori/client";
|
|
875
|
-
|
|
876
|
-
const ChatExample = () => {
|
|
877
|
-
const { messages, append, isLoading, setMessages } = useChat([
|
|
878
|
-
// Optional tools for the AI
|
|
879
|
-
{
|
|
880
|
-
name: "get_weather",
|
|
881
|
-
description: "Get current weather",
|
|
882
|
-
parameters: {
|
|
883
|
-
type: "object",
|
|
884
|
-
properties: {
|
|
885
|
-
location: { type: "string" }
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
]);
|
|
890
|
-
|
|
891
|
-
const sendMessage = (content: string) => {
|
|
892
|
-
append([{ role: 'user', content }]);
|
|
893
|
-
};
|
|
894
|
-
|
|
895
|
-
return (
|
|
896
|
-
<div>
|
|
897
|
-
{messages.map((msg, index) => (
|
|
898
|
-
<div key={index}>{msg.content}</div>
|
|
899
|
-
))}
|
|
900
|
-
{isLoading && <div>AI is typing...</div>}
|
|
901
|
-
</div>
|
|
902
|
-
);
|
|
903
|
-
};
|
|
904
|
-
```
|
|
905
|
-
|
|
906
|
-
### useTranslation
|
|
907
|
-
|
|
908
|
-
Internationalization (i18n) support built on i18next:
|
|
909
|
-
|
|
910
|
-
```typescript
|
|
911
|
-
import { useTranslation } from "@rimori/client";
|
|
912
|
-
|
|
913
|
-
const TranslatedComponent = () => {
|
|
914
|
-
const { t, ready } = useTranslation();
|
|
915
|
-
|
|
916
|
-
if (!ready) {
|
|
917
|
-
return <div>Loading translations...</div>;
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
return (
|
|
921
|
-
<div>
|
|
922
|
-
<h1>{t('discussion.title')}</h1>
|
|
923
|
-
<p>{t('discussion.whatToTalkAbout')}</p>
|
|
924
|
-
</div>
|
|
925
|
-
);
|
|
926
|
-
};
|
|
927
|
-
```
|
|
928
|
-
|
|
929
|
-
## Translation Feature
|
|
930
|
-
|
|
931
|
-
Rimori includes a comprehensive internationalization (i18n) system built on i18next that allows plugins to support multiple languages with minimal developer effort.
|
|
932
|
-
|
|
933
|
-
### How it works
|
|
934
|
-
|
|
935
|
-
- **Developer Focus**: Developers only need to ensure their interface works in English
|
|
936
|
-
- **Automatic Translations**: With every release, translations for all other languages are generated automatically
|
|
937
|
-
- **Local Testing**: For local development, you can test translations by:
|
|
938
|
-
1. Setting your user language to a non-English locale (e.g., German)
|
|
939
|
-
2. Creating a local translation file with "local-" prefix (e.g., `local-de.json`) in the `public/locales/` directory
|
|
940
|
-
3. The translator will automatically use the local translation file in development mode
|
|
941
|
-
- **Manual Translations**: If developers want to manually translate files, they should place the language file manually in the `public/locales/` folder with the language code as filename (e.g., `de.json`, `fr.json`)
|
|
942
|
-
|
|
943
|
-
### Usage
|
|
944
|
-
|
|
945
|
-
#### Using the Hook (Recommended)
|
|
946
|
-
|
|
947
|
-
```typescript
|
|
948
|
-
import { useTranslation } from "@rimori/client";
|
|
949
|
-
|
|
950
|
-
function MyComponent() {
|
|
951
|
-
const { t, ready } = useTranslation();
|
|
952
|
-
|
|
953
|
-
if (!ready) {
|
|
954
|
-
return <div>Loading translations...</div>;
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
return (
|
|
958
|
-
<div>
|
|
959
|
-
<h1>{t('discussion.title')}</h1>
|
|
960
|
-
<p>{t('discussion.whatToTalkAbout')}</p>
|
|
961
|
-
</div>
|
|
962
|
-
);
|
|
963
|
-
}
|
|
964
|
-
```
|
|
965
|
-
|
|
966
|
-
#### Using the Translator Instance
|
|
967
|
-
|
|
968
|
-
```typescript
|
|
969
|
-
import { useRimori } from "@rimori/client";
|
|
970
|
-
|
|
971
|
-
const { plugin } = useRimori();
|
|
972
|
-
const translator = await plugin.getTranslator()
|
|
973
|
-
|
|
974
|
-
const translatedText = translator.t("discussion.title");
|
|
975
|
-
```
|
|
976
|
-
|
|
977
|
-
### Translation File Structure
|
|
978
|
-
|
|
979
|
-
- **Location**: `public/locales/`
|
|
980
|
-
- **Production Files**: Must be named `{language}.json` (e.g., `en.json`, `de.json`, `fr.json`)
|
|
981
|
-
- **Local Development Files**: Must be named `local-{language}.json` (e.g., `local-de.json`, `local-fr.json`)
|
|
982
|
-
- **Format**: Standard JSON with nested objects for organization
|
|
983
|
-
- **English Requirement**: `en.json` is required as the base language
|
|
984
|
-
- **Release Process**: Files starting with "local-" are ignored during the release process
|
|
985
|
-
|
|
986
|
-
Example translation file structure:
|
|
987
|
-
|
|
988
|
-
```json
|
|
989
|
-
{
|
|
990
|
-
"discussion": {
|
|
991
|
-
"title": "Discussion",
|
|
992
|
-
"whatToTalkAbout": "What do you want to talk about?",
|
|
993
|
-
"topics": {
|
|
994
|
-
"everyday": {
|
|
995
|
-
"title": "Everyday Conversations",
|
|
996
|
-
"description": "Ordering coffee, asking for directions, etc."
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
|
-
}
|
|
1001
|
-
```
|
|
1002
|
-
|
|
1003
|
-
### Features
|
|
1004
|
-
|
|
1005
|
-
- **I18next Support**: All i18next features work with these translations including:
|
|
1006
|
-
- Variable interpolation: `{{name}}`
|
|
1007
|
-
- Pluralization
|
|
1008
|
-
- Fallback mechanisms
|
|
1009
|
-
- **Automatic Fallback**: If a translation is missing, it falls back to English
|
|
1010
|
-
- **Development Mode**: Local translation files are prioritized in development
|
|
1011
|
-
- **Production Ready**: Automatic translation generation for production releases
|
|
1012
|
-
|
|
1013
|
-
### Limitations
|
|
1014
|
-
|
|
1015
|
-
- Only one translation file per language is allowed
|
|
1016
|
-
- Namespaces are not supported
|
|
1017
|
-
- Production translation files must be named `{language}.json` and placed in `public/locales/`
|
|
1018
|
-
- Local development files must be named `local-{language}.json` and placed in `public/locales/`
|
|
1019
|
-
- English (`en.json`) is required as the base language
|
|
1020
|
-
- Local files (prefixed with "local-") are ignored during the release process
|
|
181
|
+
- `setupWorker()` wires the Rimori event bus into worker contexts.
|
|
182
|
+
- `StandaloneClient.getInstance()` signs in against Rimori when your plugin runs outside the platform (e.g., local development).
|
|
183
|
+
- `client.getQueryParam(key)` reads values provided by Rimori through the sandbox handshake (such as `applicationMode` or theme information).
|
|
1021
184
|
|
|
1022
185
|
## Utilities
|
|
1023
186
|
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
Convert between different difficulty representations:
|
|
1027
|
-
|
|
1028
|
-
```typescript
|
|
1029
|
-
import { difficultyConverter } from '@rimori/client';
|
|
1030
|
-
|
|
1031
|
-
const difficulty = difficultyConverter.toNumber('intermediate'); // Returns: 2
|
|
1032
|
-
const difficultyText = difficultyConverter.toString(3); // Returns: 'advanced'
|
|
1033
|
-
```
|
|
1034
|
-
|
|
1035
|
-
### PluginUtils
|
|
1036
|
-
|
|
1037
|
-
Various utility functions:
|
|
1038
|
-
|
|
1039
|
-
```typescript
|
|
1040
|
-
import { PluginUtils } from '@rimori/client';
|
|
1041
|
-
|
|
1042
|
-
// Utility functions for common plugin operations
|
|
1043
|
-
const utils = PluginUtils.getInstance();
|
|
1044
|
-
// Access various helper methods
|
|
1045
|
-
```
|
|
187
|
+
Import additional helpers as needed:
|
|
1046
188
|
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
```typescript
|
|
1052
|
-
import { Language } from '@rimori/client';
|
|
1053
|
-
|
|
1054
|
-
// Language-related utility functions
|
|
1055
|
-
const languageCode = Language.detectLanguage(text);
|
|
1056
|
-
const isSupported = Language.isSupported('es');
|
|
1057
|
-
```
|
|
189
|
+
- `AudioController` – high-level audio playback/recording utilities for non-React environments.
|
|
190
|
+
- `Translator` – encapsulated i18next integration for manual translation flows.
|
|
191
|
+
- `difficultyConverter` – convert between textual and numeric difficulty levels.
|
|
192
|
+
- Type definitions for AI messages, shared content, triggers, accomplishments, and more.
|
|
1058
193
|
|
|
1059
194
|
## TypeScript Support
|
|
1060
195
|
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
```typescript
|
|
1064
|
-
import type {
|
|
1065
|
-
MainPanelAction,
|
|
1066
|
-
Message,
|
|
1067
|
-
Tool,
|
|
1068
|
-
EventPayload,
|
|
1069
|
-
AccomplishmentPayload,
|
|
1070
|
-
SharedContent,
|
|
1071
|
-
BasicAssignment,
|
|
1072
|
-
UserInfo,
|
|
1073
|
-
} from '@rimori/client';
|
|
1074
|
-
|
|
1075
|
-
// All interfaces and types are exported for use in your plugin
|
|
1076
|
-
interface MyPluginData extends SharedContent<any> {
|
|
1077
|
-
// Your custom properties
|
|
1078
|
-
}
|
|
1079
|
-
```
|
|
1080
|
-
|
|
1081
|
-
The SharedContent has this type definition:
|
|
196
|
+
All exports are fully typed. You can import the type definitions directly:
|
|
1082
197
|
|
|
198
|
+
```ts
|
|
199
|
+
import type { Message, Tool, SharedContent, MacroAccomplishmentPayload } from "@rimori/client";
|
|
1083
200
|
```
|
|
1084
|
-
export interface SharedContent<T> {
|
|
1085
|
-
/** The type/category of the content (e.g. 'grammar_exercises', 'flashcards', etc.) */
|
|
1086
|
-
contentType: string;
|
|
1087
201
|
|
|
1088
|
-
|
|
1089
|
-
topic: string;
|
|
202
|
+
The generated declaration files cover every controller and helper to keep plugins strictly typed.
|
|
1090
203
|
|
|
1091
|
-
|
|
1092
|
-
keywords: string[];
|
|
204
|
+
## Example Integration
|
|
1093
205
|
|
|
1094
|
-
|
|
1095
|
-
data: T;
|
|
206
|
+
React users should install `@rimori/react-client` and wrap their app:
|
|
1096
207
|
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
}
|
|
1100
|
-
```
|
|
208
|
+
```tsx
|
|
209
|
+
import { PluginProvider, useRimori, useChat } from "@rimori/react-client";
|
|
1101
210
|
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
### Complete Plugin Example
|
|
1105
|
-
|
|
1106
|
-
```typescript
|
|
1107
|
-
import React, { useState, useEffect } from 'react';
|
|
1108
|
-
import {
|
|
1109
|
-
PluginProvider,
|
|
1110
|
-
usePlugin,
|
|
1111
|
-
MarkdownEditor,
|
|
1112
|
-
Spinner,
|
|
1113
|
-
useChat
|
|
1114
|
-
} from '@rimori/client';
|
|
1115
|
-
import { HashRouter, Route, Routes } from 'react-router-dom';
|
|
1116
|
-
|
|
1117
|
-
const StudyNotesPlugin = () => {
|
|
1118
|
-
const { db, llm, plugin, community } = useRimori();
|
|
1119
|
-
const [notes, setNotes] = useState([]);
|
|
1120
|
-
const [isLoading, setIsLoading] = useState(true);
|
|
211
|
+
function Dashboard() {
|
|
212
|
+
const client = useRimori();
|
|
1121
213
|
const { messages, append } = useChat();
|
|
1122
214
|
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
const loadNotes = async () => {
|
|
1128
|
-
try {
|
|
1129
|
-
const { data } = await db.from('notes').select('*').order('created_at', { ascending: false });
|
|
1130
|
-
setNotes(data || []);
|
|
1131
|
-
} catch (error) {
|
|
1132
|
-
console.error('Error loading notes:', error);
|
|
1133
|
-
} finally {
|
|
1134
|
-
setIsLoading(false);
|
|
1135
|
-
}
|
|
1136
|
-
};
|
|
1137
|
-
|
|
1138
|
-
const saveNote = async (content: string) => {
|
|
1139
|
-
const { data } = await db.from('notes').insert({
|
|
1140
|
-
content,
|
|
1141
|
-
created_at: new Date().toISOString()
|
|
1142
|
-
}).select().single();
|
|
1143
|
-
|
|
1144
|
-
setNotes([data, ...notes]);
|
|
1145
|
-
|
|
1146
|
-
// Share with community
|
|
1147
|
-
await community.sharedContent.create({
|
|
1148
|
-
content_type: 'study_notes',
|
|
1149
|
-
content: { text: content },
|
|
1150
|
-
metadata: { wordCount: content.length }
|
|
1151
|
-
});
|
|
1152
|
-
};
|
|
1153
|
-
|
|
1154
|
-
const generateSummary = async (noteContent: string) => {
|
|
1155
|
-
const summary = await llm.getText([
|
|
1156
|
-
{ role: 'user', content: `Summarize this study note: ${noteContent}` }
|
|
1157
|
-
]);
|
|
1158
|
-
|
|
1159
|
-
return summary;
|
|
1160
|
-
};
|
|
1161
|
-
|
|
1162
|
-
if (isLoading) return <Spinner size="large" />;
|
|
215
|
+
// interact with the core API through the client instance
|
|
216
|
+
// e.g. client.db.from("notes")...
|
|
217
|
+
}
|
|
1163
218
|
|
|
219
|
+
export function App() {
|
|
1164
220
|
return (
|
|
1165
|
-
<
|
|
1166
|
-
<
|
|
1167
|
-
|
|
1168
|
-
<div className="notes-grid">
|
|
1169
|
-
{notes.map(note => (
|
|
1170
|
-
<div key={note.id} className="note-card">
|
|
1171
|
-
<MarkdownEditor
|
|
1172
|
-
value={note.content}
|
|
1173
|
-
onChange={(content) => {/* Update logic */}}
|
|
1174
|
-
/>
|
|
1175
|
-
<button onClick={() => generateSummary(note.content)}>
|
|
1176
|
-
Generate Summary
|
|
1177
|
-
</button>
|
|
1178
|
-
</div>
|
|
1179
|
-
))}
|
|
1180
|
-
</div>
|
|
1181
|
-
|
|
1182
|
-
<div className="ai-chat">
|
|
1183
|
-
<h2>Study Assistant</h2>
|
|
1184
|
-
{messages.map((msg, index) => (
|
|
1185
|
-
<div key={index} className={`message ${msg.role}`}>
|
|
1186
|
-
{msg.content}
|
|
1187
|
-
</div>
|
|
1188
|
-
))}
|
|
1189
|
-
<button onClick={() => append([{ role: 'user', content: 'Help me study' }])}>
|
|
1190
|
-
Get Study Help
|
|
1191
|
-
</button>
|
|
1192
|
-
</div>
|
|
1193
|
-
</div>
|
|
221
|
+
<PluginProvider pluginId="your-plugin-id">
|
|
222
|
+
<Dashboard />
|
|
223
|
+
</PluginProvider>
|
|
1194
224
|
);
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
const App = () => (
|
|
1198
|
-
<PluginProvider pluginId="study-notes-plugin">
|
|
1199
|
-
<HashRouter>
|
|
1200
|
-
<Routes>
|
|
1201
|
-
<Route path="/" element={<StudyNotesPlugin />} />
|
|
1202
|
-
</Routes>
|
|
1203
|
-
</HashRouter>
|
|
1204
|
-
</PluginProvider>
|
|
1205
|
-
);
|
|
1206
|
-
|
|
1207
|
-
export default App;
|
|
225
|
+
}
|
|
1208
226
|
```
|
|
1209
227
|
|
|
1210
|
-
|
|
228
|
+
Non-React projects can interact with the same client instance directly via the examples in the sections above.
|
|
229
|
+
|
|
230
|
+
## Troubleshooting
|
|
1211
231
|
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
4. **Event Cleanup**: Always unsubscribe from events in useEffect cleanup
|
|
1216
|
-
5. **Responsive Design**: Use TailwindCSS classes for responsive layouts
|
|
232
|
+
- **`ReferenceError: process is not defined` in workers** – ensure worker bundles only import from `@rimori/client`. Packages that reference `process.env` are not compatible with Rimori workers.
|
|
233
|
+
- **Missing plugin ID or token** – re-run `rimori-init` to regenerate configuration and authentication secrets.
|
|
234
|
+
- **Event bus listeners firing twice** – store the listener returned by `event.on` and call `listener.off()` during cleanup (React users get this cleanup inside the hooks provided by `@rimori/react-client`).
|