npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2025 – Pkg Stats / Ryan Hefner

@mordn/chat-widget

v0.1.4

Published

A customizable AI chat widget for React applications

Readme

@mordn/chat-widget

A customizable AI chat widget for React/Next.js applications with built-in conversation persistence.

Quick Start

# 1. Install the package
npm install @mordn/chat-widget drizzle-kit

# 2. Run the setup wizard
npx @mordn/chat-widget

The setup wizard creates all required files:

  • API routes (/api/chat/...)
  • drizzle.config.ts
  • .env.example

Requirements

  • Next.js 14+ (App Router)
  • React 18+
  • PostgreSQL database (Supabase recommended)
  • Tailwind CSS v4

Setup

1. Environment Variables

Copy .env.example to .env.local and fill in your credentials:

# Database (Required)
DATABASE_URL="postgresql://postgres.xxx:[PASSWORD]@aws-0-region.pooler.supabase.com:6543/postgres"

# AI Provider (Required)
AI_GATEWAY_API_KEY="your-ai-gateway-key"

2. Database Setup

Push the schema to your database:

npx drizzle-kit push

3. Configure Your AI Model

Open app/api/chat/route.ts and update the config:

const DEVELOPER_CONFIG = {
  model: 'openai/gpt-4o', // Your AI model
  systemPrompt: 'You are a helpful assistant',
  temperature: 0.7,
};

4. Add the Widget

'use client';

import { ChatWidget } from '@mordn/chat-widget';
import '@mordn/chat-widget/styles.css';

export default function Page() {
  return (
    <ChatWidget
      // Required
      userId="user-123"

      // Theme: 'light' | 'dark'
      theme={{ mode: 'light' }}

      // Feature toggles
      features={{
        fileUpload: false,  // Requires Supabase Storage setup
      }}

      // Display options
      display={{
        defaultOpen: false,      // Start with chat open
        size: 'default',         // 'compact' | 'default' | 'large' | 'full'
        resizable: true,         // Allow resizing
        showToggleButton: true,  // Show FAB toggle button
      }}

      // Starter prompts shown on empty chat
      starterPrompts={[
        { title: "What can you help me with?" },
        { title: "How do I get started?" },
      ]}
    />
  );
}

File Uploads (Optional)

To enable image attachments, you need Supabase Storage and to enable the feature:

<ChatWidget
  userId="user-123"
  features={{ fileUpload: true }}
/>

1. Create Storage Bucket

  1. Go to your Supabase Dashboard
  2. Navigate to StorageNew Bucket
  3. Create a bucket named chat-attachments
  4. Set it to Public (or configure RLS policies for private access)

2. Add Environment Variables

NEXT_PUBLIC_SUPABASE_URL="https://your-project.supabase.co"
SUPABASE_SERVICE_ROLE_KEY="your-service-role-key"

You can find these in Supabase Dashboard → SettingsAPI

3. Create Upload Route

When running npx @mordn/chat-widget, select Yes when asked about the upload route.


Props

| Prop | Type | Default | Description | |------|------|---------|-------------| | userId | string | required | User identifier for storing conversations | | conversationId | string | - | Load a specific conversation | | initialMessages | array | - | Pre-fill the chat with messages | | className | string | - | Additional CSS classes | | theme | ThemeConfig | - | Theme configuration | | features | FeatureConfig | - | Feature toggles | | display | DisplayConfig | - | Display options |

ThemeConfig

{
  mode?: 'light' | 'dark';
}

FeatureConfig

{
  fileUpload?: boolean;  // Enable file attachments (default: false)
  webSearch?: boolean;   // Enable web search toggle
}

DisplayConfig

{
  width?: string;              // e.g., '400px' or '30vw'
  defaultOpen?: boolean;       // Start with chat open (default: false)
  showToggleButton?: boolean;  // Show FAB toggle button (default: true)
  toggleButtonPosition?: {
    bottom?: string;
    right?: string;
  };
}

Exports

// Main component
import { ChatWidget } from '@mordn/chat-widget';
import '@mordn/chat-widget/styles.css';

// Database utilities (server-side only)
import {
  db,
  conversations,
  messages,
  createChat,
  loadChat,
  saveChat,
  getConversations,
  deleteConversation,
  updateConversationTitle,
  eq, and, or, desc, asc, sql
} from '@mordn/chat-widget/api';

