@mordn/chat-widget 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +80 -430
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -39,11 +39,18 @@ The wizard creates exactly four files:
39
39
 
40
40
  ## Requirements
41
41
 
42
- - Next.js 14+ (App Router)
43
- - React 18+
44
- - PostgreSQL database (Supabase recommended)
45
- - Tailwind CSS v4
46
- - `ai` v5 or v6 (peer dependency)
42
+ Peer dependencies (you provide these in your app):
43
+
44
+ - **Next.js** 14, 15, or 16 (App Router)
45
+ - **React** 18 or 19
46
+ - **`ai`** v5 or v6 (Vercel AI SDK)
47
+ - **`drizzle-orm`** ^0.44 and **`postgres`** ^3.4 — only if you use the default
48
+ Drizzle store (skip if you bring your own `ChatStore`)
49
+ - A **PostgreSQL** database (Supabase recommended) — for the default store
50
+ - An AI provider package for your model, e.g. **`@ai-sdk/anthropic`**
51
+
52
+ Styling ships pre-scoped in `@mordn/chat-widget/styles.css` — you do **not**
53
+ need Tailwind in your app to use the widget.
47
54
 
48
55
  ## Setup
49
56
 
@@ -93,7 +100,7 @@ implement the `ChatStore` / `StorageAdapter` interfaces from
93
100
  `@mordn/chat-widget/server`. The hosted defaults and your own implementations
94
101
  are interchangeable — same handler, same security.
95
102
 
96
- ### 4. Add the Widget
103
+ ## Mount the widget (client)
97
104
 
98
105
  ```tsx
99
106
  'use client';
@@ -101,470 +108,113 @@ are interchangeable — same handler, same security.
101
108
  import { ChatWidget } from '@mordn/chat-widget';
102
109
  import '@mordn/chat-widget/styles.css';
103
110
 
104
- export default function Page() {
111
+ export default function Assistant({ userId }: { userId: string }) {
105
112
  return (
106
113
  <ChatWidget
107
- // Required
108
- userId="user-123"
109
-
110
- // Theme: 'light' | 'dark'
111
- theme={{ mode: 'light' }}
112
-
113
- // Feature toggles
114
- features={{
115
- fileUpload: false, // Requires Supabase Storage setup
116
- }}
117
-
118
- // Display options
119
- display={{
120
- defaultOpen: false, // Start with chat open
121
- size: 'default', // 'compact' | 'default' | 'large' | 'full'
122
- resizable: true, // Allow resizing
123
- showToggleButton: true, // Show FAB toggle button
124
- }}
125
-
126
- // Starter prompts shown on empty chat
114
+ userId={userId} // your app's user id (for the client)
115
+ theme={{ mode: 'light' }} // 'light' | 'dark'
116
+ features={{ fileUpload: true }} // needs `storage` configured on the handler
117
+ display={{ layout: 'popup', size: 'default', resizable: true }}
127
118
  starterPrompts={[
128
- { title: "What can you help me with?" },
129
- { title: "How do I get started?" },
119
+ { title: 'What can you help me with?' },
120
+ { title: 'How do I get started?' },
130
121
  ]}
131
122
  />
132
123
  );
133
124
  }
134
125
  ```
135
126
 
127
+ > The widget sends `userId` as an `X-User-Id` header for convenience, but the
128
+ > **server ignores it for authorization** — your `getChatUserId` is the only
129
+ > source of identity. See the security note above.
130
+
136
131
  ---
137
132
 
138
- ## File Uploads (Optional)
133
+ ## Bring your own database / storage
139
134
 
140
- To enable image attachments, you need Supabase Storage and to enable the feature:
135
+ The default `createDrizzleChatStore()` and `createSupabaseStorage()` are just
136
+ implementations of two interfaces. To use your own database, ORM, or object
137
+ store, implement the interface and pass it instead — same handler, same
138
+ security guarantees:
141
139
 
