Building a ChatGPT inspired app

Learn how to build a ChatGPT-inspired application with Aurelia 2, Node.js, and the OpenAI GPT-4o API.

This tutorial will guide you through creating a ChatGPT-like application using Aurelia 2 for the frontend, Node.js + Express for the backend, and OpenAI's GPT-4o API to generate responses. We'll demonstrate modern Aurelia 2 patterns, proper dependency injection, TypeScript types, error handling, and responsive design.


Prerequisites

Before you begin, ensure you have the following:

  • Node.js (latest LTS version recommended)

  • OpenAI API Key - You'll need to add credits to your account

  • Basic familiarity with TypeScript and Aurelia 2 concepts


1. Setting Up the Aurelia 2 Frontend

Create a New Aurelia Project

Run the following command to scaffold a new Aurelia 2 project with Vite:

npx makes aurelia

Select "Default TypeScript App" and "Vite" when prompted. Name your project chatgpt-clone.

Install Dependencies

Navigate to your project directory and install dependencies:

cd chatgpt-clone
npm install

2. Setting Up the Node.js Backend

Initialize the Backend Project

Create a new directory for the backend and initialize a Node.js project:

mkdir server
cd server
npm init -y

Install Required Packages

Install Express and the official OpenAI Node.js client:

npm install express openai cors body-parser dotenv
npm install -D @types/express @types/cors nodemon

Create the Express Server

Create an server.js file inside the server directory:

const express = require('express');
const { OpenAI } = require('openai');
const cors = require('cors');
const bodyParser = require('body-parser');
require('dotenv').config();

const app = express();
const port = process.env.PORT || 3001;

// Middleware
app.use(cors());
app.use(bodyParser.json({ limit: '10mb' }));
app.use(express.static('public'));

// OpenAI client initialization
const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

// Validate environment variables
if (!process.env.OPENAI_API_KEY) {
  console.error('Missing OPENAI_API_KEY environment variable');
  process.exit(1);
}

// Chat endpoint with conversation history support
app.post('/api/chat', async (req, res) => {
  const { messages, model = 'gpt-4o-mini' } = req.body;

  // Validate request
  if (!messages || !Array.isArray(messages) || messages.length === 0) {
    return res.status(400).json({ 
      error: 'Invalid request: messages array is required' 
    });
  }

  try {
    const completion = await openai.chat.completions.create({
      model,
      messages,
      max_tokens: 1000,
      temperature: 0.7,
      stream: false,
    });

    const reply = completion.choices[0]?.message?.content;
    if (!reply) {
      throw new Error('No response generated');
    }

    res.json({ 
      reply,
      usage: completion.usage,
      model: completion.model
    });
  } catch (error) {
    console.error('OpenAI API Error:', error);
    
    if (error.status === 401) {
      res.status(401).json({ error: 'Invalid API key' });
    } else if (error.status === 429) {
      res.status(429).json({ error: 'Rate limit exceeded. Please try again later.' });
    } else if (error.status === 402) {
      res.status(402).json({ error: 'Insufficient credits. Please check your OpenAI account.' });
    } else {
      res.status(500).json({ error: 'An error occurred while processing your request' });
    }
  }
});

// Health check endpoint
app.get('/api/health', (req, res) => {
  res.json({ status: 'OK', timestamp: new Date().toISOString() });
});

app.listen(port, () => {
  console.log(`🚀 Server is running on http://localhost:${port}`);
  console.log(`📡 API endpoint: http://localhost:${port}/api/chat`);
});

Configure Environment Variables

Create a .env file in the server directory:

# Required: Your OpenAI API key
OPENAI_API_KEY=your_openai_api_key_here

# Optional: Server port (defaults to 3001)
PORT=3001

# Optional: Node environment
NODE_ENV=development

Replace your_openai_api_key_here with your actual OpenAI API key. Important: Keep this file secure and never commit it to version control.

Add Package.json Scripts

Update your server/package.json to include development scripts:

{
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  }
}

3. Connecting Frontend and Backend

Create Type Definitions

First, create type definitions for better TypeScript support. Create src/types/chat.ts:

export interface ChatMessage {
  role: 'user' | 'assistant' | 'system';
  content: string;
  timestamp: Date;
  id: string;
}

export interface ChatResponse {
  reply: string;
  usage?: {
    prompt_tokens: number;
    completion_tokens: number;
    total_tokens: number;
  };
  model: string;
}

export interface ChatError {
  error: string;
}

export type ChatApiResponse = ChatResponse | ChatError;

Create Chat Service

Create a dedicated service for API communication. Create src/services/chat-service.ts:

