@mordn/chat-widget 0.1.3 → 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,66 +2,210 @@
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)
14
23
  - React 18+
15
24
  - PostgreSQL database (Supabase recommended)
16
25
  - Tailwind CSS v4
17
- - Vercel AI SDK
18
26
 
19
27
  ## Setup
20
28
 
21
29
  ### 1. Environment Variables
22
30
 
23
- Add your database connection string and AI Gateway API key:
31
+ Copy `.env.example` to `.env.local` and fill in your credentials:
24
32
 
25
33
  ```env
34
+ # Database (Required)
26
35
  DATABASE_URL="postgresql://postgres.xxx:[PASSWORD]@aws-0-region.pooler.supabase.com:6543/postgres"
36
+
37
+ # AI Provider (Required)
27
38
  AI_GATEWAY_API_KEY="your-ai-gateway-key"
28
39
  ```
29
40
 
30
41
  ### 2. Database Setup
31
42
 
32
- Install drizzle-kit as a dev dependency:
43
+ Push the schema to your database:
33
44
 
34
45
  ```bash
35
- npm install drizzle-kit --save-dev
46
+ npx drizzle-kit push
36
47
  ```
37
48
 
38
- Create `drizzle.config.ts` in your project root:
49
+ ### 3. Configure Your AI Model
50
+
51
+ Open `app/api/chat/route.ts` and update the config:
39
52
 
40
53
  ```typescript
41
- import 'dotenv/config';
42
- import { defineConfig } from 'drizzle-kit';
43
-
44
- export default defineConfig({
45
- schema: './node_modules/@mordn/chat-widget/dist/schema/index.js',
46
- out: './drizzle',
47
- dialect: 'postgresql',
48
- dbCredentials: {
49
- url: process.env.DATABASE_URL!,
50
- },
51
- });
54
+ const DEVELOPER_CONFIG = {
55
+ model: 'openai/gpt-4o', // Your AI model
56
+ systemPrompt: 'You are a helpful assistant',
57
+ temperature: 0.7,
58
+ };
52
59
  ```
53
60
 
54
- Run the migration to create tables:
61
+ ### 4. Add the Widget
55
62
 
56
- ```bash
57
- npx drizzle-kit push
63
+ ```tsx
64
+ 'use client';
65
+
66
+ import { ChatWidget } from '@mordn/chat-widget';
67
+ import '@mordn/chat-widget/styles.css';
68
+
69
+ export default function Page() {
70
+ return (
71
+ <ChatWidget
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
+ ]}
96
+ />
97
+ );
98
+ }
99
+ ```
100
+
101
+ ---
102
+
103
+ ## File Uploads (Optional)
104
+
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
+ />
112
+ ```
113
+
114
+ ### 1. Create Storage Bucket
115
+
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)
120
+
121
+ ### 2. Add Environment Variables
122
+
123
+ ```env
124
+ NEXT_PUBLIC_SUPABASE_URL="https://your-project.supabase.co"
125
+ SUPABASE_SERVICE_ROLE_KEY="your-service-role-key"
126
+ ```
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
+
136
+ ## Props
137
+
138
+ | Prop | Type | Default | Description |
139
+ |------|------|---------|-------------|
140
+ | `userId` | `string` | **required** | User identifier for storing conversations |
141
+ | `conversationId` | `string` | - | Load a specific conversation |
142
+ | `initialMessages` | `array` | - | Pre-fill the chat with messages |
143
+ | `className` | `string` | - | Additional CSS classes |
144
+ | `theme` | `ThemeConfig` | - | Theme configuration |
145
+ | `features` | `FeatureConfig` | - | Feature toggles |
146
+ | `display` | `DisplayConfig` | - | Display options |
147
+
148
+ ### ThemeConfig
149
+
150
+ ```typescript
151
+ {
152
+ mode?: 'light' | 'dark';
153
+ }
58
154
  ```
59
155
 
60
- ### 3. API Routes
156
+ ### FeatureConfig
61
157
 
62
- Create the following API routes in your Next.js app:
158
+ ```typescript
159
+ {
160
+ fileUpload?: boolean; // Enable file attachments (default: false)
161
+ webSearch?: boolean; // Enable web search toggle
162
+ }
163
+ ```
63
164
 
