@mordn/chat-widget 0.8.0 → 0.9.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 +80 -430
- package/dist/{chat-store-DERCPwhl.d.mts → chat-store-DdykLpDo.d.mts} +1 -1
- package/dist/{chat-store-DERCPwhl.d.ts → chat-store-DdykLpDo.d.ts} +1 -1
- package/dist/server/drizzle/index.d.mts +1 -1
- package/dist/server/drizzle/index.d.ts +1 -1
- package/dist/server/hosted/index.d.mts +38 -0
- package/dist/server/hosted/index.d.ts +38 -0
- package/dist/server/hosted/index.js +195 -0
- package/dist/server/hosted/index.js.map +1 -0
- package/dist/server/hosted/index.mjs +169 -0
- package/dist/server/hosted/index.mjs.map +1 -0
- package/dist/server/index.d.mts +2 -2
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.js +2 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +2 -1
- package/dist/server/index.mjs.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -39,11 +39,18 @@ The wizard creates exactly four files:
|
|
|
39
39
|
|
|
40
40
|
## Requirements
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
-
|
|
45
|
-
-
|
|
46
|
-
-
|
|
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
|
-
|
|
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
|
|
111
|
+
export default function Assistant({ userId }: { userId: string }) {
|
|
105
112
|
return (
|
|
106
113
|
<ChatWidget
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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:
|
|
129
|
-
{ title:
|
|
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
|
-
##
|
|
133
|
+
## Bring your own database / storage
|
|
139
134
|
|
|
140
|
-
|
|
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
|
-
```
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
##
|
|
172
|
-
|
|
173
|
-
|
|
|
174
|
-
|
|
175
|
-
| `
|
|
176
|
-
| `
|
|
177
|
-
| `
|
|
178
|
-
| `
|
|
179
|
-
| `
|
|
180
|
-
| `
|
|
181
|
-
| `
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
```
|
|
219
|
-
//
|
|
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
|
-
//
|
|
200
|
+
// Server handler + the pluggable contracts (server-only)
|
|
224
201
|
import {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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
|
+
|
|
@@ -275,4 +275,4 @@ interface ChatStore {
|
|
|
275
275
|
*/
|
|
276
276
|
type ChatStoreFactory = (userId: string) => ChatStore;
|
|
277
277
|
|
|
278
|
-
export { type
|
|
278
|
+
export { type ChatStore as C, type ListMessagesOptions as L, type StoredAttachment as S, type ChatStoreFactory as a, type StoredConversation as b, type StoredMessage as c, type SaveTurnInput as d, ConversationOwnershipError as e };
|
|
@@ -275,4 +275,4 @@ interface ChatStore {
|
|
|
275
275
|
*/
|
|
276
276
|
type ChatStoreFactory = (userId: string) => ChatStore;
|
|
277
277
|
|
|
278
|
-
export { type
|
|
278
|
+
export { type ChatStore as C, type ListMessagesOptions as L, type StoredAttachment as S, type ChatStoreFactory as a, type StoredConversation as b, type StoredMessage as c, type SaveTurnInput as d, ConversationOwnershipError as e };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { C as ChatStore } from '../../chat-store-DdykLpDo.mjs';
|
|
2
2
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
|
3
3
|
import * as ai from 'ai';
|
|
4
4
|
import * as drizzle_orm_pg_core from 'drizzle-orm/pg-core';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { C as ChatStore } from '../../chat-store-DdykLpDo.mjs';
|
|
2
|
+
import { S as StorageAdapter } from '../../storage-adapter-DD8uqiAP.mjs';
|
|
3
|
+
import 'ai';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hosted ChatStore + StorageAdapter — thin HTTP clients over the @mordn/chat-api
|
|
7
|
+
* service. Same interfaces as the Drizzle/Supabase defaults, so switching a
|
|
8
|
+
* consumer from BYO to hosted is a one-line change:
|
|
9
|
+
*
|
|
10
|
+
* store: createHostedChatStore({ apiKey: process.env.MORDN_CHAT_KEY })
|
|
11
|
+
* storage: createHostedStorage({ apiKey: process.env.MORDN_CHAT_KEY })
|
|
12
|
+
*
|
|
13
|
+
* Identity: the `apiKey` authenticates the TENANT (the customer/app). The
|
|
14
|
+
* per-request `userId` the handler binds is sent as `X-Chat-User` — the end
|
|
15
|
+
* user, derived from the consumer's verified session (same trust model as
|
|
16
|
+
* getUserId). The hosted service enforces both axes; this client never decides
|
|
17
|
+
* authorization, it only carries identity.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
interface HostedOptions {
|
|
21
|
+
/** Tenant API key (mck_live_… / mck_test_…). Required. Never sent to the client. */
|
|
22
|
+
apiKey: string;
|
|
23
|
+
/** API base URL. Defaults to the hosted service; override for self-host/local. */
|
|
24
|
+
baseUrl?: string;
|
|
25
|
+
/** Optional fetch override (testing). */
|
|
26
|
+
fetch?: typeof fetch;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Create a `ChatStoreFactory` backed by the hosted @mordn/chat-api service.
|
|
30
|
+
* Pass to `createChatHandler({ store: createHostedChatStore({ apiKey }) })`.
|
|
31
|
+
*/
|
|
32
|
+
declare function createHostedChatStore(options: HostedOptions): (userId: string) => ChatStore;
|
|
33
|
+
/**
|
|
34
|
+
* Create a `StorageAdapterFactory` backed by the hosted service.
|
|
35
|
+
*/
|
|
36
|
+
declare function createHostedStorage(options: HostedOptions): (userId: string) => StorageAdapter;
|
|
37
|
+
|
|
38
|
+
export { type HostedOptions, createHostedChatStore, createHostedStorage };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { C as ChatStore } from '../../chat-store-DdykLpDo.js';
|
|
2
|
+
import { S as StorageAdapter } from '../../storage-adapter-DD8uqiAP.js';
|
|
3
|
+
import 'ai';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hosted ChatStore + StorageAdapter — thin HTTP clients over the @mordn/chat-api
|
|
7
|
+
* service. Same interfaces as the Drizzle/Supabase defaults, so switching a
|
|
8
|
+
* consumer from BYO to hosted is a one-line change:
|
|
9
|
+
*
|
|
10
|
+
* store: createHostedChatStore({ apiKey: process.env.MORDN_CHAT_KEY })
|
|
11
|
+
* storage: createHostedStorage({ apiKey: process.env.MORDN_CHAT_KEY })
|
|
12
|
+
*
|
|
13
|
+
* Identity: the `apiKey` authenticates the TENANT (the customer/app). The
|
|
14
|
+
* per-request `userId` the handler binds is sent as `X-Chat-User` — the end
|
|
15
|
+
* user, derived from the consumer's verified session (same trust model as
|
|
16
|
+
* getUserId). The hosted service enforces both axes; this client never decides
|
|
17
|
+
* authorization, it only carries identity.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
interface HostedOptions {
|
|
21
|
+
/** Tenant API key (mck_live_… / mck_test_…). Required. Never sent to the client. */
|
|
22
|
+
apiKey: string;
|
|
23
|
+
/** API base URL. Defaults to the hosted service; override for self-host/local. */
|
|
24
|
+
baseUrl?: string;
|
|
25
|
+
/** Optional fetch override (testing). */
|
|
26
|
+
fetch?: typeof fetch;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Create a `ChatStoreFactory` backed by the hosted @mordn/chat-api service.
|
|
30
|
+
* Pass to `createChatHandler({ store: createHostedChatStore({ apiKey }) })`.
|
|
31
|
+
*/
|
|
32
|
+
declare function createHostedChatStore(options: HostedOptions): (userId: string) => ChatStore;
|
|
33
|
+
/**
|
|
34
|
+
* Create a `StorageAdapterFactory` backed by the hosted service.
|
|
35
|
+
*/
|
|
36
|
+
declare function createHostedStorage(options: HostedOptions): (userId: string) => StorageAdapter;
|
|
37
|
+
|
|
38
|
+
export { type HostedOptions, createHostedChatStore, createHostedStorage };
|