Generated Files Reference

import { saveChat, updateConversationTitle, db, conversations, messages, eq } from '@mordn/chat-widget/api';
import { convertToModelMessages, streamText, UIMessage } from 'ai';

export const maxDuration = 30;

const DEVELOPER_CONFIG = {
  model: 'openai/gpt-4o',
  systemPrompt: 'You are a helpful assistant',
  temperature: 0.7,
};

export async function POST(req: Request) {
  try {
    const body = await req.json();
    const userId = req.headers.get('X-User-Id');

    if (!userId) {
      return new Response('userId is required in X-User-Id header', { status: 400 });
    }

    const chatMessages: UIMessage[] = body.messages || [];
    const id: string = body.id || 'temp-id';
    const { model, systemPrompt, temperature } = DEVELOPER_CONFIG;

    const existingConv = await db
      .select({ id: conversations.id })
      .from(conversations)
      .where(eq(conversations.id, id))
      .limit(1);

    if (!existingConv.length) {
      await db.insert(conversations).values({
        id,
        userId,
        title: 'New Chat',
        metadata: {},
      });
    }

    const userMessages = chatMessages.filter(msg => msg.role === 'user');
    if (userMessages.length > 0) {
      const newUserMessage = userMessages[userMessages.length - 1];
      const textPart = newUserMessage.parts?.find(p => p.type === 'text') as { text: string } | undefined;
      const fileParts = newUserMessage.parts?.filter(p => p.type === 'file') || [];

      const existingMsg = await db
        .select({ id: messages.id })
        .from(messages)
        .where(eq(messages.id, newUserMessage.id))
        .limit(1);

      if (!existingMsg.length) {
        await db.insert(messages).values({
          id: newUserMessage.id,
          conversationId: id,
          role: newUserMessage.role,
          content: textPart?.text || '',
          files: fileParts,
          model: model,
          metadata: { parts: newUserMessage.parts || [] },
        });
      }

      if (textPart?.text) {
        const conv = await db
          .select({ title: conversations.title })
          .from(conversations)
          .where(eq(conversations.id, id))
          .limit(1);

        if (conv[0]?.title === 'New Chat') {
          await updateConversationTitle(id, textPart.text.slice(0, 100));
        }
      }
    }

    const transformedMessages = chatMessages.map(msg => {
      if (msg.role === 'user' && msg.parts) {
        const textPart = msg.parts.find(p => p.type === 'text');
        const fileParts = msg.parts.filter(p => p.type === 'file');

        if (fileParts.length > 0) {
          const content: any[] = [];
          if (textPart && 'text' in textPart) {
            content.push({ type: 'text', text: textPart.text });
          }
          for (const file of fileParts) {
            if ('mediaType' in file && (file as any).mediaType?.startsWith('image/')) {
              content.push({ type: 'image', image: (file as any).url });
            }
          }
          return { ...msg, content };
        }
      }
      return msg;
    });

    const result = streamText({
      model: model,
      messages: convertToModelMessages(transformedMessages),
      system: systemPrompt,
      temperature: temperature,
    });

    return result.toUIMessageStreamResponse({
      sendSources: true,
      sendReasoning: true,
      onFinish: ({ messages: finalMessages }) => {
        if (finalMessages.length > 0) {
          saveChat({ chatId: id, messages: finalMessages, model, userId });
        }
      },
    });
  } catch (error) {
    console.error('Chat API error:', error);
    return new Response(JSON.stringify({ error: 'Internal server error' }), {
      status: 500,
      headers: { 'Content-Type': 'application/json' },
    });
  }
}
import { NextResponse } from 'next/server';
import { getConversations } from '@mordn/chat-widget/api';

export async function GET(request: Request) {
  try {
    const url = new URL(request.url);
    const userId = url.searchParams.get('userId') || request.headers.get('X-User-Id');

    if (!userId) {
      return NextResponse.json({ error: 'userId is required' }, { status: 400 });
    }

    const conversationsData = await getConversations(userId);

    const conversations = conversationsData.map(conv => ({
      id: conv.id,
      title: conv.title,
      created_at: conv.createdAt,
      updated_at: conv.updatedAt,
      metadata: conv.metadata,
      message_count: conv.messageCount,
    }));

    return NextResponse.json({ conversations });
  } catch (error) {
    console.error('Error in chat history API:', error);
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
  }
}
import { NextResponse } from 'next/server';
import { db, conversations, messages, eq, and, asc } from '@mordn/chat-widget/api';