64
- #### `app/api/chat/route.ts` - Main Chat Endpoint
165
+ ### DisplayConfig
166
+
167
+ ```typescript
168
+ {
169
+ width?: string; // e.g., '400px' or '30vw'
170
+ defaultOpen?: boolean; // Start with chat open (default: false)
171
+ showToggleButton?: boolean; // Show FAB toggle button (default: true)
172
+ toggleButtonPosition?: {
173
+ bottom?: string;
174
+ right?: string;
175
+ };
176
+ }
177
+ ```
178
+
179
+ ---
180
+
181
+ ## Exports
182
+
183
+ ```typescript
184
+ // Main component
185
+ import { ChatWidget } from '@mordn/chat-widget';
186
+ import '@mordn/chat-widget/styles.css';
187
+
188
+ // Database utilities (server-side only)
189
+ import {
190
+ db,
191
+ conversations,
192
+ messages,
193
+ createChat,
194
+ loadChat,
195
+ saveChat,
196
+ getConversations,
197
+ deleteConversation,
198
+ updateConversationTitle,
199
+ eq, and, or, desc, asc, sql
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>
65
209
 
66
210
  ```typescript
67
211
  import { saveChat, updateConversationTitle, db, conversations, messages, eq } from '@mordn/chat-widget/api';
@@ -69,123 +213,128 @@ import { convertToModelMessages, streamText, UIMessage } from 'ai';
69
213
 
70
214
  export const maxDuration = 30;
71
215
 
72
- // DEVELOPER CONFIG - Set these for your app
73
216
  const DEVELOPER_CONFIG = {
74
- model: 'openai/gpt-4o', // Your AI model (provider/model format)
217
+ model: 'openai/gpt-4o',
75
218
  systemPrompt: 'You are a helpful assistant',
76
219
  temperature: 0.7,
77
220
  };
78
221
 
79
222
  export async function POST(req: Request) {
80
- const body = await req.json();
81
- const userId = req.headers.get('X-User-Id');
82
-
83
- if (!userId) {
84
- return new Response('userId is required in X-User-Id header', { status: 400 });
85
- }
86
-
87
- const chatMessages: UIMessage[] = body.messages || [];
88
- const id: string = body.id || 'temp-id';
89
-
90
- const { model, systemPrompt, temperature } = DEVELOPER_CONFIG;
91
-
92
- // Check if conversation exists, create if not
93
- const existingConv = await db
94
- .select({ id: conversations.id })
95
- .from(conversations)
96
- .where(eq(conversations.id, id))
97
- .limit(1);
223
+ try {
224
+ const body = await req.json();
225
+ const userId = req.headers.get('X-User-Id');
98
226
 
99
- if (!existingConv.length) {
100
- await db.insert(conversations).values({
101
- id,
102
- userId,
103
- title: 'New Chat',
104
- metadata: {},
105
- });
106
- }
227
+ if (!userId) {
228
+ return new Response('userId is required in X-User-Id header', { status: 400 });
229
+ }
107
230
 
108
- // Save the new user message
109
- const userMessages = chatMessages.filter(msg => msg.role === 'user');
110
- if (userMessages.length > 0) {
111
- const newUserMessage = userMessages[userMessages.length - 1];
112
- const textPart = newUserMessage.parts?.find(p => p.type === 'text') as { text: string } | undefined;
113
- const fileParts = newUserMessage.parts?.filter(p => p.type === 'file') || [];
231
+ const chatMessages: UIMessage[] = body.messages || [];
232
+ const id: string = body.id || 'temp-id';
233
+ const { model, systemPrompt, temperature } = DEVELOPER_CONFIG;
114
234
 
115
- const existingMsg = await db
116
- .select({ id: messages.id })
117
- .from(messages)
118
- .where(eq(messages.id, newUserMessage.id))
235
+ const existingConv = await db
236
+ .select({ id: conversations.id })
237
+ .from(conversations)
238
+ .where(eq(conversations.id, id))
119
239
  .limit(1);
120
240
 
121
- if (!existingMsg.length) {
122
- await db.insert(messages).values({
123
- id: newUserMessage.id,
124
- conversationId: id,
125
- role: newUserMessage.role,
126
- content: textPart?.text || '',
127
- files: fileParts,
128
- model: model,
129
- metadata: { parts: newUserMessage.parts || [] },
241
+ if (!existingConv.length) {
242
+ await db.insert(conversations).values({
243
+ id,
244
+ userId,
245
+ title: 'New Chat',
246
+ metadata: {},
130
247
  });
131
248
  }
132
249
 
133
- // Update conversation title if needed
134
- if (textPart?.text) {
135
- const conv = await db
136
- .select({ title: conversations.title })
137
- .from(conversations)
138
- .where(eq(conversations.id, id))
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))
139
260
  .limit(1);
140
261
 
141
- if (conv[0]?.title === 'New Chat') {
142
- await updateConversationTitle(id, textPart.text.slice(0, 100));
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
+ });
143
272
  }
144
- }
145
- }
146
273
 
147
- // Transform messages for AI (handle images)
148
- const transformedMessages = chatMessages.map(msg => {
149
- if (msg.role === 'user' && msg.parts) {
150
- const textPart = msg.parts.find(p => p.type === 'text');
151
- const fileParts = msg.parts.filter(p => p.type === 'file');
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);
152
280
 
153
- if (fileParts.length > 0) {
154
- const content = [];
155
- if (textPart && 'text' in textPart) {
156
- content.push({ type: 'text', text: textPart.text });
157
- }
158
- for (const file of fileParts) {
159
- if ('mediaType' in file && file.mediaType?.startsWith('image/')) {
160
- content.push({ type: 'image', image: (file as any).url });
161
- }
281
+ if (conv[0]?.title === 'New Chat') {
282
+ await updateConversationTitle(id, textPart.text.slice(0, 100));
162
283
  }
163
- return { ...msg, content };
164
284
  }
165
285
  }
166
- return msg;
167
- });
168
-
169
- const result = streamText({
170
- model: model,
171
- messages: convertToModelMessages(transformedMessages),
172
- system: systemPrompt,
173
- temperature: temperature,
174
- });
175
-
176
- return result.toUIMessageStreamResponse({
177
- sendSources: true,
178
- sendReasoning: true,
179
- onFinish: ({ messages: finalMessages }) => {
180
- if (finalMessages.length > 0) {
181
- saveChat({ chatId: id, messages: finalMessages, model, userId });
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
+ }
182
304
  }
183
- },
184
- });
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
+ }
185
331
  }
