LoginSignup
3
2

More than 3 years have passed since last update.

React + Express + Watson Assistantで自動応答ボットを作る

Last updated at Posted at 2019-07-07

この記事を書いたきっかけ

今まで何となく避けて通ってきたReactを勉強する事になりました。
せっかくなので何か成果物を作りたいなーと思ったので、Watsonを使った自動応答ページを作ってみる事に

完成品

React側のソース(github)

sample.gif

構成

ザックリ言うと、フロントはReact、APIを呼び出すサーバにはExpressを使っています。
構成.png

デモにあったの天気の取得は、裏側でAssistantIBM Cloud FunctionsThe Weather Company Dataという小技を使っています。
関連エントリ
* Watson AssistantからIBM Cloud functionsを使う時の考慮点

React側の構成

package.json(一部抜粋)

  "dependencies": {
    "bootstrap": "^4.3.1",
    "dotenv": "^8.0.0",
    "react": "^16.8.6",
    "react-bootstrap": "^1.0.0-beta.9",
    "react-dom": "^16.8.6",
    "react-icons": "^3.7.0",
    "react-loading-overlay": "^1.0.1",
    "react-scripts": "3.0.1",
    "react-spinners": "^0.5.4"
  },
  1. react-bootstrap
    なれたbootstrapでフロントを開発したかったので利用
  2. react-spinnersreact-loading-overlay
    問合せ中のローディング画面用のパッケージ
  3. dotenv
    APIサーバ等をenvファイルに持たせるためのパッケージ

ソースコード

index.html

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
    <link
      rel="stylesheet"
      href="https://maxcdn.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
      integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T"
      crossorigin="anonymous"
    />
    <title>React App</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
    <div id="inputLabel"></div>    
  </body>
</html>

App.js(実際の入出力フォーム)

import React from 'react';
import ReactDOM from 'react-dom';
import './App.css';
import { FaRobot } from 'react-icons/fa';
import LoadingOverlay from 'react-loading-overlay';
import ScaleLoader from 'react-spinners/ScaleLoader'

function App() {
  /**
   * 入力テキストとボタンをrenderするコンポーネント
   */
  class Input extends React.Component{
    constructor(props){
      super(props);
      this.state = {
        textValue  : "",
        messages   : [],
        session_id : "",
        isActive : false
      }
      this.handleClick = this.handleClick.bind(this);
      this.changeText  = this.changeText.bind(this);
      this.createWatsonAssistantSession();
    };
    /**
     * Watson AssistantのセッションIDを取得して初期メッセージを表示する
     */
    createWatsonAssistantSession(){
      fetch(process.env.REACT_APP_API_SERVER + "/create-session", {
        mode: 'cors'
      })
      .then((response) => {
        return response.json().then(res=>{
          this.setState({
            session_id : res.session_id
          })
          this.fetchAssistant();
        });
      })
      .then((error)=>{
        return error;
      });
    }
    fetchAssistant(){
      fetch(process.env.REACT_APP_API_SERVER + "/conversation", {
        mode  : 'cors',
        method: 'POST',
        body : JSON.stringify({ session_id : this.state.session_id, inputText : this.state.textValue }),
        headers : new Headers({ "Content-type" : "application/json" })
      })
      .then((response) => {
        let conversation = [];
        const question = {kind: "question",text: this.state.textValue};
        return response.json().then(res=>{
          let answer;
          if(res.output.generic[0]){
            answer   = {kind: "answer",text: res.output.generic[0].text};
          }else{
            answer   = {kind: "answer",text: "答えがありません"};
          }
          conversation.push(question,answer);
          this.setState({
            messages : this.state.messages.concat(conversation)
          });
          this.setState({
            isActive:false
          });

        });
      })
      .then((error)=>{
        return error;
      });  
    }
    handleClick(){
      this.setState({
        isActive:true
      });
      this.fetchAssistant();
    };
    changeText(e){
      this.setState({
        textValue: e.target.value
      });
    };
    render(){
      return (
        <div className="container">
            <LoadingOverlay active={this.state.isActive} spinner={<ScaleLoader /> } text='Watsonに問合せ中…'>
            <div className="row">
              <h3 className="offset-md-3 text-danger"><FaRobot /> Reactボット</h3>
            </div>
            <div className="row">
              <div className="offset-md-3 col-md-6">
                <input type="text" className="form-control" placeholder="入力してみてね" value={this.state.textValue}
                  onChange={this.changeText}></input>
              </div>
              <div className="col-md-2">
                <button className="btn btn-primary" onClick={this.handleClick}>Talk To Watson</button>
              </div>
            </div>
          </LoadingOverlay>
          <OutputLabel messages={this.state.messages} />
        </div>
      );
    }
  }
  /**
   * 会話の内容をラベルとして表示する
   */
  class OutputLabel extends React.Component{
    /**
     * 発信なのかbotからの応答なのかを判定してrenderする文字列を返す
     * @param {*} message 
     * @param {*} index 
     */
    determineClass(message,index){
      if(message.text===""){
        return;
      }
      let   bg    = (message.kind === "question")?"offset-md-3 col-md-5 bg-warning":"offset-md-4 col-md-5 bg-success";
      const actor = (message.kind === "question")?"あなた":"bot"
      return(
        <div className="row" key={index} >
          <div className={bg}>
            <label className="control-label" >
              {actor}{message.text}
            </label>
          </div>
        </div>);
    };
    render(){
      const labels = this.props.messages.map((message, index) => {
        return this.determineClass(message,index);
      });
      return(
        <div>
         {labels}
       </div>
      );
    }
  }
  return ReactDOM.render(<Input />, document.getElementById('root'));
}