Note: Aurelia 2 supports two dependency injection patterns:

  1. Constructor injection with @inject decorator (shown below)

  2. Field injection with resolve() function (commented alternatives included)

import { DI, IHttpClient, ILogger, inject } from 'aurelia';
import { ChatMessage, ChatApiResponse, ChatResponse } from '../types/chat';

export const IChatService = DI.createInterface<IChatService>('IChatService', x => x.singleton(ChatService));
export interface IChatService extends ChatService {}

@inject(IHttpClient, ILogger)
export class ChatService {
  private readonly baseUrl = 'http://localhost:3001/api';

  constructor(
    private readonly http: IHttpClient,
    private readonly logger: ILogger
  ) {
    this.logger = logger.scopeTo('ChatService');
  }

  // Alternative DI approach using resolve function:
  // import { resolve } from 'aurelia';
  // private readonly http = resolve(IHttpClient);
  // private readonly logger = resolve(ILogger);

  async sendMessage(messages: ChatMessage[]): Promise<ChatResponse> {
    this.logger.debug('Sending chat message', { messageCount: messages.length });

    try {
      const payload = {
        messages: messages.map(msg => ({
          role: msg.role,
          content: msg.content
        })),
        model: 'gpt-4o-mini' // Using the more cost-effective model
      };

      const response = await this.http.post(`${this.baseUrl}/chat`, payload);
      const data: ChatApiResponse = await response.json();

      if ('error' in data) {
        throw new Error(data.error);
      }

      this.logger.debug('Received chat response', { 
        usage: data.usage,
        model: data.model 
      });

      return data;
    } catch (error) {
      this.logger.error('Failed to send chat message', error);
      throw error;
    }
  }

  async healthCheck(): Promise<boolean> {
    try {
      await this.http.get(`${this.baseUrl}/health`);
      return true;
    } catch {
      return false;
    }
  }
}

Create the Chat Component

Create src/components/chat.ts:

import { bindable, ILogger, inject } from 'aurelia';
import { IChatService } from '../services/chat-service';
import { ChatMessage } from '../types/chat';

@inject(IChatService, ILogger)
export class Chat {
  @bindable public title: string = 'AI Chat Assistant';
  
  public userMessage = '';
  public messages: ChatMessage[] = [];
  public isLoading = false;
  public isTyping = false;
  public error: string | null = null;
  public isConnected = true;

  private messagesContainer?: HTMLElement;

  // Alternative DI approach using resolve function:
  // import { resolve } from 'aurelia';
  // private readonly chatService = resolve(IChatService);
  // private readonly logger = resolve(ILogger);

  constructor(
    private readonly chatService: IChatService,
    private readonly logger: ILogger
  ) {
    this.logger = logger.scopeTo('Chat');
    this.checkConnection();
  }

  public attached(): void {
    this.messagesContainer = document.querySelector('.chat-messages');
    this.addWelcomeMessage();
  }

  private addWelcomeMessage(): void {
    this.messages.push({
      id: this.generateId(),
      role: 'assistant',
      content: 'Hello! I\'m your AI assistant. How can I help you today?',
      timestamp: new Date()
    });
  }

  public async sendMessage(): Promise<void> {
    const message = this.userMessage.trim();
    if (!message || this.isLoading) return;

    this.error = null;
    this.isLoading = true;
    
    // Add user message
    const userMessage: ChatMessage = {
      id: this.generateId(),
      role: 'user',
      content: message,
      timestamp: new Date()
    };
    this.messages.push(userMessage);
    this.userMessage = '';
    
    this.scrollToBottom();
    this.showTypingIndicator();

    try {
      const response = await this.chatService.sendMessage(this.messages);
      
      // Add assistant response
      const assistantMessage: ChatMessage = {
        id: this.generateId(),
        role: 'assistant',
        content: response.reply,
        timestamp: new Date()
      };
      this.messages.push(assistantMessage);

      this.logger.debug('Message sent successfully', {
        tokensUsed: response.usage?.total_tokens,
        model: response.model
      });
    } catch (error) {
      this.logger.error('Failed to send message', error);
      this.error = error instanceof Error ? error.message : 'Failed to send message';
      
      // Add error message
      this.messages.push({
        id: this.generateId(),
        role: 'assistant',
        content: 'I apologize, but I encountered an error. Please try again.',
        timestamp: new Date()
      });
    } finally {
      this.isLoading = false;
      this.hideTypingIndicator();
      this.scrollToBottom();
    }
  }

  public handleKeyPress(event: KeyboardEvent): boolean {
    if (event.key === 'Enter' && !event.shiftKey) {
      event.preventDefault();
      void this.sendMessage();
      return false;
    }
    return true;
  }

