@mordn/chat-widget 0.7.1 → 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.
- package/README.md +137 -452
- package/SECURITY.md +101 -0
- package/dist/chat-store-DERCPwhl.d.mts +278 -0
- package/dist/chat-store-DERCPwhl.d.ts +278 -0
- package/dist/cli/init.js +111 -346
- package/dist/server/drizzle/index.d.mts +340 -0
- package/dist/server/drizzle/index.d.ts +340 -0
- package/dist/server/drizzle/index.js +238 -0
- package/dist/server/drizzle/index.js.map +1 -0
- package/dist/server/drizzle/index.mjs +207 -0
- package/dist/server/drizzle/index.mjs.map +1 -0
- package/dist/server/index.d.mts +217 -0
- package/dist/server/index.d.ts +217 -0
- package/dist/server/index.js +370 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/index.mjs +349 -0
- package/dist/server/index.mjs.map +1 -0
- package/dist/server/supabase/index.d.mts +50 -0
- package/dist/server/supabase/index.d.ts +50 -0
- package/dist/server/supabase/index.js +111 -0
- package/dist/server/supabase/index.js.map +1 -0
- package/dist/server/supabase/index.mjs +86 -0
- package/dist/server/supabase/index.mjs.map +1 -0
- package/dist/storage-adapter-DD8uqiAP.d.mts +126 -0
- package/dist/storage-adapter-DD8uqiAP.d.ts +126 -0
- package/dist/styles.css +1 -1
- package/package.json +20 -4
package/README.md
CHANGED
|
@@ -1,64 +1,106 @@
|
|
|
1
1
|
# @mordn/chat-widget
|
|
2
2
|
|
|
3
|
-
A customizable AI chat widget for React/Next.js
|
|
3
|
+
A customizable, **secure-by-default** AI chat widget for React/Next.js apps,
|
|
4
|
+
with conversation persistence and attachments handled for you.
|
|
5
|
+
|
|
6
|
+
The widget owns the hard, dangerous-to-get-wrong backend plumbing — conversation
|
|
7
|
+
ownership, idempotent persistence, history, private attachments, streaming —
|
|
8
|
+
behind one mounted handler. You supply the three things that are genuinely
|
|
9
|
+
yours: **who the user is** (auth), **which model**, and **which tools**.
|
|
10
|
+
|
|
11
|
+
> ## ⚠️ Security: you establish identity on the server
|
|
12
|
+
>
|
|
13
|
+
> The widget sends an `X-User-Id` header, but **it is not an authentication
|
|
14
|
+
> boundary** — the browser controls it. You must implement `getChatUserId(req)`
|
|
15
|
+
> to return the user id from your **verified server session** (Clerk, NextAuth,
|
|
16
|
+
> Supabase Auth, …). The scaffold's stub **throws until you do this**, so a
|
|
17
|
+
> fresh install is never silently insecure.
|
|
18
|
+
>
|
|
19
|
+
> Trusting a client-supplied id is the IDOR bug that lets one user read another
|
|
20
|
+
> user's chats. The package is designed so this is *unrepresentable* once you
|
|
21
|
+
> wire up `getChatUserId`. **Read [SECURITY.md](./SECURITY.md).**
|
|
4
22
|
|
|
5
23
|
## Quick Start
|
|
6
24
|
|
|
7
25
|
```bash
|
|
8
|
-
# 1. Install
|
|
26
|
+
# 1. Install
|
|
9
27
|
npm install @mordn/chat-widget drizzle-kit
|
|
10
28
|
|
|
11
29
|
# 2. Run the setup wizard
|
|
12
30
|
npx @mordn/chat-widget
|
|
13
31
|
```
|
|
14
32
|
|
|
15
|
-
The
|
|
16
|
-
|
|
17
|
-
- `
|
|
33
|
+
The wizard creates exactly four files:
|
|
34
|
+
|
|
35
|
+
- `app/api/chat/[[...chat]]/route.ts` — one catch-all that mounts the whole backend
|
|
36
|
+
- `lib/chat-auth.ts` — the `getChatUserId` stub **you implement** (the security boundary)
|
|
37
|
+
- `drizzle.config.ts` — points at the package's chat schema
|
|
18
38
|
- `.env.example`
|
|
19
39
|
|
|
20
40
|
## Requirements
|
|
21
41
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
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.
|
|
26
54
|
|
|
27
55
|
## Setup
|
|
28
56
|
|
|
29
57
|
### 1. Environment Variables
|
|
30
58
|
|
|
31
|
-
Copy `.env.example` to `.env.local` and fill in your credentials
|
|
59
|
+
Copy `.env.example` to `.env.local` and fill in your credentials (see the file
|
|
60
|
+
for the full list — `DATABASE_URL`, and the Supabase keys if you keep uploads).
|
|
32
61
|
|
|
33
|
-
|
|
34
|
-
# Database (Required)
|
|
35
|
-
DATABASE_URL="postgresql://postgres.xxx:[PASSWORD]@aws-0-region.pooler.supabase.com:6543/postgres"
|
|
62
|
+
### 2. Implement the auth boundary
|
|
36
63
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
64
|
+
Open `lib/chat-auth.ts` and replace the throwing stub with your real session
|
|
65
|
+
lookup:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
// Clerk example
|
|
69
|
+
import { auth } from '@clerk/nextjs/server';
|
|
40
70
|
|
|
41
|
-
|
|
71
|
+
export async function getChatUserId() {
|
|
72
|
+
const { userId } = await auth(); // from the verified session — never a header
|
|
73
|
+
return userId;
|
|
74
|
+
}
|
|
75
|
+
```
|
|
42
76
|
|
|
43
|
-
|
|
77
|
+
### 3. Database Setup
|
|
44
78
|
|
|
45
79
|
```bash
|
|
46
|
-
npx drizzle-kit push
|
|
80
|
+
npx drizzle-kit push # creates chat_conversations + chat_messages
|
|
47
81
|
```
|
|
48
82
|
|
|
49
|
-
###
|
|
83
|
+
### 4. Configure your model and tools
|
|
50
84
|
|
|
51
|
-
|
|
85
|
+
Everything is configured in the single `route.ts` the wizard created — model,
|
|
86
|
+
system prompt, store, storage, and tools:
|
|
52
87
|
|
|
53
|
-
```
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
88
|
+
```ts
|
|
89
|
+
export const { GET, POST, DELETE } = createChatHandler({
|
|
90
|
+
getUserId: getChatUserId,
|
|
91
|
+
model: anthropic('claude-sonnet-4-5'),
|
|
92
|
+
store: createDrizzleChatStore(), // or bring your own ChatStore
|
|
93
|
+
storage: createSupabaseStorage(), // or bring your own StorageAdapter
|
|
94
|
+
// buildTools: async (ctx) => ({ tools: { /* ... */ }, cleanup: async () => {} }),
|
|
95
|
+
});
|
|
59
96
|
```
|
|
60
97
|
|
|
61
|
-
|
|
98
|
+
**Bring your own database / storage:** pass a custom `store` / `storage` that
|
|
99
|
+
implement the `ChatStore` / `StorageAdapter` interfaces from
|
|
100
|
+
`@mordn/chat-widget/server`. The hosted defaults and your own implementations
|
|
101
|
+
are interchangeable — same handler, same security.
|
|
102
|
+
|
|
103
|
+
## Mount the widget (client)
|
|
62
104
|
|
|
63
105
|
```tsx
|
|
64
106
|
'use client';
|
|
@@ -66,470 +108,113 @@ const DEVELOPER_CONFIG = {
|
|
|
66
108
|
import { ChatWidget } from '@mordn/chat-widget';
|
|
67
109
|
import '@mordn/chat-widget/styles.css';
|
|
68
110
|
|
|
69
|
-
export default function
|
|
111
|
+
export default function Assistant({ userId }: { userId: string }) {
|
|
70
112
|
return (
|
|
71
113
|
<ChatWidget
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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 }}
|
|
92
118
|
starterPrompts={[
|
|
93
|
-
{ title:
|
|
94
|
-
{ title:
|
|
119
|
+
{ title: 'What can you help me with?' },
|
|
120
|
+
{ title: 'How do I get started?' },
|
|
95
121
|
]}
|
|
96
122
|
/>
|
|
97
123
|
);
|
|
98
124
|
}
|
|
99
125
|
```
|
|
100
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
|
+
|
|
101
131
|
---
|
|
102
132
|
|
|
103
|
-
##
|
|
133
|
+
## Bring your own database / storage
|
|
104
134
|
|
|
105
|
-
|
|
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:
|
|
106
139
|
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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 });
|
|
112
147
|
```
|
|
113
148
|
|
|
114
|
-
|
|
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.
|
|
115
152
|
|
|
116
|
-
|
|
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)
|
|
153
|
+
### File uploads & the storage bucket
|
|
120
154
|
|
|
121
|
-
|
|
155
|
+
`createSupabaseStorage()` expects a **private** `chat-attachments` bucket and
|
|
156
|
+
the service-role key:
|
|
122
157
|
|
|
123
158
|
```env
|
|
124
159
|
NEXT_PUBLIC_SUPABASE_URL="https://your-project.supabase.co"
|
|
125
|
-
SUPABASE_SERVICE_ROLE_KEY="your-service-role-key"
|
|
160
|
+
SUPABASE_SERVICE_ROLE_KEY="your-service-role-key" # server-only, never NEXT_PUBLIC
|
|
126
161
|
```
|
|
127
162
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
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.
|
|
133
167
|
|
|
134
168
|
---
|
|
135
169
|
|
|
136
|
-
##
|
|
137
|
-
|
|
138
|
-
|
|
|
139
|
-
|
|
140
|
-
| `
|
|
141
|
-
| `
|
|
142
|
-
| `
|
|
143
|
-
| `
|
|
144
|
-
| `
|
|
145
|
-
| `
|
|
146
|
-
| `
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
### FeatureConfig
|
|
157
|
-
|
|
158
|
-
```typescript
|
|
159
|
-
{
|
|
160
|
-
fileUpload?: boolean; // Enable file attachments (default: false)
|
|
161
|
-
webSearch?: boolean; // Enable web search toggle
|
|
162
|
-
}
|
|
163
|
-
```
|
|
164
|
-
|
|
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
|
-
```
|
|
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.
|
|
178
190
|
|
|
179
191
|
---
|
|
180
192
|
|
|
181
193
|
## Exports
|
|
182
194
|
|
|
183
|
-
```
|
|
184
|
-
//
|
|
195
|
+
```ts
|
|
196
|
+
// Client component + styles
|
|
185
197
|
import { ChatWidget } from '@mordn/chat-widget';
|
|
186
198
|
import '@mordn/chat-widget/styles.css';
|
|
187
199
|
|
|
188
|
-
//
|
|
200
|
+
// Server handler + the pluggable contracts (server-only)
|
|
189
201
|
import {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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>
|
|
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>
|
|
335
|
-
|
|
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';
|
|
202
|
+
createChatHandler,
|
|
203
|
+
type ChatStore, type ChatStoreFactory,
|
|
204
|
+
type StorageAdapter, type StorageAdapterFactory,
|
|
205
|
+
ConversationOwnershipError,
|
|
206
|
+
} from '@mordn/chat-widget/server';
|
|
342
207
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
const url = new URL(request.url);
|
|
346
|
-
const userId = url.searchParams.get('userId') || request.headers.get('X-User-Id');
|
|
208
|
+
// Default Postgres/Drizzle store (server-only)
|
|
209
|
+
import { createDrizzleChatStore, schema } from '@mordn/chat-widget/server/drizzle';
|
|
347
210
|
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
}
|
|
447
|
-
```
|
|
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
|
-
}
|
|
211
|
+
// Default Supabase storage adapter (server-only)
|
|
212
|
+
import { createSupabaseStorage } from '@mordn/chat-widget/server/supabase';
|
|
527
213
|
```
|
|
528
214
|
|
|
529
|
-
</details>
|
|
530
|
-
|
|
531
215
|
---
|
|
532
216
|
|
|
533
217
|
## License
|
|
534
218
|
|
|
535
219
|
MIT
|
|
220
|
+
|