export default App;

fetch部分をaxiosに変えるともう少し記述はシンプルになります。

    /**
     * Watson AssistantのセッションIDを取得して初期メッセージを表示する
     */
    createWatsonAssistantSession(){
      axios.get(process.env.REACT_APP_API_SERVER + "/create-session")
      .then((response) => {
        this.setState({
          session_id : response.data.session_id
        })
        this.fetchAssistant();
        return;
      })
      .then((error)=>{
        return error;
      });
    }
    fetchAssistant(){
      let params = new URLSearchParams();
      params.append('session_id',this.state.session_id);
      params.append('inputText',this.state.textValue);
      axios.post(process.env.REACT_APP_API_SERVER + "/conversation",params)
      .then((response) => {
        let conversation = [];
        const question = {kind: "question",text: this.state.textValue};
        let answer;
        if(response.data.output.generic[0]){
          answer   = {kind: "answer",text: response.data.output.generic[0].text};
        }else{
          answer   = {kind: "answer",text: "答えがありません"};
        }
        conversation.push(question,answer);
        this.setState({
          messages : this.state.messages.concat(conversation)
        });
        this.setState({
          isActive:false
        });  
      })
      .then((error)=>{
        return error;
      });  
    }

環境変数(.env)

.env.example

# API Server for Watson Call
REACT_APP_API_SERVER=http://localhost:3010/watson/assistant

Express側

package.json(一部抜粋)

dotenvibm-watsonを追加

  "dependencies": {
    "body-parser": "~1.18.2",
    "cookie-parser": "~1.4.3",
    "cors": "^2.8.5",
    "debug": "~2.6.9",
    "dotenv": "^8.0.0",
    "ejs": "~2.5.7",
    "express": "~4.15.5",
    "ibm-watson": "^4.2.1",
    "morgan": "~1.9.0",
    "serve-favicon": "~2.4.5",
    "watson-developer-cloud": "^4.0.1"
  }

app.js

cors対策とwatson用のroutesを追記

var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');
var cors = require('cors');

var index = require('./routes/index');
var users = require('./routes/users');
var watson = require('./routes/watson');

var app = express();

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');

// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

app.use('/', index);
app.use('/users', users);
//ここを追記
app.use(cors())
app.use('/watson', watson);

// catch 404 and forward to error handler
app.use(function(req, res, next) {
  var err = new Error('Not Found');
  err.status = 404;
  next(err);
});

// error handler
app.use(function(err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get('env') === 'development' ? err : {};

  // render the error page
  res.status(err.status || 500);
  res.render('error');
});

module.exports = app;

routes

今回は手抜きで直接routeの中に処理を書きましたw
公開されているAPIリファレンスのまんま、
新規セッション作成と会話用の受け口を作成

var express = require('express');
var router = express.Router();

/* GET home page. */
router.get('/assistant/create-session', function(req, res, next) {
  const AssistantV2 = require('ibm-watson/assistant/v2');

  const service = new AssistantV2({
    iam_apikey: process.env.REACT_APP_ASSISTANT_IAM_APIKEY,
    version: '2019-06-14',
    url: process.env.REACT_APP_ASSISTANT_URL
  });

  service.createSession({
    assistant_id: process.env.REACT_APP_ASSISTANT_ID
  })
  .then(watson_response => {
    console.log(JSON.stringify(watson_response, null, 2));
    res.send(JSON.stringify(watson_response, null, 2));
  })
  .catch(err => {
    console.log(err);
  });
});

router.post('/assistant/conversation', function(req, res, next) {
  const AssistantV2 = require('ibm-watson/assistant/v2');

  const service = new AssistantV2({
    iam_apikey: process.env.REACT_APP_ASSISTANT_IAM_APIKEY,
    version: '2019-02-28',
    url: process.env.REACT_APP_ASSISTANT_URL
  });

  service.message({
    assistant_id: process.env.REACT_APP_ASSISTANT_ID,
    session_id: req.body.session_id,
    input: {
      'message_type': 'text',
      'text': req.body.inputText
      }
    })
    .then(watson_response => {
      res.send(JSON.stringify(watson_response, null, 2));
    })
    .catch(err => {
      console.log(err);
    });
});

module.exports = router;

環境変数(.env)

.env.example

# Environment variables
PORT=3010
REACT_APP_ASSISTANT_ID="XXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
REACT_APP_ASSISTANT_IAM_APIKEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
REACT_APP_ASSISTANT_URL=https://gateway.watsonplatform.net/assistant/api

ハマったポイント

  1. CORS対策 React側から通信をすると、CORSでエラーとなってしまいました。対策としてExpress側のサーバでCORS対策として、corsをインストール
  2. React側で環境変数が読めない! よくあるケースの様で、環境変数の先頭にREACT_APP_をつける必要があるようです。Express側では不要ですが、今回は平仄を合わせる意味でこちらの環境変数名にもREACT_APP_をつけています。

Reactを触った感想

今回はチュートリアルをザっと眺めてからトライしてみましたが、思ったよりも簡単に実装をする事が出来ました。
特に各コンポーネントを部品として扱うので、再利用性や他のReactのパッケージ組み込みが簡単で、今までよりもリッチな画面を作りやすいかな…という感じです。(この機能の実装にかかったのは4h程度)
一方で設計をしっかりしないと、いつどこでstateに値が設定されているか分かり辛くなったり、コンポーネントを重複して開発しそうだなぁ。。。

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2