@mordn/chat-widget 0.1.1 → 0.1.4

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 CHANGED
@@ -2,12 +2,21 @@
2
2
 
3
3
  A customizable AI chat widget for React/Next.js applications with built-in conversation persistence.
4
4
 
5
- ## Installation
5
+ ## Quick Start
6
6
 
7
7
  ```bash
8
- npm install @mordn/chat-widget
8
+ # 1. Install the package
9
+ npm install @mordn/chat-widget drizzle-kit
10
+
11
+ # 2. Run the setup wizard
12
+ npx @mordn/chat-widget
9
13
  ```
10
14
 
15
+ The setup wizard creates all required files:
16
+ - API routes (`/api/chat/...`)
17
+ - `drizzle.config.ts`
18
+ - `.env.example`
19
+
11
20
  ## Requirements
12
21
 
13
22
  - Next.js 14+ (App Router)
@@ -17,241 +26,113 @@ npm install @mordn/chat-widget
17
26
 
18
27
  ## Setup
19
28
 
20
- ### 1. Environment Variable
29
+ ### 1. Environment Variables
21
30
 
22
- Add your database connection string:
31
+ Copy `.env.example` to `.env.local` and fill in your credentials:
23
32
 
24
33
  ```env
34
+ # Database (Required)
25
35
  DATABASE_URL="postgresql://postgres.xxx:[PASSWORD]@aws-0-region.pooler.supabase.com:6543/postgres"
26
- ```
27
-
28
- ### 2. Database Setup
29
36
 
30
- Install drizzle-kit as a dev dependency:
31
-
32
- ```bash
33
- npm install drizzle-kit --save-dev
37
+ # AI Provider (Required)
38
+ AI_GATEWAY_API_KEY="your-ai-gateway-key"
34
39
  ```
35
40
 
36
- Create `drizzle.config.ts` in your project root:
37
-
38
- ```typescript
39
- import { defineConfig } from 'drizzle-kit';
40
-
41
- export default defineConfig({
42
- schema: './node_modules/@mordn/chat-widget/dist/db/index.js',
43
- out: './drizzle',
44
- dialect: 'postgresql',
45
- dbCredentials: {
46
- url: process.env.DATABASE_URL!,
47
- },
48
- });
49
- ```
41
+ ### 2. Database Setup
50
42
 
51
- Run the migration to create tables:
43
+ Push the schema to your database:
52
44
 
53
45
  ```bash
54
46
  npx drizzle-kit push
55
47
  ```
56
48
 
57
- ### 3. API Routes
58
-
59
- Create the following API routes in your Next.js app:
49
+ ### 3. Configure Your AI Model
60
50
 
61
- #### `app/api/chat/route.ts` - Main Chat Endpoint
51
+ Open `app/api/chat/route.ts` and update the config:
62
52
 
63
53
  ```typescript
64
- import { saveChat, createChat, db, conversations, eq } from '@mordn/chat-widget/api';
65
- import { streamText } from 'ai';
66
- import { openai } from '@ai-sdk/openai';
67
-
68
- export async function POST(req: Request) {
69
- const { messages, id } = await req.json();
70
- const userId = req.headers.get('X-User-Id');
71
-
72
- if (!userId) {
73
- return new Response('Unauthorized', { status: 401 });
74
- }
75
-
76
- // Create conversation if it doesn't exist
77
- const existing = await db
78
- .select({ id: conversations.id })
79
- .from(conversations)
80
- .where(eq(conversations.id, id))
81
- .limit(1);
82
-
83
- if (existing.length === 0) {
84
- await createChat(userId);
85
- // Update the ID to match the provided one
86
- await db.update(conversations)
87
- .set({ id })
88
- .where(eq(conversations.id, existing[0]?.id));
89
- }
90
-
91
- const result = streamText({
92
- model: openai('gpt-4o'),
93
- system: 'You are a helpful assistant.',
94
- messages,
95
- onFinish: async ({ response }) => {
96
- await saveChat({
97
- chatId: id,
98
- messages: [...messages, ...response.messages],
99
- userId,
100
- });
101
- },
102
- });
103
-
104
- return result.toDataStreamResponse();
105
- }
106
- ```
107
-
108
- #### `app/api/chat/history/route.ts` - List Conversations
109
-
110
- ```typescript
111
- import { NextResponse } from 'next/server';
112
- import { getConversations } from '@mordn/chat-widget/api';
113
-
114
- export async function GET(request: Request) {
115
- const userId = request.headers.get('X-User-Id');
116
-
117
- if (!userId) {
118
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
119
- }
120
-
121
- const conversations = await getConversations(userId);
122
- return NextResponse.json({ conversations });
123
- }
54
+ const DEVELOPER_CONFIG = {
55
+ model: 'openai/gpt-4o', // Your AI model
56
+ systemPrompt: 'You are a helpful assistant',
57
+ temperature: 0.7,
58
+ };
124
59
  ```
