Пишем чат с анализом настроения с помощью Next.js

Обложка поста

Перевод статьи «Build A Chat App With Sentiment Analysis Using Next.js»

Приложения, работающие в режиме реального времени существуют довольно давно. Их можно увидеть на примере мультиплеерных игр, сервисов для совместной работы, служб обмена сообщениями, инструментов анализа данных в реальном времени и т. п. Отдельные технологии разрабатывались на протяжении многих лет, чтобы упростить наиболее сложные аспекты создания приложений, зависящих от изменений в реальном времени.

Данный материал поможет вам создать простой чат, анализирующий эмоции человека по словам в его сообщениях.

Подготовка

Перед началом, убедитесь, что на вашей машине установлены Node и npm (или Yarn). Вот краткий обзор основных инструментов, которые будут нужны для создания чата:

  1. Next.js — фреймворк для приложений на платформе React, использующих серверный рендеринг (SSR). Он решает большинство проблем, возникающих при создании React-проектов с SSR .
  2. Pusherинструмент для создания приложений, работающих в режиме реального времени, с помощью push-уведомлений и сообщений типа издатель-подписчик. Это основа будущего приложения.
  3. Sentiment — модуль, который использует базу слов AFINN-165 и классификацию Emoji Sentiment Ranking для эмоционального анализа по произвольным блокам входного текста.
  4. React — популярный JavaScript-фреймворк для создания масштабируемых веб-приложений с использованием компонентной архитектуры.
  5. Некоторые другие библиотеки, которые будут рассмотрены ниже.

Установка зависимостей

Создайте новый каталог и выполните следующие команды, чтобы установить необходимые зависимости для приложения.

# Создание новой директории
mkdir realtime-chat-app
  
# Переход в созданную директорию
cd realtime-chat-app
  
# Инициализация нового пакета и установка зависимостей приложения
npm init -y
  
npm install react react-dom next pusher pusher-js sentiment
npm install express body-parser cors dotenv axios

Установка переменных окружения

В панели управления Pusher создайте новое приложение (есть бесплатный тариф), чтобы получить его учётные данные. Затем создайте файл .env в корневом каталоге вашего приложения и добавьте в него полученные данные следующим образом:

PUSHER_APP_ID=YOUR_APP_ID
PUSHER_APP_KEY=YOUR_APP_KEY
PUSHER_APP_SECRET=YOUR_APP_SECRET
PUSHER_APP_CLUSTER=YOUR_APP_CLUSTER

Убедитесь, что вы используете имена переменных, которые указаны в приведённом выше фрагменте, т. к. далее вы будете ссылаться на них в нескольких местах кода.

После этого, создайте файл конфигурации Next.js с именем next.config.js в корневом каталоге приложения со следующим содержимым:

/* next.config.js */
  
   const webpack = require('webpack');
   require('dotenv').config();
  
   module.exports = {
     webpack: config => {
       const env = Object.keys(process.env).reduce((acc, curr) => {
         acc[`process.env.${curr}`] = JSON.stringify(process.env[curr]);
         return acc;
       }, {});
      
       config.plugins.push(new webpack.DefinePlugin(env));
      
       return config;
     }
   };

Поскольку Next.js использует Webpack для загрузки и обвязки модулей, необходимо настроить его, чтобы использовать переменные окружения, которые были определены выше, и сделать их доступными для компонентов React с помощью доступа к объекту process.env.

Первые шаги

Настройка сервера

Настроим простой сервер, использующий Next.js. Также загрузим необходимые middleware (промежуточные функции обработки запроса) и настроим Pusher, используя учётные данные, добавленные ранее в переменные окружения.

Создайте файл server.js в корневом каталоге приложения и добавьте в него следующий фрагмент кода для настройки сервера:

/* server.js */
  
   const cors = require('cors');
   const next = require('next');
   const Pusher = require('pusher');
   const express = require('express');
   const bodyParser = require('body-parser');
   const dotenv = require('dotenv').config();
   const Sentiment = require('sentiment');
  
   const dev = process.env.NODE_ENV !== 'production';
   const port = process.env.PORT || 3000;
  
   const app = next({ dev });
   const handler = app.getRequestHandler();
   const sentiment = new Sentiment();
  
   // Убедитесь, что ваши учётные данные правильно установлены в файле .env
   // Используем определённые переменные
   const pusher = new Pusher({
     appId: process.env.PUSHER_APP_ID,
     key: process.env.PUSHER_APP_KEY,
     secret: process.env.PUSHER_APP_SECRET,
     cluster: process.env.PUSHER_APP_CLUSTER,
     encrypted: true
   });
  
   app.prepare()
     .then(() => {
    
       const server = express();
      
       server.use(cors());
       server.use(bodyParser.json());
       server.use(bodyParser.urlencoded({ extended: true }));
      
       server.get('*', (req, res) => {
         return handler(req, res);
       });
      
       server.listen(port, err => {
         if (err) throw err;
         console.log(`> Ready on http://localhost:${port}`);
       });
      
     })
     .catch(ex => {
       console.error(ex.stack);
       process.exit(1);
     });

Изменение скриптов npm

Измените блок scripts в файле package.json так, чтобы файл выглядел следующим образом:

/* package.json */
  
   "scripts": {
     "dev": "node server.js",
     "build": "next build",
     "start": "NODE_ENV=production node server.js"
   }

Всё необходимое для создания компонентов приложения готово. Если сейчас выполнить команду npm run dev в терминале, то он запустит сервер на порте 3000, если, конечно, он доступен. Однако в браузере пока ничего не произойдёт, потому что на главной странице не создано ни одного компонента. Поэтому далее создадим компоненты приложения.

Создание index-страницы

Для работы Next.js нужно, чтобы компоненты страницы приложения находились в каталоге pages, поэтому добавим его в корневой каталог приложения и уже внутри него создадим новый файл index.js для главной страницы приложения.

Прежде чем поместить контент на главную страницу, добавим компонент Layout, который можно будет использовать на страницах приложения в качестве шаблона. Идём дальше и создаём каталог components в корне приложения. Помещаем новый файл Layout.js внутри только что созданного каталога со следующим содержимым:

/* components/Layout.js */
  
   import React, { Fragment } from 'react';
   import Head from 'next/head';
   const Layout = props => (
     <Fragment>
       <Head>
         <meta charSet="utf-8" />
       <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
         <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossOrigin="anonymous" />
         <title>{props.pageTitle || 'Realtime Chat'}</title>
       </Head>
       {props.children}
     </Fragment>
   );
  
   export default Layout;

Здесь мы используем компонент next/head для добавления метаданных в заголовки всех страниц. Также добавляем ссылку на файл Bootstrap CDN, чтобы придать стиль приложению. Кроме всего прочего, заголовок страницы делаем динамическим и отображаем содержимое страницы используя {props.children}.

Далее добавляем следующее содержимое в файл index.js, который был создан ранее:

/* pages/index.js */
    
    import React, { Component } from 'react';
    import Layout from '../components/Layout';
    
    class IndexPage extends Component {
    
      state = { user: null }
      
      handleKeyUp = evt => {
        if (evt.keyCode === 13) {
          const user =  evt.target.value;
          this.setState({ user });
        }
      }
      
      render() {
        const { user } = this.state;
        
        const nameInputStyles = {
          background: 'transparent',
          color: '#999',
          border: 0,
          borderBottom: '1px solid #666',
          borderRadius: 0,
          fontSize: '3rem',
          fontWeight: 500,
          boxShadow: 'none !important'
        };
        
        return (
          <Layout pageTitle="Realtime Chat">
          
            <main className="container-fluid position-absolute h-100 bg-dark">
            
              <div className="row position-absolute w-100 h-100">
              
                <section className="col-md-8 d-flex flex-row flex-wrap align-items-center align-content-center px-5">
                  <div className="px-5 mx-5">
                  
                    <span className="d-block w-100 h1 text-light" style={{marginTop: -50}}>
                      {
                        user
                          ? (<span>
                              <span style={{color: '#999'}}>Hello!</span> {user}
                            </span>)
                          : `What is your name?`
                      }
                    </span>
                    
                    { !user && <input type="text" className="form-control mt-3 px-3 py-2" onKeyUp={this.handleKeyUp} autoComplete="off" style={nameInputStyles} /> }
                    
                  </div>
                </section>
                
                <section className="col-md-4 position-relative d-flex flex-wrap h-100 align-items-start align-content-between bg-white px-0"></section>
                
              </div>
              
            </main>
            
          </Layout>
        );
      }
      
    }
    
    export default () => (
      <IndexPage />
    );