186
332
  ```
187
333
 
188
- #### `app/api/chat/history/route.ts` - List Conversations
334
+ </details>
335
+
336
+ <details>
337
+ <summary><strong>app/api/chat/history/route.ts</strong> - List Conversations</summary>
189
338
 
190
339
  ```typescript
191
340
  import { NextResponse } from 'next/server';
@@ -219,7 +368,10 @@ export async function GET(request: Request) {
219
368
  }
220
369
  ```
221
370
 
222
- #### `app/api/chat/history/[conversationId]/route.ts` - Get Conversation Messages
371
+ </details>
372
+
373
+ <details>
374
+ <summary><strong>app/api/chat/history/[conversationId]/route.ts</strong> - Get Conversation</summary>
223
375
 
224
376
  ```typescript
225
377
  import { NextResponse } from 'next/server';
@@ -238,7 +390,6 @@ export async function GET(
238
390
  return NextResponse.json({ error: 'userId is required' }, { status: 400 });
239
391
  }
240
392
 
241
- // Verify the conversation belongs to the user
242
393
  const conv = await db
243
394
  .select({
244
395
  id: conversations.id,
@@ -295,17 +446,28 @@ export async function GET(
295
446
  }
296
447
  ```
297
448
 
298
- #### `app/api/chat/upload/route.ts` - File Upload (Optional)
449
+ </details>
450
+
451
+ <details>
452
+ <summary><strong>app/api/chat/upload/route.ts</strong> - File Upload (Optional)</summary>
299
453
 
300
454
  ```typescript
301
455
  import { createClient } from '@supabase/supabase-js';
302
456
  import { nanoid } from 'nanoid';
303
457
 
304
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
305
- const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
306
-
307
458
  export async function POST(req: Request) {
308
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
+
309
471
  const formData = await req.formData();
310
472
  const file = formData.get('file') as File;
311
473
  const conversationId = formData.get('conversationId') as string;
@@ -319,12 +481,10 @@ export async function POST(req: Request) {
319
481
  return Response.json({ error: 'userId is required' }, { status: 400 });
320
482
  }
321
483
 
322
- // Only images supported
323
484
  if (!file.type.startsWith('image/')) {
324
485
  return Response.json({ error: 'Only image files are supported' }, { status: 400 });
325
486
  }
326
487
 
327
- // 5MB limit
328
488
  if (file.size > 5 * 1024 * 1024) {
329
489
  return Response.json({ error: 'File size exceeds 5MB limit' }, { status: 400 });
330
490
  }
@@ -366,129 +526,9 @@ export async function POST(req: Request) {
366
526
  }
367
527
  ```
368
528
 
369
- ### 4. Use the Widget
370
-
371
- **For sitewide usage**, create a provider component:
372
-
373
- ```tsx
374
- // components/chat-provider.tsx
375
- 'use client';
529
+ </details>
376
530
 
377
- import { ChatWidget } from '@mordn/chat-widget';
378
- import '@mordn/chat-widget/styles.css';
379
- import { useAuth } from '@/contexts/auth-context'; // Your auth hook
380
-
381
- export function ChatProvider() {
382
- const { user, loading } = useAuth();
383
-
384
- if (loading || !user) {
385
- return null;
386
- }
387
-
388
- return (
389
- <ChatWidget
390
- userId={user.id}
391
- theme={{ mode: 'dark' }}
392
- />
393
- );
394
- }
395
- ```
396
-
397
- Add to your root layout:
398
-
399
- ```tsx
400
- // app/layout.tsx
401
- import { ChatProvider } from '@/components/chat-provider';
402
-
403
- export default function RootLayout({ children }) {
404
- return (
405
- <html>
406
- <body>
407
- {children}
408
- <ChatProvider />
409
- </body>
410
- </html>
411
- );
412
- }
413
- ```
414
-
415
- ## Props
416
-
417
- | Prop | Type | Default | Description |
418
- |------|------|---------|-------------|
419
- | `userId` | `string` | **required** | User identifier for storing conversations |
420
- | `conversationId` | `string` | - | Load a specific conversation |
421
- | `initialMessages` | `array` | - | Pre-fill the chat with messages |
422
- | `className` | `string` | - | Additional CSS classes |
423
- | `theme` | `ThemeConfig` | - | Theme configuration |
424
- | `features` | `FeatureConfig` | - | Feature toggles |
425
- | `display` | `DisplayConfig` | - | Display options |
426
-
427
- ### ThemeConfig
428
-
429
- ```typescript
430
- {
431
- mode?: 'light' | 'dark';
432
- primaryColor?: string;
433
- backgroundColor?: string;
434
- textColor?: string;
435
- }
436
- ```
437
-
438
- ### FeatureConfig
439
-
440
- ```typescript
441
- {
442
- fileUpload?: boolean; // Enable file attachments (default: true)
443
- webSearch?: boolean; // Enable web search toggle
444
- }
445
- ```
446
-
447
- ### DisplayConfig
448
-
449
- ```typescript
450
- {
451
- width?: string; // e.g., '400px' or '30vw' (default: '30vw')
452
- defaultOpen?: boolean; // Start with chat open (default: false)
453
- showToggleButton?: boolean; // Show FAB toggle button (default: true)
454
- toggleButtonPosition?: {
455
- bottom?: string;
456
- right?: string;
457
- };
458
- }
459
- ```
460
-
461
- ## Exports
462
-
463
- ```typescript
464
- // Main component
465
- import { ChatWidget } from '@mordn/chat-widget';
466
- import '@mordn/chat-widget/styles.css';
467
-
468
- // Database utilities (server-side only)
469
- import {
470
- db,
471
- conversations,
472
- messages,
473
- createChat,
474
- loadChat,
475
- saveChat,
476
- getConversations,
477
- deleteConversation,
478
- updateConversationTitle,
479
- eq, and, or, desc, asc, sql
480
- } from '@mordn/chat-widget/api';
481
-
482
- // Types
483
- import type {
484
- ChatWidgetConfig,
485
- ThemeConfig,
486
- FeatureConfig,
487
- DisplayConfig,
488
- Conversation,
489
- Message
490
- } from '@mordn/chat-widget';
491
- ```
531
+ ---
492
532
 
493
533
  ## License
494
534