125
60
 
126
- #### `app/api/chat/history/[id]/route.ts` - Get Conversation Messages
127
-
128
- ```typescript
129
- import { NextResponse } from 'next/server';
130
- import { loadChat } from '@mordn/chat-widget/api';
131
-
132
- export async function GET(
133
- request: Request,
134
- { params }: { params: { id: string } }
135
- ) {
136
- const userId = request.headers.get('X-User-Id');
137
-
138
- if (!userId) {
139
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
140
- }
141
-
142
- const messages = await loadChat(params.id);
143
- return NextResponse.json({ messages });
144
- }
145
- ```
146
-
147
- #### `app/api/chat/upload/route.ts` - File Upload (Optional)
148
-
149
- ```typescript
150
- import { NextResponse } from 'next/server';
151
- import { createClient } from '@supabase/supabase-js';
152
-
153
- const supabase = createClient(
154
- process.env.NEXT_PUBLIC_SUPABASE_URL!,
155
- process.env.SUPABASE_SERVICE_ROLE_KEY!
156
- );
157
-
158
- export async function POST(request: Request) {
159
- const formData = await request.formData();
160
- const file = formData.get('file') as File;
161
- const conversationId = formData.get('conversationId') as string;
162
- const userId = formData.get('userId') as string;
163
-
164
- if (!file || !userId) {
165
- return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
166
- }
167
-
168
- const filename = `${userId}/${conversationId}/${Date.now()}-${file.name}`;
169
- const { data, error } = await supabase.storage
170
- .from('chat-uploads')
171
- .upload(filename, file);
172
-
173
- if (error) {
174
- return NextResponse.json({ error: error.message }, { status: 500 });
175
- }
176
-
177
- const { data: { publicUrl } } = supabase.storage
178
- .from('chat-uploads')
179
- .getPublicUrl(filename);
180
-
181
- return NextResponse.json({
182
- url: publicUrl,
183
- filename: file.name,
184
- mediaType: file.type,
185
- size: file.size,
186
- });
187
- }
188
- ```
189
-
190
- ### 4. Use the Widget
191
-
192
- **For sitewide usage**, create a provider component:
61
+ ### 4. Add the Widget
193
62
 
194
63
  ```tsx
195
- // components/chat-provider.tsx
196
64
  'use client';
197
65
 
198
66
  import { ChatWidget } from '@mordn/chat-widget';
199
67
  import '@mordn/chat-widget/styles.css';
200
- import { useAuth } from '@/contexts/auth-context'; // Your auth hook
201
-
202
- export function ChatProvider() {
203
- const { user, loading } = useAuth();
204
-
205
- if (loading || !user) {
206
- return null;
207
- }
208
68
 
69
+ export default function Page() {
209
70
  return (
210
71
  <ChatWidget
211
- userId={user.id}
212
- theme={{ mode: 'dark' }}
72
+ // Required
73
+ userId="user-123"
74
+
75
+ // Theme: 'light' | 'dark'
76
+ theme={{ mode: 'light' }}
77
+
78
+ // Feature toggles
79
+ features={{
80
+ fileUpload: false, // Requires Supabase Storage setup
81
+ }}
82
+
83
+ // Display options
84
+ display={{
85
+ defaultOpen: false, // Start with chat open
86
+ size: 'default', // 'compact' | 'default' | 'large' | 'full'
87
+ resizable: true, // Allow resizing
88
+ showToggleButton: true, // Show FAB toggle button
89
+ }}
90
+
91
+ // Starter prompts shown on empty chat
92
+ starterPrompts={[
93
+ { title: "What can you help me with?" },
94
+ { title: "How do I get started?" },
95
+ ]}
213
96
  />
214
97
  );
215
98
  }
216
99
  ```