  public clearChat(): void {
    this.messages = [];
    this.error = null;
    this.addWelcomeMessage();
  }

  private async checkConnection(): Promise<void> {
    try {
      this.isConnected = await this.chatService.healthCheck();
    } catch {
      this.isConnected = false;
    }
  }

  private showTypingIndicator(): void {
    this.isTyping = true;
  }

  private hideTypingIndicator(): void {
    this.isTyping = false;
  }

  private scrollToBottom(): void {
    // Use setTimeout to ensure DOM is updated
    setTimeout(() => {
      this.messagesContainer?.scrollTo({
        top: this.messagesContainer.scrollHeight,
        behavior: 'smooth'
      });
    }, 100);
  }

  private generateId(): string {
    return `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
  }

  public formatTimestamp(timestamp: Date): string {
    return timestamp.toLocaleTimeString([], { 
      hour: '2-digit', 
      minute: '2-digit' 
    });
  }
}

Create the Chat Component View

Create src/components/chat.html:

<template>
  <div class="chat-container">
    <!-- Header -->
    <div class="chat-header">
      <h2 class="chat-title">${title}</h2>
      <div class="chat-status">
        <span class="status-indicator ${isConnected ? 'connected' : 'disconnected'}"></span>
        <span class="status-text">${isConnected ? 'Connected' : 'Disconnected'}</span>
        <button class="clear-btn" click.trigger="clearChat()" title="Clear conversation">
          🗑️
        </button>
      </div>
    </div>

    <!-- Error Message -->
    <div class="error-message" if.bind="error">
      <span class="error-icon">⚠️</span>
      ${error}
    </div>

    <!-- Messages -->
    <div class="chat-messages" ref="messagesContainer">
      <div 
        repeat.for="message of messages" 
        class="message ${message.role}"
        data-message-id="${message.id}"
      >
        <div class="message-content">
          <div class="message-text">${message.content}</div>
          <div class="message-time">${formatTimestamp(message.timestamp)}</div>
        </div>
      </div>
      
      <!-- Typing Indicator -->
      <div class="message assistant typing-indicator" if.bind="isTyping">
        <div class="message-content">
          <div class="typing-dots">
            <span></span><span></span><span></span>
          </div>
        </div>
      </div>
    </div>

    <!-- Input Form -->
    <form class="chat-input-form" submit.trigger="sendMessage()">
      <div class="input-container">
        <textarea
          class="message-input"
          value.bind="userMessage"
          placeholder="Type your message... (Press Enter to send, Shift+Enter for new line)"
          keydown.trigger="handleKeyPress($event)"
          disabled.bind="isLoading || !isConnected"
          rows="1"
        ></textarea>
        <button 
          type="submit" 
          class="send-button ${isLoading ? 'loading' : ''}"
          disabled.bind="isLoading || !userMessage.trim() || !isConnected"
          title="Send message"
        >
          <span if.bind="!isLoading">Send</span>
          <span if.bind="isLoading" class="loading-spinner">⌛</span>
        </button>
      </div>
    </form>
  </div>
</template>

Apply Modern CSS Styling

Replace the content of src/my-app.css with modern, responsive styles:

/* Reset and base styles */
* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  min-height: 100vh;
}

.my-app {
  padding: 20px;
  max-width: 100%;
}

/* Chat Container */
.chat-container {
  max-width: 800px;
  margin: 0 auto;
  background: white;
  border-radius: 12px;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
  overflow: hidden;
  display: flex;
  flex-direction: column;
  height: 90vh;
  min-height: 600px;
}

/* Header */
.chat-header {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  padding: 20px;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.chat-title {
  margin: 0;
  font-size: 1.5rem;
  font-weight: 600;
}

.chat-status {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 0.9rem;
}

.status-indicator {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: #4ade80;
}

.status-indicator.disconnected {
  background: #f87171;
}

.clear-btn {
  background: rgba(255, 255, 255, 0.2);
  border: none;
  color: white;
  padding: 8px;
  border-radius: 6px;
  cursor: pointer;
  transition: background-color 0.2s;
}

.clear-btn:hover {
  background: rgba(255, 255, 255, 0.3);
}

/* Error Message */
.error-message {
  background: #fee2e2;
  color: #dc2626;
  padding: 12px 20px;
  border-left: 4px solid #dc2626;
  display: flex;
  align-items: center;
  gap: 8px;
}

/* Messages */
.chat-messages {
  flex: 1;
  overflow-y: auto;
  padding: 20px;
  display: flex;
  flex-direction: column;
  gap: 16px;
  scroll-behavior: smooth;
}

.message {
  display: flex;
  max-width: 80%;
  animation: messageSlideIn 0.3s ease-out;
}

.message.user {
  align-self: flex-end;
}

.message.assistant {
  align-self: flex-start;
}

.message-content {
  background: #f1f5f9;
  border-radius: 18px;
  padding: 12px 16px;
  position: relative;
  word-wrap: break-word;
  max-width: 100%;
}

.message.user .message-content {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
}

.message.assistant .message-content {
  background: #f8fafc;
  border: 1px solid #e2e8f0;
}

.message-text {
  line-height: 1.4;
  white-space: pre-wrap;
}

.message-time {
  font-size: 0.75rem;
  opacity: 0.7;
  margin-top: 4px;
  text-align: right;
}

.message.assistant .message-time {
  text-align: left;
}

/* Typing Indicator */
.typing-indicator .message-content {
  background: #f8fafc;
  border: 1px solid #e2e8f0;
}

.typing-dots {
  display: flex;
  gap: 4px;
  align-items: center;
}

.typing-dots span {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: #94a3b8;
  animation: typingDot 1.4s infinite both;
}

.typing-dots span:nth-child(2) {
  animation-delay: 0.2s;
}

.typing-dots span:nth-child(3) {
  animation-delay: 0.4s;
}

/* Input Form */
.chat-input-form {
  padding: 20px;
  background: #f8fafc;
  border-top: 1px solid #e2e8f0;
}

.input-container {
  display: flex;
  gap: 12px;
  align-items: flex-end;
}

.message-input {
  flex: 1;
  border: 1px solid #d1d5db;
  border-radius: 20px;
  padding: 12px 16px;
  font-size: 16px;
  font-family: inherit;
  resize: none;
  outline: none;
  transition: border-color 0.2s, box-shadow 0.2s;
  min-height: 44px;
  max-height: 120px;
}

.message-input:focus {
  border-color: #667eea;
  box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}

.message-input:disabled {
  background: #f3f4f6;
  cursor: not-allowed;
}

.send-button {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  border: none;
  border-radius: 50%;
  width: 44px;
  height: 44px;
  cursor: pointer;
  transition: transform 0.2s, box-shadow 0.2s;
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: 600;
  font-size: 14px;
}

.send-button:hover:not(:disabled) {
  transform: scale(1.05);
  box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}

.send-button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
  transform: none;
}

.send-button.loading {
  animation: buttonPulse 1s infinite;
}

.loading-spinner {
  animation: spin 1s linear infinite;
}

/* Animations */
@keyframes messageSlideIn {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

@keyframes typingDot {
  0%, 80%, 100% {
    transform: scale(0.8);
    opacity: 0.5;
  }
  40% {
    transform: scale(1);
    opacity: 1;
  }
}

@keyframes buttonPulse {
  0%, 100% {
    opacity: 1;
  }
  50% {
    opacity: 0.7;
  }
}

@keyframes spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

/* Responsive Design */
@media (max-width: 768px) {
  .my-app {
    padding: 10px;
  }
  
  .chat-container {
    height: 100vh;
    border-radius: 0;
  }
  
  .chat-header {
    padding: 15px;
  }
  
  .chat-title {
    font-size: 1.25rem;
  }
  
  .message {
    max-width: 90%;
  }
  
  .chat-messages {
    padding: 15px;
  }
  
  .chat-input-form {
    padding: 15px;
  }
}

@media (max-width: 480px) {
  .message {
    max-width: 95%;
  }
  
  .input-container {
    gap: 8px;
  }
  
  .send-button {
    width: 40px;
    height: 40px;
    font-size: 12px;
  }
}

/* Scrollbar Styling */
.chat-messages::-webkit-scrollbar {
  width: 6px;
}

.chat-messages::-webkit-scrollbar-track {
  background: #f1f5f9;
}

.chat-messages::-webkit-scrollbar-thumb {
  background: #cbd5e1;
  border-radius: 3px;
}

.chat-messages::-webkit-scrollbar-thumb:hover {
  background: #94a3b8;
}

Update the Main App Component

Update src/my-app.ts to include the chat component:

export class MyApp {
  public message = 'Welcome to your AI Chat Assistant!';
}

Update src/my-app.html:

<template>
  <require from="./components/chat"></require>
  <div class="my-app">
    <chat title="AI Chat Assistant"></chat>
  </div>
</template>

4. Running the Application

Start the Backend Server

Navigate to the server directory and run:

# For development with auto-restart
npm run dev

# Or for production
npm start

You should see:

🚀 Server is running on http://localhost:3001
📡 API endpoint: http://localhost:3001/api/chat

Start the Aurelia Frontend

In a new terminal, navigate to your Aurelia project directory and run:

npm run dev

Your browser should automatically open to http://localhost:8080 with the chat application.


5. Features Implemented

Our ChatGPT-inspired app includes:

✅ Core Features

  • Modern Aurelia 2 Architecture: Proper dependency injection, services, and TypeScript types

  • Real-time Chat Interface: Smooth messaging experience with loading states

  • Error Handling: Comprehensive error handling for API failures, rate limits, and network issues

  • Responsive Design: Mobile-friendly layout that works on all screen sizes

  • Connection Status: Visual indicator showing server connectivity

  • Message Persistence: Chat history maintained during the session

  • Typing Indicators: Visual feedback when the AI is generating a response

  • Accessibility: Keyboard navigation and screen reader support

🎨 UI/UX Enhancements

  • Modern Design: Clean, modern interface inspired by popular chat applications

  • Smooth Animations: Message slide-ins and loading animations

  • Message Timestamps: Each message includes a timestamp

  • Auto-scroll: Messages automatically scroll to the bottom

  • Clear Chat: Option to clear conversation history

6. Advanced Enhancements (Optional)

To further enhance your application, consider adding:

Streaming Responses

Implement OpenAI's streaming API for real-time text generation:

// In your chat service
async sendStreamingMessage(messages: ChatMessage[]): Promise<ReadableStream> {
  const response = await fetch(`${this.baseUrl}/chat-stream`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ messages, stream: true })
  });
  return response.body;
}

Markdown Support

Add markdown rendering for formatted responses:

npm install marked @types/marked

Conversation History

Persist conversations in localStorage or a database:

export class ConversationService {
  saveConversation(id: string, messages: ChatMessage[]): void {
    localStorage.setItem(`chat-${id}`, JSON.stringify(messages));
  }

  loadConversation(id: string): ChatMessage[] {
    const stored = localStorage.getItem(`chat-${id}`);
    return stored ? JSON.parse(stored) : [];
  }
}

Authentication

Add user authentication and personalized conversations:

export class AuthService {
  // Implement user authentication logic
}

Dark Mode

Add theme switching capability:

[data-theme="dark"] .chat-container {
  background: #1f2937;
  color: #f9fafb;
}

7. Testing Your Application

Backend Testing

Test your server endpoints:

# Health check
curl http://localhost:3001/api/health

# Chat endpoint
curl -X POST http://localhost:3001/api/chat \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"Hello!"}]}'

Frontend Testing

  1. Open your browser to http://localhost:8080

  2. Type a message and press Enter

  3. Verify the AI responds appropriately

  4. Test error handling by stopping the backend server

  5. Test responsive design by resizing your browser window

8. Troubleshooting

Common Issues

"Invalid API key" error:

  • Verify your OpenAI API key is correct in the .env file

  • Ensure you have credits in your OpenAI account

"Rate limit exceeded" error:

  • You're sending too many requests. Wait a moment and try again

  • Consider implementing request throttling

CORS errors:

  • Ensure the server's CORS configuration allows your frontend domain

  • Check that both server and client are running on expected ports

Connection issues:

  • Verify both backend (port 3001) and frontend (port 8080) are running

  • Check browser console for network errors

  • Ensure firewall isn't blocking the ports

Conclusion

Congratulations! You've built a modern, feature-rich ChatGPT-inspired application using Aurelia 2. This project demonstrates:

🏗️ Modern Architecture

  • Aurelia 2: Latest framework features with proper dependency injection

  • TypeScript: Strong typing throughout the application

  • Service Pattern: Separation of concerns with dedicated services

  • Component-Based: Reusable and maintainable component structure

🔧 Best Practices

  • Error Handling: Comprehensive error management and user feedback

  • Responsive Design: Mobile-first approach with modern CSS

  • Performance: Optimized rendering and smooth animations

  • Accessibility: Keyboard navigation and semantic HTML

🚀 Production-Ready Features

  • Environment Configuration: Secure API key management

  • Health Checks: Server monitoring endpoints

  • Loading States: Clear user feedback during operations

  • Connection Status: Real-time connectivity monitoring

This application serves as an excellent foundation for building more complex AI-powered chat interfaces. You can extend it with additional features like conversation persistence, user authentication, file uploads, or integration with other AI services.

Next Steps:

  • Deploy your application to production using services like Vercel, Netlify, or AWS

  • Add user authentication and conversation history

  • Implement streaming responses for real-time text generation

  • Add support for image uploads and multimodal conversations

  • Create custom AI personalities or specialized assistants

Last updated

Was this helpful?