Здесь для главной страницы приложения создаётся компонент IndexPage. Состояние компонента инициализируется с помощью пустого свойства user. Свойство user предназначено для хранения имени текущего активного пользователя.

Также здесь добавляется поле ввода для получения имени пользователя, если в данный момент оно не задано. Как только поле заполнено, имя сохраняется в свойстве user по нажатию enter или return.

Если сейчас запустить приложение в браузере, можно увидеть следующее:

Создание компонента Chat

Добавим компонент Chat. Для этого создаём новый файл Chat.js в каталоге components и добавляем следующее содержимое:

/* components/Chat.js */
  
   import React, { Component, Fragment } from 'react';
   import axios from 'axios';
   import Pusher from 'pusher-js';
  
   class Chat extends Component {
  
     state = { chats: [] }
    
     componentDidMount() {
    
       this.pusher = new Pusher(process.env.PUSHER_APP_KEY, {
         cluster: process.env.PUSHER_APP_CLUSTER,
         encrypted: true
       });
      
       this.channel = this.pusher.subscribe('chat-room');
      
       this.channel.bind('new-message', ({ chat = null }) => {
         const { chats } = this.state;
         chat && chats.push(chat);
         this.setState({ chats });
       });
      
       this.pusher.connection.bind('connected', () => {
         axios.post('/messages')
           .then(response => {
             const chats = response.data.messages;
             this.setState({ chats });
           });
       });
      
     }
    
     componentWillUnmount() {
       this.pusher.disconnect();
     }
    
   }
  
   export default Chat;

Давайте разберёмся, что было сделано выше:

  1. Сначала инициализируется состояние компонента, чтобы оно вмещало пустой массив chats. Этот массив будет заполняться сообщениями по мере их поступления. Как только компонент установлен, внутри метода componentDidMount() устанавливается соединение с Pusher.
  2. Присоединяемся к Pusher-каналу с именем chat-room. Затем подписываемся на событие new-message, которое запускается при поступлении нового сообщения в чат. После просто заполняем chats, добавляя новый чат.
  3. В том же методе componentDidMount() подписываемся на событие connected, чтобы извлечь все сообщения чата из истории с помощью HTTP-запроса POST/messages и библиотеки axios, как только пользователь присоединился. После этого заполняем chats сообщениями чата, полученными в ответ на запрос.
  4. Компонент Chat ещё не завершён. Всё ещё необходимо добавить метод render(). Чтобы это сделать, добавьте следующий код в класс компонента Chat:
    /* components/Chat.js */

    handleKeyUp = evt => {
      const value = evt.target.value;

      if (evt.keyCode === 13 && !evt.shiftKey) {
        const { activeUser: user } = this.props;
        const chat = { user, message: value, timestamp: +new Date };

        evt.target.value = '';
        axios.post('/message', chat);
      }
    }

    render() {
      return (this.props.activeUser && <Fragment>

        <div className="border-bottom border-gray w-100 d-flex align-items-center bg-white" style={{ height: 90 }}>
          <h2 className="text-dark mb-0 mx-4 px-2">{this.props.activeUser}</h2>
        </div>

        <div className="border-top border-gray w-100 px-4 d-flex align-items-center bg-light" style={{ minHeight: 90 }}>
          <textarea className="form-control px-3 py-2" onKeyUp={this.handleKeyUp} placeholder="Enter a chat message" style={{ resize: 'none' }}></textarea>
        </div>

      </Fragment> )
    } 

Как видно из метода render(), свойство activeUser требуется для идентификации текущего активного пользователя. Также присутствует элемент <textarea>, который необходим для ввода сообщения. К этому элементу добавлен обработчик событий onKeyUp для отправки сообщения в чат при нажатии клавиш enter или return.