142
- ```tsx
143
- <ChatWidget
144
- userId="user-123"
145
- features={{ fileUpload: true }}
146
- />
140
+ ```ts
141
+ import type { ChatStore, StorageAdapter } from '@mordn/chat-widget/server';
142
+
143
+ const myStore = (userId: string): ChatStore => ({ /* ... */ });
144
+ const myStorage = (userId: string): StorageAdapter => ({ /* ... */ });
145
+
146
+ createChatHandler({ getUserId, model, store: myStore, storage: myStorage });
147
147
  ```
148
148
 
149
- ### 1. Create Storage Bucket
149
+ Both factories are constructed per request with the **server-verified** user id,
150
+ so a store/adapter instance can only ever touch that user's data — cross-user
151
+ access (IDOR) is unrepresentable. See `SECURITY.md` for the full model.
150
152
 
151
- 1. Go to your [Supabase Dashboard](https://supabase.com/dashboard)
152
- 2. Navigate to **Storage** → **New Bucket**
153
- 3. Create a bucket named `chat-attachments`
154
- 4. Set it to **Public** (or configure RLS policies for private access)
153
+ ### File uploads & the storage bucket
155
154
 
156
- ### 2. Add Environment Variables
155
+ `createSupabaseStorage()` expects a **private** `chat-attachments` bucket and
156
+ the service-role key:
157
157
 
158
158
  ```env
159
159
  NEXT_PUBLIC_SUPABASE_URL="https://your-project.supabase.co"
160
- SUPABASE_SERVICE_ROLE_KEY="your-service-role-key"
160
+ SUPABASE_SERVICE_ROLE_KEY="your-service-role-key" # server-only, never NEXT_PUBLIC
161
161
  ```
162
162
 
163
- You can find these in Supabase Dashboard **Settings** **API**
164
-
165
- ### 3. Create Upload Route
166
-
167
- When running `npx @mordn/chat-widget`, select **Yes** when asked about the upload route.
163
+ Create the bucket as **Private** the adapter never relies on public read; it
164
+ mints short-lived signed URLs and re-signs them on history load. A public
165
+ bucket would defeat the security model. Omit the `storage` option entirely to
166
+ disable uploads.
168
167
 
169
168
  ---
170
169
 
171
- ## Props
172
-
173
- | Prop | Type | Default | Description |
174
- |------|------|---------|-------------|
175
- | `userId` | `string` | **required** | User identifier for storing conversations |
176
- | `conversationId` | `string` | - | Load a specific conversation |
177
- | `initialMessages` | `array` | - | Pre-fill the chat with messages |
178
- | `className` | `string` | - | Additional CSS classes |
179
- | `theme` | `ThemeConfig` | - | Theme configuration |
180
- | `features` | `FeatureConfig` | - | Feature toggles |
181
- | `display` | `DisplayConfig` | - | Display options |
182
-
183
- ### ThemeConfig
184
-
185
- ```typescript
186
- {
187
- mode?: 'light' | 'dark';
188
- }
189
- ```
190
-
191
- ### FeatureConfig
192
-
193
- ```typescript
194
- {
195
- fileUpload?: boolean; // Enable file attachments (default: false)
196
- webSearch?: boolean; // Enable web search toggle
197
- }
198
- ```
199
-
200
- ### DisplayConfig
201
-
202
- ```typescript
203
- {
204
- width?: string; // e.g., '400px' or '30vw'
205
- defaultOpen?: boolean; // Start with chat open (default: false)
206
- showToggleButton?: boolean; // Show FAB toggle button (default: true)
207
- toggleButtonPosition?: {
208
- bottom?: string;
209
- right?: string;
210
- };
211
- }
212
- ```
170
+ ## Handler options (`createChatHandler`)
171
+
172
+ | Option | Required | Description |
173
+ |--------|----------|-------------|
174
+ | `getUserId(req)` | **yes** | Return the user id from your verified server session, or `null` (→ 401). The security boundary. |
175
+ | `model` | yes | A `LanguageModel`, or `(ctx) => LanguageModel` for per-user selection. |
176
+ | `store` | no* | A `ChatStoreFactory`. Use `createDrizzleChatStore()` or your own. *Required until a hosted default ships.* |
177
+ | `storage` | no | A `StorageAdapterFactory` (e.g. `createSupabaseStorage()`). Omit to disable uploads. |
178
+ | `buildTools(ctx)` | no | `async (ctx) => ({ tools, cleanup? })`. `cleanup` is called exactly once per turn (finish/error/abort) — use it to close per-request resources like an MCP client. |
179
+ | `buildSystemPrompt(ctx)` | no | Returns the system prompt; receives the request context to personalise. |
180
+ | `transformMessages(msgs, ctx)` | no | Last-chance rewrite of model messages (e.g. image part handling). |
181
+ | `onChatFinish(info)` | no | Post-persist hook for telemetry/usage. |
182
+ | `onError(err)` | no | Map a stream error to the user-facing string. |
183
+ | `stopWhen` | no | AI SDK stop condition for tool-call loops (default: bounded step count). |
184
+ | `upload` | no | `{ allowedMediaTypes?, maxBytes? }` — server-side upload policy. |
185
+ | `maxHistoryMessages` | no | Sliding-window size sent to the model (default 30). |
186
+
187
+ The widget exposes only these seams. Ownership checks, idempotent persistence,
188
+ history pagination, attachment re-signing, and socket teardown are owned by the
189
+ handler and are not configurable — getting them wrong is a bug, not a setting.
213
190
 
214
191
  ---
215
192
 
216
193
  ## Exports
217
194
 
218
- ```typescript
219
- // Main component
195
+ ```ts
196
+ // Client component + styles
220
197
  import { ChatWidget } from '@mordn/chat-widget';
221
198
  import '@mordn/chat-widget/styles.css';
222
199
 
223
- // Database utilities (server-side only)
200
+ // Server handler + the pluggable contracts (server-only)
224
201
  import {
225
- db,
226
- conversations,
227
- messages,
228
- createChat,
229
- loadChat,
230
- saveChat,
231
- getConversations,
232
- deleteConversation,
233
- updateConversationTitle,
234
- eq, and, or, desc, asc, sql
235
- } from '@mordn/chat-widget/api';
236
- ```
202
+ createChatHandler,
203
+ type ChatStore, type ChatStoreFactory,
204
+ type StorageAdapter, type StorageAdapterFactory,
205
+ ConversationOwnershipError,
206
+ } from '@mordn/chat-widget/server';
237
207
 