217
100
 
218
- Add to your root layout:
101
+ ---
219
102
 
220
- ```tsx
221
- // app/layout.tsx
222
- import { ChatProvider } from '@/components/chat-provider';
103
+ ## File Uploads (Optional)
223
104
 
224
- export default function RootLayout({ children }) {
225
- return (
226
- <html>
227
- <body>
228
- {children}
229
- <ChatProvider />
230
- </body>
231
- </html>
232
- );
233
- }
105
+ To enable image attachments, you need Supabase Storage and to enable the feature:
106
+
107
+ ```tsx
108
+ <ChatWidget
109
+ userId="user-123"
110
+ features={{ fileUpload: true }}
111
+ />
234
112
  ```
235
113
 
236
- **For a specific page only:**
114
+ ### 1. Create Storage Bucket
237
115
 
238
- ```tsx
239
- // app/dashboard/page.tsx
240
- 'use client';
116
+ 1. Go to your [Supabase Dashboard](https://supabase.com/dashboard)
117
+ 2. Navigate to **Storage** → **New Bucket**
118
+ 3. Create a bucket named `chat-attachments`
119
+ 4. Set it to **Public** (or configure RLS policies for private access)
241
120
 
242
- import { ChatWidget } from '@mordn/chat-widget';
243
- import '@mordn/chat-widget/styles.css';
121
+ ### 2. Add Environment Variables
244
122
 
245
- export default function DashboardPage() {
246
- return (
247
- <div>
248
- <h1>Dashboard</h1>
249
- <ChatWidget userId="user-123" />
250
- </div>
251
- );
252
- }
123
+ ```env
124
+ NEXT_PUBLIC_SUPABASE_URL="https://your-project.supabase.co"
125
+ SUPABASE_SERVICE_ROLE_KEY="your-service-role-key"
253
126
  ```
254
127
 
128
+ You can find these in Supabase Dashboard → **Settings** → **API**
129
+
130
+ ### 3. Create Upload Route
131
+
132
+ When running `npx @mordn/chat-widget`, select **Yes** when asked about the upload route.
133
+
134
+ ---
135
+
255
136
  ## Props
256
137
 
257
138
  | Prop | Type | Default | Description |
@@ -260,9 +141,6 @@ export default function DashboardPage() {
260
141
  | `conversationId` | `string` | - | Load a specific conversation |
261
142
  | `initialMessages` | `array` | - | Pre-fill the chat with messages |
262
143
  | `className` | `string` | - | Additional CSS classes |
263
- | `model` | `string` | - | AI model identifier |
264
- | `systemPrompt` | `string` | - | System prompt for the AI |
265
- | `temperature` | `number` | - | AI temperature (0-1) |
266
144
  | `theme` | `ThemeConfig` | - | Theme configuration |
267
145
  | `features` | `FeatureConfig` | - | Feature toggles |
268
146
  | `display` | `DisplayConfig` | - | Display options |
@@ -272,9 +150,6 @@ export default function DashboardPage() {
272
150
  ```typescript
273
151
  {
274
152
  mode?: 'light' | 'dark';
275
- primaryColor?: string; // Hex color
276
- backgroundColor?: string; // Hex color
277
- textColor?: string; // Hex color
278
153
  }
279
154
  ```
280
155
 
@@ -282,7 +157,7 @@ export default function DashboardPage() {
282
157
 
283
158
  ```typescript
284
159
  {
285
- fileUpload?: boolean; // Enable file attachments (default: true)
160
+ fileUpload?: boolean; // Enable file attachments (default: false)
286
161
  webSearch?: boolean; // Enable web search toggle
287
162
  }
288
163
  ```
@@ -291,16 +166,18 @@ export default function DashboardPage() {
291
166
 
292
167
  ```typescript
293
168
  {
294
- width?: string; // e.g., '400px' or '30vw' (default: '30vw')
169
+ width?: string; // e.g., '400px' or '30vw'
295
170
  defaultOpen?: boolean; // Start with chat open (default: false)
296
171
  showToggleButton?: boolean; // Show FAB toggle button (default: true)
297
172
  toggleButtonPosition?: {
298
- bottom?: string; // e.g., '24px'
299
- right?: string; // e.g., '24px'
173
+ bottom?: string;
174
+ right?: string;
300
175
  };
301
176
  }
302
177
  ```
303
178
 
179
+ ---
180
+
304
181
  ## Exports
305
182
 
306
183
  ```typescript
@@ -321,18 +198,338 @@ import {
321
198
  updateConversationTitle,
322
199
  eq, and, or, desc, asc, sql
323
200
  } from '@mordn/chat-widget/api';
201
+ ```
202
+
203
+ ---
204
+
205
+ ## Generated Files Reference
206
+
207
+ <details>
208
+ <summary><strong>app/api/chat/route.ts</strong> - Main Chat Endpoint</summary>
209
+
210
+ ```typescript
211
+ import { saveChat, updateConversationTitle, db, conversations, messages, eq } from '@mordn/chat-widget/api';
212
+ import { convertToModelMessages, streamText, UIMessage } from 'ai';
213
+
214
+ export const maxDuration = 30;
215
+
216
+ const DEVELOPER_CONFIG = {
217
+ model: 'openai/gpt-4o',
218
+ systemPrompt: 'You are a helpful assistant',
219
+ temperature: 0.7,
220
+ };
221
+
222
+ export async function POST(req: Request) {
223
+ try {
224
+ const body = await req.json();
225
+ const userId = req.headers.get('X-User-Id');
226
+
227
+ if (!userId) {
228
+ return new Response('userId is required in X-User-Id header', { status: 400 });
229
+ }
230
+
231
+ const chatMessages: UIMessage[] = body.messages || [];
232
+ const id: string = body.id || 'temp-id';
233
+ const { model, systemPrompt, temperature } = DEVELOPER_CONFIG;
234
+
235
+ const existingConv = await db
236
+ .select({ id: conversations.id })
237
+ .from(conversations)
238
+ .where(eq(conversations.id, id))
239
+ .limit(1);
240
+
241
+ if (!existingConv.length) {
242
+ await db.insert(conversations).values({
243
+ id,
244
+ userId,
245
+ title: 'New Chat',
246
+ metadata: {},
247
+ });
248
+ }
249
+
250
+ const userMessages = chatMessages.filter(msg => msg.role === 'user');
251
+ if (userMessages.length > 0) {
252
+ const newUserMessage = userMessages[userMessages.length - 1];
253
+ const textPart = newUserMessage.parts?.find(p => p.type === 'text') as { text: string } | undefined;
254
+ const fileParts = newUserMessage.parts?.filter(p => p.type === 'file') || [];
255
+
256
+ const existingMsg = await db
257
+ .select({ id: messages.id })
258
+ .from(messages)
259
+ .where(eq(messages.id, newUserMessage.id))
260
+ .limit(1);
261
+
262
+ if (!existingMsg.length) {
263
+ await db.insert(messages).values({
264
+ id: newUserMessage.id,
265
+ conversationId: id,
266
+ role: newUserMessage.role,
267
+ content: textPart?.text || '',
268
+ files: fileParts,
269
+ model: model,
270
+ metadata: { parts: newUserMessage.parts || [] },
271
+ });
272
+ }
273
+
274
+ if (textPart?.text) {
275
+ const conv = await db
276
+ .select({ title: conversations.title })
277
+ .from(conversations)
278
+ .where(eq(conversations.id, id))
279
+ .limit(1);
280
+
281
+ if (conv[0]?.title === 'New Chat') {
282
+ await updateConversationTitle(id, textPart.text.slice(0, 100));
283
+ }
284
+ }
285
+ }
286
+
287
+ const transformedMessages = chatMessages.map(msg => {
288
+ if (msg.role === 'user' && msg.parts) {
289
+ const textPart = msg.parts.find(p => p.type === 'text');
290
+ const fileParts = msg.parts.filter(p => p.type === 'file');
291
+
292
+ if (fileParts.length > 0) {
293
+ const content: any[] = [];
294
+ if (textPart && 'text' in textPart) {
295
+ content.push({ type: 'text', text: textPart.text });
296
+ }
297
+ for (const file of fileParts) {
298
+ if ('mediaType' in file && (file as any).mediaType?.startsWith('image/')) {
299
+ content.push({ type: 'image', image: (file as any).url });
300
+ }
301
+ }
302
+ return { ...msg, content };
303
+ }
304
+ }
305
+ return msg;
306
+ });
307
+
308
+ const result = streamText({
309
+ model: model,
310
+ messages: convertToModelMessages(transformedMessages),
311
+ system: systemPrompt,
312
+ temperature: temperature,
313
+ });
314
+
315
+ return result.toUIMessageStreamResponse({
316
+ sendSources: true,
317
+ sendReasoning: true,
318
+ onFinish: ({ messages: finalMessages }) => {
319
+ if (finalMessages.length > 0) {
320
+ saveChat({ chatId: id, messages: finalMessages, model, userId });
321
+ }
322
+ },
323
+ });
324
+ } catch (error) {
325
+ console.error('Chat API error:', error);
326
+ return new Response(JSON.stringify({ error: 'Internal server error' }), {
327
+ status: 500,
328
+ headers: { 'Content-Type': 'application/json' },
329
+ });
330
+ }
331
+ }
332
+ ```
333
+
334
+ </details>
324
335
 
325
- // Types
326
- import type {
327
- ChatWidgetConfig,
328
- ThemeConfig,
329
- FeatureConfig,
330
- DisplayConfig,
331
- Conversation,
332
- Message
333
- } from '@mordn/chat-widget';
336
+ <details>
337
+ <summary><strong>app/api/chat/history/route.ts</strong> - List Conversations</summary>
338
+
339
+ ```typescript
340
+ import { NextResponse } from 'next/server';
341
+ import { getConversations } from '@mordn/chat-widget/api';
342
+
343
+ export async function GET(request: Request) {
344
+ try {
345
+ const url = new URL(request.url);
346
+ const userId = url.searchParams.get('userId') || request.headers.get('X-User-Id');
347
+
348
+ if (!userId) {
349
+ return NextResponse.json({ error: 'userId is required' }, { status: 400 });
350
+ }
351
+
352
+ const conversationsData = await getConversations(userId);
353
+
354
+ const conversations = conversationsData.map(conv => ({
355
+ id: conv.id,
356
+ title: conv.title,
357
+ created_at: conv.createdAt,
358
+ updated_at: conv.updatedAt,
359
+ metadata: conv.metadata,
360
+ message_count: conv.messageCount,
361
+ }));
362
+
363
+ return NextResponse.json({ conversations });
364
+ } catch (error) {
365
+ console.error('Error in chat history API:', error);
366
+ return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
367
+ }
368
+ }
369
+ ```
370
+
371
+ </details>
372
+
373
+ <details>
374
+ <summary><strong>app/api/chat/history/[conversationId]/route.ts</strong> - Get Conversation</summary>
375
+
376
+ ```typescript
377
+ import { NextResponse } from 'next/server';
378
+ import { db, conversations, messages, eq, and, asc } from '@mordn/chat-widget/api';
379
+
380
+ export async function GET(
381
+ request: Request,
382
+ { params }: { params: Promise<{ conversationId: string }> }
383
+ ) {
384
+ try {
385
+ const { conversationId } = await params;
386
+ const url = new URL(request.url);
387
+ const userId = url.searchParams.get('userId') || request.headers.get('X-User-Id');
388
+
389
+ if (!userId) {
390
+ return NextResponse.json({ error: 'userId is required' }, { status: 400 });
391
+ }
392
+
393
+ const conv = await db
394
+ .select({
395
+ id: conversations.id,
396
+ title: conversations.title,
397
+ metadata: conversations.metadata,
398
+ })
399
+ .from(conversations)
400
+ .where(and(
401
+ eq(conversations.id, conversationId),
402
+ eq(conversations.userId, userId)
403
+ ))
404
+ .limit(1);
405
+
406
+ if (!conv.length) {
407
+ return NextResponse.json({ error: 'Conversation not found' }, { status: 404 });
408
+ }
409
+
410
+ const conversation = conv[0];
411
+
412
+ const dbMessages = await db
413
+ .select()
414
+ .from(messages)
415
+ .where(eq(messages.conversationId, conversationId))
416
+ .orderBy(asc(messages.createdAt))
417
+ .limit(1000);
418
+
419
+ const transformedMessages = dbMessages.map(msg => {
420
+ const metadata = msg.metadata as { parts?: any[] } | null;
421
+
422
+ if (metadata?.parts && Array.isArray(metadata.parts)) {
423
+ return {
424
+ id: msg.id,
425
+ role: msg.role,
426
+ content: msg.content,
427
+ created_at: msg.createdAt,
428
+ parts: metadata.parts
429
+ };
430
+ }
431
+
432
+ return {
433
+ id: msg.id,
434
+ role: msg.role,
435
+ content: msg.content,
436
+ created_at: msg.createdAt,
437
+ parts: msg.content ? [{ type: 'text', text: msg.content }] : undefined
438
+ };
439
+ });
440
+
441
+ return NextResponse.json({ conversation, messages: transformedMessages });
442
+ } catch (error) {
443
+ console.error('Error loading conversation:', error);
444
+ return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
445
+ }
446
+ }
334
447
  ```
335
448
 
449
+ </details>
450
+
451
+ <details>
452
+ <summary><strong>app/api/chat/upload/route.ts</strong> - File Upload (Optional)</summary>
453
+
454
+ ```typescript
455
+ import { createClient } from '@supabase/supabase-js';
456
+ import { nanoid } from 'nanoid';
457
+
458
+ export async function POST(req: Request) {
459
+ try {
460
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
461
+ const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
462
+
463
+ // Check for required environment variables
464
+ if (!supabaseUrl || !supabaseServiceKey) {
465
+ console.error('Missing Supabase environment variables');
466
+ return Response.json({
467
+ error: 'File upload is not configured. Please set up Supabase Storage environment variables.'
468
+ }, { status: 503 });
469
+ }
470
+
471
+ const formData = await req.formData();
472
+ const file = formData.get('file') as File;
473
+ const conversationId = formData.get('conversationId') as string;
474
+ const userId = formData.get('userId') as string;
475
+
476
+ if (!file) {
477
+ return Response.json({ error: 'No file provided' }, { status: 400 });
478
+ }
479
+
480
+ if (!userId) {
481
+ return Response.json({ error: 'userId is required' }, { status: 400 });
482
+ }
483
+
484
+ if (!file.type.startsWith('image/')) {
485
+ return Response.json({ error: 'Only image files are supported' }, { status: 400 });
486
+ }
487
+
488
+ if (file.size > 5 * 1024 * 1024) {
489
+ return Response.json({ error: 'File size exceeds 5MB limit' }, { status: 400 });
490
+ }
491
+
492
+ const supabase = createClient(supabaseUrl, supabaseServiceKey);
493
+
494
+ const timestamp = Date.now();
495
+ const randomId = nanoid(8);
496
+ const safeFilename = file.name.replace(/[^a-zA-Z0-9.-]/g, '_');
497
+ const filePath = `${userId}/${conversationId || 'default'}/${timestamp}-${randomId}-${safeFilename}`;
498
+
499
+ const fileBuffer = await file.arrayBuffer();
500
+
501
+ const { error: uploadError } = await supabase.storage
502
+ .from('chat-attachments')
503
+ .upload(filePath, fileBuffer, {
504
+ contentType: file.type,
505
+ upsert: false,
506
+ });
507
+
508
+ if (uploadError) {
509
+ return Response.json({ error: 'Failed to upload file' }, { status: 500 });
510
+ }
511
+
512
+ const { data: urlData } = supabase.storage
513
+ .from('chat-attachments')
514
+ .getPublicUrl(filePath);
515
+
516
+ return Response.json({
517
+ url: urlData.publicUrl,
518
+ filename: file.name,
519
+ mediaType: file.type,
520
+ size: file.size,
521
+ type: 'file',
522
+ });
523
+ } catch (error) {
524
+ return Response.json({ error: 'Internal server error' }, { status: 500 });
525
+ }
526
+ }
527
+ ```
528
+
529
+ </details>
530
+
531
+ ---
532
+
336
533
  ## License
337
534
 
338
535
  MIT