В обработчике handleKeyUp() создаётся объект chat, содержащий user (текущий активный пользователь), message (само сообщение) и timestamp (время отправки сообщения). После нужно очистить поле <textarea> и выполнить HTTP-запрос POST/message, передавая объект chat.

Теперь необходимо добавить компонент Chat на главную страницу. Сначала добавьте следующую строку к операторам import в файле pages/index.js.

/* pages/index.js */
  
   // Другие установки import здесь
   import Chat from '../components/Chat';

Затем установите метод render() компонента IndexPage. Вставьте компонент Chat в пустой элемент <section>. Он должен выглядеть следующим образом:

    /* pages/index.js */

    <section className="col-md-4 position-relative d-flex flex-wrap h-100 align-items-start align-content-between bg-white px-0">
      { user && <Chat activeUser={user} /> }
    </section>

Теперь можно перезагрузить приложение в браузере, чтобы увидеть изменения.

Определение маршрутов чата

На данный момент при попытках отправить сообщение в чат ничего не происходит. Пока что нельзя увидеть ни сообщения, ни историю чата. Это потому, что ещё не реализованы два маршрута, к которым мы делаем запросы.

Создадим маршруты /message и /messages. Далее изменим файл server.js и добавим следующий код непосредственно перед вызовом server.listen() внутри функции then().

/* server.js */
  
   // server.get('*') находится здесь
  
   const chatHistory = { messages: [] };
  
   server.post('/message', (req, res, next) => {
     const { user = null, message = '', timestamp = +new Date } = req.body;
     const sentimentScore = sentiment.analyze(message).score;
    
     const chat = { user, message, timestamp, sentiment: sentimentScore };
    
     chatHistory.messages.push(chat);
     pusher.trigger('chat-room', 'new-message', { chat });
   });
  
   server.post('/messages', (req, res, next) => {
     res.json({ ...chatHistory, status: 'success' });
   });
  
   // server.listen() находится здесь

Здесь создаётся своего рода хранилище для истории чата, чтобы хранить сообщения в массиве. Это полезно для новых пользователей, которые присоединяются к чату для просмотра предыдущих сообщений. Всякий раз, когда клиент отправляет POST-запрос к /messages при подключении, он получает все сообщения в истории чата при ответе.

В запросе POST/message приходит выборка данных из req.body с помощью инструмента body-parser, который мы добавили ранее. Затем используется модуль sentiment, чтобы вычислить общую оценку настроения в сообщении. Далее изменяем объект chat, добавив в него свойство sentiment, содержащее оценку настроения.

Наконец, добавляем чат к истории чата в messages, а затем запускаем событие new-message в канале chat-room, передавая объект chat в событие.

Осталось всего несколько шагов, и приложение будет готово. Если перезагрузить приложение в браузере сейчас и попытаться отправить сообщение в чат, оно не будет отображено. Это не потому, что приложение не работает, оно работает отлично. Просто ещё не происходит вывод сообщений чата на экран.

Отображение сообщений чата

Создайте новый файл ChatMessage.js в каталоге components и добавьте в него следующее содержимое:

    /* components/ChatMessage.js */

    import React, { Component } from 'react';

    class ChatMessage extends Component {

      render() {
        const { position = 'left', message } = this.props;
        const isRight = position.toLowerCase() === 'right';

        const align = isRight ? 'text-right' : 'text-left';
        const justify = isRight ? 'justify-content-end' : 'justify-content-start';

        const messageBoxStyles = {
          maxWidth: '70%',
          flexGrow: 0
        };

        const messageStyles = {
          fontWeight: 500,
          lineHeight: 1.4,
          whiteSpace: 'pre-wrap'
        };

        return <div className={`w-100 my-1 d-flex ${justify}`}>
          <div className="bg-light rounded border border-gray p-2" style={messageBoxStyles}>
            <span className={`d-block text-secondary ${align}`} style={messageStyles}>
              {message}
            </span>
          </div>
        </div>
      }

    }

    export default ChatMessage;

Компонент ChatMessage — простой компонент, требующий 2 свойства: message — сообщение чата, и position — положение сообщения справа или слева. Это полезно для размещения сообщений текущего пользователя на одной стороне экрана, а сообщений других пользователей — на другой.