238
- ---
208
+ // Default Postgres/Drizzle store (server-only)
209
+ import { createDrizzleChatStore, schema } from '@mordn/chat-widget/server/drizzle';
239
210
 
240
- ## Generated Files Reference
241
-
242
- <details>
243
- <summary><strong>app/api/chat/route.ts</strong> - Main Chat Endpoint</summary>
244
-
245
- ```typescript
246
- import { saveChat, updateConversationTitle, db, conversations, messages, eq } from '@mordn/chat-widget/api';
247
- import { convertToModelMessages, streamText, UIMessage } from 'ai';
248
-
249
- export const maxDuration = 30;
250
-
251
- const DEVELOPER_CONFIG = {
252
- model: 'openai/gpt-4o',
253
- systemPrompt: 'You are a helpful assistant',
254
- temperature: 0.7,
255
- };
256
-
257
- export async function POST(req: Request) {
258
- try {
259
- const body = await req.json();
260
- const userId = req.headers.get('X-User-Id');
261
-
262
- if (!userId) {
263
- return new Response('userId is required in X-User-Id header', { status: 400 });
264
- }
265
-
266
- const chatMessages: UIMessage[] = body.messages || [];
267
- const id: string = body.id || 'temp-id';
268
- const { model, systemPrompt, temperature } = DEVELOPER_CONFIG;
269
-
270
- const existingConv = await db
271
- .select({ id: conversations.id })
272
- .from(conversations)
273
- .where(eq(conversations.id, id))
274
- .limit(1);
275
-
276
- if (!existingConv.length) {
277
- await db.insert(conversations).values({
278
- id,
279
- userId,
280
- title: 'New Chat',
281
- metadata: {},
282
- });
283
- }
284
-
285
- const userMessages = chatMessages.filter(msg => msg.role === 'user');
286
- if (userMessages.length > 0) {
287
- const newUserMessage = userMessages[userMessages.length - 1];
288
- const textPart = newUserMessage.parts?.find(p => p.type === 'text') as { text: string } | undefined;
289
- const fileParts = newUserMessage.parts?.filter(p => p.type === 'file') || [];
290
-
291
- const existingMsg = await db
292
- .select({ id: messages.id })
293
- .from(messages)
294
- .where(eq(messages.id, newUserMessage.id))
295
- .limit(1);
296
-
297
- if (!existingMsg.length) {
298
- await db.insert(messages).values({
299
- id: newUserMessage.id,
300
- conversationId: id,
301
- role: newUserMessage.role,
302
- content: textPart?.text || '',
303
- files: fileParts,
304
- model: model,
305
- metadata: { parts: newUserMessage.parts || [] },
306
- });
307
- }
308
-
309
- if (textPart?.text) {
310
- const conv = await db
311
- .select({ title: conversations.title })
312
- .from(conversations)
313
- .where(eq(conversations.id, id))
314
- .limit(1);
315
-
316
- if (conv[0]?.title === 'New Chat') {
317
- await updateConversationTitle(id, textPart.text.slice(0, 100));
318
- }
319
- }
320
- }
321
-
322
- const transformedMessages = chatMessages.map(msg => {
323
- if (msg.role === 'user' && msg.parts) {
324
- const textPart = msg.parts.find(p => p.type === 'text');
325
- const fileParts = msg.parts.filter(p => p.type === 'file');
326
-
327
- if (fileParts.length > 0) {
328
- const content: any[] = [];
329
- if (textPart && 'text' in textPart) {
330
- content.push({ type: 'text', text: textPart.text });
331
- }
332
- for (const file of fileParts) {
333
- if ('mediaType' in file && (file as any).mediaType?.startsWith('image/')) {
334
- content.push({ type: 'image', image: (file as any).url });
335
- }
336
- }
337
- return { ...msg, content };
338
- }
339
- }
340
- return msg;
341
- });
342
-
343
- const result = streamText({
344
- model: model,
345
- messages: convertToModelMessages(transformedMessages),
346
- system: systemPrompt,
347
- temperature: temperature,
348
- });
349
-
350
- return result.toUIMessageStreamResponse({
351
- sendSources: true,
352
- sendReasoning: true,
353
- onFinish: ({ messages: finalMessages }) => {
354
- if (finalMessages.length > 0) {
355
- saveChat({ chatId: id, messages: finalMessages, model, userId });
356
- }
357
- },
358
- });
359
- } catch (error) {
360
- console.error('Chat API error:', error);
361
- return new Response(JSON.stringify({ error: 'Internal server error' }), {
362
- status: 500,
363
- headers: { 'Content-Type': 'application/json' },
364
- });
365
- }
366
- }
211
+ // Default Supabase storage adapter (server-only)
212
+ import { createSupabaseStorage } from '@mordn/chat-widget/server/supabase';
367
213
  ```
