この記事を書いたきっかけ
今まで何となく避けて通ってきたReact
を勉強する事になりました。
せっかくなので何か成果物を作りたいなーと思ったので、Watsonを使った自動応答ページを作ってみる事に
完成品
構成
ザックリ言うと、フロントはReact
、APIを呼び出すサーバにはExpress
を使っています。
デモにあったの天気の取得は、裏側でAssistant
⇒IBM Cloud Functions
⇒The 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"
},
- react-bootstrap
なれたbootstrapでフロントを開発したかったので利用 -
react-spinners
とreact-loading-overlay
問合せ中のローディング画面用のパッケージ - 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(一部抜粋)
dotenv
とibm-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
ハマったポイント
- CORS対策
React
側から通信をすると、CORSでエラーとなってしまいました。対策としてExpress
側のサーバでCORS対策として、cors
をインストール - React側で環境変数が読めない!
よくあるケースの様で、環境変数の先頭に
REACT_APP_
をつける必要があるようです。Express
側では不要ですが、今回は平仄を合わせる意味でこちらの環境変数名にもREACT_APP_
をつけています。
Reactを触った感想
今回はチュートリアルをザっと眺めてからトライしてみましたが、思ったよりも簡単に実装をする事が出来ました。
特に各コンポーネントを部品として扱うので、再利用性や他のReactのパッケージ組み込みが簡単で、今までよりもリッチな画面を作りやすいかな…という感じです。(この機能の実装にかかったのは4h程度)
一方で設計をしっかりしないと、いつどこでstate
に値が設定されているか分かり辛くなったり、コンポーネントを重複して開発しそうだなぁ。。。