export async function GET(
  request: Request,
  { params }: { params: Promise<{ conversationId: string }> }
) {
  try {
    const { conversationId } = await params;
    const url = new URL(request.url);
    const userId = url.searchParams.get('userId') || request.headers.get('X-User-Id');

    if (!userId) {
      return NextResponse.json({ error: 'userId is required' }, { status: 400 });
    }

    const conv = await db
      .select({
        id: conversations.id,
        title: conversations.title,
        metadata: conversations.metadata,
      })
      .from(conversations)
      .where(and(
        eq(conversations.id, conversationId),
        eq(conversations.userId, userId)
      ))
      .limit(1);

    if (!conv.length) {
      return NextResponse.json({ error: 'Conversation not found' }, { status: 404 });
    }

    const conversation = conv[0];

    const dbMessages = await db
      .select()
      .from(messages)
      .where(eq(messages.conversationId, conversationId))
      .orderBy(asc(messages.createdAt))
      .limit(1000);

    const transformedMessages = dbMessages.map(msg => {
      const metadata = msg.metadata as { parts?: any[] } | null;

      if (metadata?.parts && Array.isArray(metadata.parts)) {
        return {
          id: msg.id,
          role: msg.role,
          content: msg.content,
          created_at: msg.createdAt,
          parts: metadata.parts
        };
      }

      return {
        id: msg.id,
        role: msg.role,
        content: msg.content,
        created_at: msg.createdAt,
        parts: msg.content ? [{ type: 'text', text: msg.content }] : undefined
      };
    });

    return NextResponse.json({ conversation, messages: transformedMessages });
  } catch (error) {
    console.error('Error loading conversation:', error);
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
  }
}
import { createClient } from '@supabase/supabase-js';
import { nanoid } from 'nanoid';

export async function POST(req: Request) {
  try {
    const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
    const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;

    // Check for required environment variables
    if (!supabaseUrl || !supabaseServiceKey) {
      console.error('Missing Supabase environment variables');
      return Response.json({
        error: 'File upload is not configured. Please set up Supabase Storage environment variables.'
      }, { status: 503 });
    }

    const formData = await req.formData();
    const file = formData.get('file') as File;
    const conversationId = formData.get('conversationId') as string;
    const userId = formData.get('userId') as string;

    if (!file) {
      return Response.json({ error: 'No file provided' }, { status: 400 });
    }

    if (!userId) {
      return Response.json({ error: 'userId is required' }, { status: 400 });
    }

    if (!file.type.startsWith('image/')) {
      return Response.json({ error: 'Only image files are supported' }, { status: 400 });
    }

    if (file.size > 5 * 1024 * 1024) {
      return Response.json({ error: 'File size exceeds 5MB limit' }, { status: 400 });
    }

    const supabase = createClient(supabaseUrl, supabaseServiceKey);

    const timestamp = Date.now();
    const randomId = nanoid(8);
    const safeFilename = file.name.replace(/[^a-zA-Z0-9.-]/g, '_');
    const filePath = `${userId}/${conversationId || 'default'}/${timestamp}-${randomId}-${safeFilename}`;

    const fileBuffer = await file.arrayBuffer();

    const { error: uploadError } = await supabase.storage
      .from('chat-attachments')
      .upload(filePath, fileBuffer, {
        contentType: file.type,
        upsert: false,
      });

    if (uploadError) {
      return Response.json({ error: 'Failed to upload file' }, { status: 500 });
    }

    const { data: urlData } = supabase.storage
      .from('chat-attachments')
      .getPublicUrl(filePath);

    return Response.json({
      url: urlData.publicUrl,
      filename: file.name,
      mediaType: file.type,
      size: file.size,
      type: 'file',
    });
  } catch (error) {
    return Response.json({ error: 'Internal server error' }, { status: 500 });
  }
}

License

MIT