368
214
 
369
- </details>
370
-
371
- <details>
372
- <summary><strong>app/api/chat/history/route.ts</strong> - List Conversations</summary>
373
-
374
- ```typescript
375
- import { NextResponse } from 'next/server';
376
- import { getConversations } from '@mordn/chat-widget/api';
377
-
378
- export async function GET(request: Request) {
379
- try {
380
- const url = new URL(request.url);
381
- const userId = url.searchParams.get('userId') || request.headers.get('X-User-Id');
382
-
383
- if (!userId) {
384
- return NextResponse.json({ error: 'userId is required' }, { status: 400 });
385
- }
386
-
387
- const conversationsData = await getConversations(userId);
388
-
389
- const conversations = conversationsData.map(conv => ({
390
- id: conv.id,
391
- title: conv.title,
392
- created_at: conv.createdAt,
393
- updated_at: conv.updatedAt,
394
- metadata: conv.metadata,
395
- message_count: conv.messageCount,
396
- }));
397
-
398
- return NextResponse.json({ conversations });
399
- } catch (error) {
400
- console.error('Error in chat history API:', error);
401
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
402
- }
403
- }
404
- ```
405
-
406
- </details>
407
-
408
- <details>
409
- <summary><strong>app/api/chat/history/[conversationId]/route.ts</strong> - Get Conversation</summary>
410
-
411
- ```typescript
412
- import { NextResponse } from 'next/server';
413
- import { db, conversations, messages, eq, and, asc } from '@mordn/chat-widget/api';
414
-
415
- export async function GET(
416
- request: Request,
417
- { params }: { params: Promise<{ conversationId: string }> }
418
- ) {
419
- try {
420
- const { conversationId } = await params;
421
- const url = new URL(request.url);
422
- const userId = url.searchParams.get('userId') || request.headers.get('X-User-Id');
423
-
424
- if (!userId) {
425
- return NextResponse.json({ error: 'userId is required' }, { status: 400 });
426
- }
427
-
428
- const conv = await db
429
- .select({
430
- id: conversations.id,
431
- title: conversations.title,
432
- metadata: conversations.metadata,
433
- })
434
- .from(conversations)
435
- .where(and(
436
- eq(conversations.id, conversationId),
437
- eq(conversations.userId, userId)
438
- ))
439
- .limit(1);
440
-
441
- if (!conv.length) {
442
- return NextResponse.json({ error: 'Conversation not found' }, { status: 404 });
443
- }
444
-
445
- const conversation = conv[0];
446
-
447
- const dbMessages = await db
448
- .select()
449
- .from(messages)
450
- .where(eq(messages.conversationId, conversationId))
451
- .orderBy(asc(messages.createdAt))
452
- .limit(1000);
453
-
454
- const transformedMessages = dbMessages.map(msg => {
455
- const metadata = msg.metadata as { parts?: any[] } | null;
456
-
457
- if (metadata?.parts && Array.isArray(metadata.parts)) {
458
- return {
459
- id: msg.id,
460
- role: msg.role,
461
- content: msg.content,
462
- created_at: msg.createdAt,
463
- parts: metadata.parts
464
- };
465
- }
466
-
467
- return {
468
- id: msg.id,
469
- role: msg.role,
470
- content: msg.content,
471
- created_at: msg.createdAt,
472
- parts: msg.content ? [{ type: 'text', text: msg.content }] : undefined
473
- };
474
- });
475
-
476
- return NextResponse.json({ conversation, messages: transformedMessages });
477
- } catch (error) {
478
- console.error('Error loading conversation:', error);
479
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
480
- }
481
- }
482
- ```
483
-
484
- </details>
485
-
486
- <details>
487
- <summary><strong>app/api/chat/upload/route.ts</strong> - File Upload (Optional)</summary>
488
-
489
- ```typescript
490
- import { createClient } from '@supabase/supabase-js';
491
- import { nanoid } from 'nanoid';
492
-
493
- export async function POST(req: Request) {
494
- try {
495
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
496
- const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
497
-
498
- // Check for required environment variables
499
- if (!supabaseUrl || !supabaseServiceKey) {
500
- console.error('Missing Supabase environment variables');
501
- return Response.json({
502
- error: 'File upload is not configured. Please set up Supabase Storage environment variables.'
503
- }, { status: 503 });
504
- }
505
-
506
- const formData = await req.formData();
507
- const file = formData.get('file') as File;
508
- const conversationId = formData.get('conversationId') as string;
509
- const userId = formData.get('userId') as string;
510
-
511
- if (!file) {
512
- return Response.json({ error: 'No file provided' }, { status: 400 });
513
- }
514
-
515
- if (!userId) {
516
- return Response.json({ error: 'userId is required' }, { status: 400 });
517
- }
518
-
519
- if (!file.type.startsWith('image/')) {
520
- return Response.json({ error: 'Only image files are supported' }, { status: 400 });
521
- }
522
-
523
- if (file.size > 5 * 1024 * 1024) {
524
- return Response.json({ error: 'File size exceeds 5MB limit' }, { status: 400 });
525
- }
526
-
527
- const supabase = createClient(supabaseUrl, supabaseServiceKey);
528
-
529
- const timestamp = Date.now();
530
- const randomId = nanoid(8);
531
- const safeFilename = file.name.replace(/[^a-zA-Z0-9.-]/g, '_');
532
- const filePath = `${userId}/${conversationId || 'default'}/${timestamp}-${randomId}-${safeFilename}`;
533
-
534
- const fileBuffer = await file.arrayBuffer();
535
-
536
- const { error: uploadError } = await supabase.storage
537
- .from('chat-attachments')
538
- .upload(filePath, fileBuffer, {
539
- contentType: file.type,
540
- upsert: false,
541
- });
542
-
543
- if (uploadError) {
544
- return Response.json({ error: 'Failed to upload file' }, { status: 500 });
545
- }
546
-
547
- const { data: urlData } = supabase.storage
548
- .from('chat-attachments')
549
- .getPublicUrl(filePath);
550
-
551
- return Response.json({
552
- url: urlData.publicUrl,
553
- filename: file.name,
554
- mediaType: file.type,
555
- size: file.size,
556
- type: 'file',
557
- });
558
- } catch (error) {
559
- return Response.json({ error: 'Internal server error' }, { status: 500 });
560
- }
561
- }
562
- ```
563
-
564
- </details>
565
-
566
215
  ---
567
216
 
568
217
  ## License
569
218
 
570
219
  MIT
220
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mordn/chat-widget",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "A customizable AI chat widget for React applications",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",