Внесём следующие изменения в компонент Chat для отображения сообщений. Для этого изменим файл components/Chat.js.

Сначала добавим следующие константы перед определением класса компонента Chat. Каждая константа представляет собой массив кодировок, обозначающих конкретный смайл Эмоджи. Также нужно убедиться, что компонент ChatMessage импортирован.

/* components/Chat.js */
  
   // Модуль включается здесь
   import ChatMessage from './ChatMessage';
  
   const SAD_EMOJI = [55357, 56864];
   const HAPPY_EMOJI = [55357, 56832];
   const NEUTRAL_EMOJI = [55357, 56848];
  
   // Класс компонента Chat здесь

Далее следует добавить следующий фрагмент кода между контейнером <div> заголовка чата и контейнером <div> окна сообщений, которое было создано ранее в компоненте Chat.

/* components/Chat.js */

    {/** ЗАГОЛОВОК ЧАТА ЗДЕСЬ **/}

    <div className="px-4 pb-4 w-100 d-flex flex-row flex-wrap align-items-start align-content-start position-relative" style={{ height: 'calc(100% - 180px)', overflowY: 'scroll' }}>

      {this.state.chats.map((chat, index) => {

        const previous = Math.max(0, index - 1);
        const previousChat = this.state.chats[previous];
        const position = chat.user === this.props.activeUser ? "right" : "left";

        const isFirst = previous === index;
        const inSequence = chat.user === previousChat.user;
        const hasDelay = Math.ceil((chat.timestamp - previousChat.timestamp) / (1000 * 60)) > 1;

        const mood = chat.sentiment > 0 ? HAPPY_EMOJI : (chat.sentiment === 0 ? NEUTRAL_EMOJI : SAD_EMOJI);

        return (
          <Fragment key={index}>

            { (isFirst || !inSequence || hasDelay) && (
              <div className={`d-block w-100 font-weight-bold text-dark mt-4 pb-1 px-1 text-${position}`} style={{ fontSize: '0.9rem' }}>
                <span className="d-block" style={{ fontSize: '1.6rem' }}>
                  {String.fromCodePoint(...mood)}
                </span>
                <span>{chat.user || 'Anonymous'}</span>
              </div>
            ) }

            <ChatMessage message={chat.message} position={position} />

          </Fragment>
        );

      })}

    </div>

    {/** ОКНО СООБЩЕНИЯ ЧАТА ЗДЕСЬ **/}

Попробуем разобраться, что делает этот фрагмент кода. Сначала выполняется проход по каждому объекту chat в массиве chats. Затем выполняется проверка, совпадает ли отправитель сообщения с текущим пользователем, и определяется позиция сообщения в чате. Сообщения активного пользователя появляются справа.

Здесь также выполняется оценка sentiment, чтобы установить настроение пользователя (весёлое, грустное или нейтральное) с использованием ранее определённых констант.

Имя пользователя отображается перед сообщением в чате на основе одного из следующих условий:

  1. isFirst — текущее сообщение является первым в списке.
  2. !inSequence — текущее сообщение следует непосредственно за сообщением другого пользователя.
  3. hasDelay — текущее сообщение имеет задержку более 1 минуты от предыдущего сообщения того же пользователя.
  4. Также обратите внимание, как используется метод [String.fromCodePoint()], чтобы получить смайлики из кодировок, которые были определены в константах ранее.

Наконец, приложение чата завершено. Теперь можно проверить, как оно работает в браузере. Вот несколько скриншотов, показывающих чат между пользователями 9lad, Steve и Bob.

9lad

Steve

Bob

Заключение

Это руководство помогает создать очень простое приложение чата с эмоциональным анализом с помощью модулей Next.js (React), Pusher и Sentiment Node. Несмотря на то, что здесь затрагиваются лишь основы, есть множество инструментов, которые помогут вам создать более продвинутое приложение чата. Исходный код этого руководства можно найти на GitHub.

Также вы можете изучить документацию для каждого инструмента, который был использован в этом проекте, чтобы узнать больше о других способах реализации таких приложений.

 

Не смешно? А здесь смешно: @ithumor