Dialogflow - 9. React 클라이언트
Dialogflow 웹앱 시리즈입니다.
- Dialogflow - 1. Intent
- Dialogflow - 2. Context
- Dialogflow - 3. Fulfillment
- Dialogflow - 4. 외부 사이트 연동
- Dialogflow - 5. Webhook
- Dialogflow - 6. 클라이언트
- Dialogflow - 7. 챗봇 클라이언트
- Dialogflow - 8. Node.js 클라이언트
- Dialogflow - 9. React 클라이언트
지난 시간에는 React App을 Dialogflow와 바로 연결할 수 없어서 Node App을 구현했습니다.
오늘은 마지막으로 React App과 Node App을 음성으로 연동해 보겠습니다.
React App 생성
먼저 React 라이브러리 프로젝트를 만들고 브라우저의 마이크 입력을 받습니다.
- Create-react-library에 대한 자세한 내용은: React 라이브러리 npm 배포
- 브라우저 마이크 입력에 대한 자세한 내용은: React 웹캠 - 2. getUserMedia
필요한 노드 패키지를 설치(npm install)합니다.
- socket.io-client: Node App 웹소켓에 연결하기 위한 패키지
- socket.io-stream: 웹소켓 스트리밍을 위한 패키지
- recordrtc: 마이크 입력을 오디오 스트리밍으로 뽑아주는 패키지
- reactstrap/bootstrap: 세팅 화면 GUI 패키지
example/App.js
import React from 'react'
import DialogComponent from 'react-app'
const App = () => {
return <DialogComponent showDetail={true} />
}
export default App
example$ npm install && npm start 실행하면 라이브러리를 호출하겠죠.
src/index.js
import React, { Fragment } from 'react'
import DialogflowHandler from './core/DialogflowHandler.js'
import 'bootstrap/dist/css/bootstrap.css';
const DialogComponent = (props) => {
const [stream, setStream] = React.useState(undefined);
React.useEffect(() => {
if (props.stream) {
setStream(stream);
} else {
const createStream = async () => {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
setStream(stream);
}
createStream();
}
}, [])
return (<>
{stream ?
<DialogflowHandler
stream={stream}
showDetail={props.showDetail}
/>
:
<p>waiting for microphone...</p>
}
</>
)
}
export default DialogComponent;
마이크 입력 stream을 받아 DialogflowHandler에게 넘겨줍니다. 간혹 앱(example)에서 스트림을 넘겨주는 경우도 있을 것이므로 props로 스트림을 받지 않은 경우에만 입력을 받습니다. 이때, createStream을 새로 정의한 이유는 async/await를 사용하기 위함입니다.
src/DialogflowHandler.js
import io from 'socket.io-client';
import RecordRTC from 'recordrtc';
import SocketStream from 'socket.io-stream';
import React, { Fragment } from 'react';
import { Setting, LoadPref, StatusLog } from '../util/Util.js';
import { Button } from 'reactstrap';
import Status from './Status.js';
const DEFAULT_PORT = 8001;
const DEFAULT_HOSTNAME = '127.0.0.1';
export default class DialogflowHandler extends React.Component {
constructor(props) {
super(props);
this.state = {
status: Status.INITIALIZING,
dfAddress: LoadPref('dfAddress', DEFAULT_HOSTNAME),
dfPort: LoadPref('dfPort', DEFAULT_PORT),
reload: false,
log: undefined
}
}
componentDidMount() {
this.connect();
}
connect = () => {
let url = "http://" + this.state.dfAddress + ':' + this.state.dfPort;
console.log("connect: url=" + url);
this.socket = io.connect(url, { 'reconnection': true });
this.socket.on('connect', () => this.setState({ status: Status.IDLE }));
this.socket.on('disconnect', () => this.setState({ status: Status.UNAVAILABLE }));
this.socket.on('connect_error', (err) => this.setState({ status: Status.UNAVAILABLE }));
this.socket.on('results', this.onDialogflowResult);
}
startDialogflowByAudioStream = () => {
if (this.state.status !== Status.IDLE) {
this.onDialogflowResult(undefined, "Dialogflow is busy - " + this.state.status);
return;
}
this.setState({ status: Status.LISTENING });
this.recorder = RecordRTC(this.props.stream, {
type: 'audio',
mimeType: 'audio/webm',
sampleRate: 44100,
desiredSampRate: 16000,
recorderType: RecordRTC.StereoAudioRecorder,
numberOfAudioChannels: 1,
disableLogs: true,
timeSlice: 3000,
ondataavailable: b => {
console.log("onDataAvailable...");
let stream = SocketStream.createStream();
SocketStream(this.socket).emit('stream', stream, {
name: 'stream.wav',
size: b.size
}, this.props.includeTTSAudioData);
SocketStream.createBlobReadStream(b).pipe(stream);
}
})
this.recorder.startRecording();
}
onDialogflowResult = (response, err = undefined) => {
console.log("onDialogflowResult():", response, err);
try {
this.recorder.stopRecording();
} catch (err) {
// this.setState({ log: "Error stop recording" });
}
this.setState({ status: Status.IDLE });
try {
console.log("Intent: ", response.queryResult.intent);
if (response.queryResult.intent === null) {
this.setState({ log: 'response is null', status: Status.IDLE })
return;
}
let log = "Q: " + response.queryResult.queryText + " A:"
+ (response.queryResult.fulfillmentText !== '' ? response.queryResult.fulfillmentText :
"[Intent]" + response.queryResult.intent.displayName);
console.log(log);
this.setState({ log: log });
} catch (err) {
console.log(err);
this.setState({ log: "Error while parsing response" });
}
};
render() {
return (<>
{this.props.showDetail &&
<>
<StatusLog status={this.state.status} log={this.state.log} />
<Button color="primary" onClick={() => this.startDialogflowByAudioStream()} style={{ margin: "1em" }}>
Start Dialogflow
</Button>
{this.state.reload && <Button color="danger" onClick={() => window.location.reload()}>Reload required</Button>}
</>
}
<div style={{ width: '90%', margin: 'auto' }}>
<Setting
title={'Google Dialogflow'}
subtitle={(((this.state.status !== Status.UNAVAILABLE && this.state.status !== Status.INITIALIZING)
? 'connected to ' : 'disconnected from ') + this.state.dfAddress + ':' + this.state.dfPort)}
items={[
{ key: 'dfAddress', title: 'DF Host', defVal: this.state.dfAddress, callback: () => this.setState({ reload: true }) },
{ key: 'dfPort', title: 'DF Port', defVal: this.state.dfPort, callback: () => this.setState({ reload: true }) }
]}
/>
<div style={{ margin: '1em' }}>
<iframe
allow="microphone;"
width="350"
height="430"
src="https://console.dialogflow.com/api-client/demo/embedded/ee3a2cad-4346-436e-aeb0-e19379dd2966">
</iframe>
</div>
</div>
</>);
}
}
컴포넌트가 시작되면 웹소켓(socket.io-client) 연결을 시도합니다. 웹소켓 주소와 포트는 설정 화면으로 localStorage에 저장하도록 구현했습니다. Start Dialogflow 버튼을 누르면 RecordRTC를 통해 마이크 입력을 스트림으로 내보내고(ondataavailable) 이를 SocketStream으로 Node App 웹소켓으로 스트리밍합니다. Node App에 수신부를 구현해야겠죠. 아래 섹션에서 설명합니다.
결과를 받으면 onDialogflowResult에서 결과를 출력합니다. 코드는 길어도 구성은 간단하죠?
결과 화면에 덤으로 지난 시간에 봤던 Dialogflow Integrations Web Demo <iframe>도 넣었습니다.
화면 구성 GUI 컴포넌트들은 util/Util.js로 분리해뒀으니 관심있으신 분은 소스를 참고하세요.
자, 그런데 이대로 실행하면 웹소켓 서버가 없어서 연결이 실패합니다.
이제 Node App에 웹소켓 수신부를 구현해 봅시다.
Tip>
여담이지만, 이렇게 React/Node 앱을 여러 개 작업할 때는 Visual Studio Code의 워크스페이스 기능을 사용하면 편리합니다.
우분투에서 작업한다면 Terminator도 추천합니다. 여러개 터미널을 띄어야 할 때 아주 유용합니다.
Node App 웹 소켓
먼저 엔트리 포인트를 수정해봅시다. 지난 번에 텍스트 기반의 테스트 코드를 구현했는데, 앞으로 변경점이 많을 수 있으니 확장성을 고려해서 구조를 잡습니다.
컴포넌트별로 소스들을 src/ 폴더 안에 두고 command line으로 분기하도록 index.js에 yargs를 사용했습니다. 앞으로는 src/에 컴포넌트가 추가되면, .command를 추가하면 되겠죠. 단, GOOGLE_APPLICATION_CREDENTIALS를 임시로 export하고 싶은 경우에는 package.json에 분기 코드를 함께 기재합니다.
"scripts": {
"start": "GOOGLE_APPLICATION_CREDENTIALS='./rubenchoi-gcp.json' node index.js",
"test": "GOOGLE_APPLICATION_CREDENTIALS='./rubenchoi-gcp.json' node index.js test"
},
src/DialogflowNodeApp.js
'use strict';
const projectID = 'newagent-tdyh'
const encoding = 'LINEAR16';
const sampleRateHertz = 16000;
const languageCode = 'ko-KR';
const singleUtterance = true;
const interimResults = true;
const OUTPUT_AUDIO_ENCODING = 'OUTPUT_AUDIO_ENCODING_LINEAR_16';
const express = require('express');
const app = express();
const path = require('path');
const util = require('util');
const uuid = require('uuid');
const { struct } = require('pb-util');
const dflow = require('dialogflow').v2beta1;
const { Transform, pipeline } = require('stream');
const socketIoStream = require('socket.io-stream');
const pump = util.promisify(pipeline);
const initServer = (port) => {
let server = require('http').createServer(app);
const io = require('socket.io')(server, {
cors: {
origin: '*',
}
});
server.listen(port, () => console.log("server waiting for " + port));
io.on('connection', (client) => start(client));
}
const start = (client) => {
socketIoStream(client).on('stream', (stream, data, includeAudioData) => {
const filename = path.basename(data.name);
stream.pipe(fs.createWriteStream(filename));
detectIntentStream(stream, (results) => {
client.emit('results', results);
}, includeAudioData)
});
}
const detectIntentStream = async (audio, callback, includeAudioData) => {
console.log("Detecting Intent Stream : start " + (includeAudioData && "w/audio"));
const sessionClient = new dflow.SessionsClient();
const stream = sessionClient.streamingDetectIntent()
.on('data', (data) => {
if (!data.recognitionResult) {
console.log('Intent detected!');
callback(data);
}
})
.on('error', (err) => console.log(err))
.on('end', () => console.log("===> ended"));
const request = {
session: sessionClient.sessionPath(projectID, uuid.v4()),
queryInput: {
audioConfig: {
sampleRateHertz: sampleRateHertz,
encoding: encoding,
languageCode: languageCode
},
singleUtterance: singleUtterance
},
outputAudioConfig: includeAudioData ? { audioEncoding: OUTPUT_AUDIO_ENCODING, } : undefined
}
stream.write(request);
await pump(audio, new Transform({
objectMode: true,
transform: (obj, _, next) => {
next(null, {
inputAudio: obj,
outputAudioConfig: { audioEncoding: 'OUTPUT_AUDIO_ENCODING_LINEAR_16' }
});
}
}), stream);
}
class DialogflowNodeApp {
constructor(port) {
initServer(port);
}
}
module.exports = (port) => {
return new DialogflowNodeApp(port);
}
코드는 길어도 내용은 간단합니다. 이 컴포넌트가 호출되면 initServer()에서 8001 포트에 웹소켓 연결을 기다립니다. React 앱에서 연결이 되면 start(client)를 통해 client를 스트림으로 감싸고 파이프라인으로 Dialogflow API인 detectIntentStream()에 스트림을 넘겨줍니다.
요청 파라미터는 지난번 텍스트 기반과 동일하고요, 다만 outputAudioConfig에 audioEncoding 옵션을 주면 응답에 TTS 오디오 정보가 함께 옵니다. 즉, 앱에서 Dialogflow TTS 응답을 재생할 수 있습니다. (물론 TTS 생성에 필요한 시간이 더 걸리겠죠.)
실행
준비 완료! 실행해 봅시다. 먼저 Node App을 실행합니다. nodejs-app$ npm start
React App을 실행하면서 로그 확인을 위해 개발자 모드(F12)를 켭시다.
react-app/examples$ npm start
Start Dialogflow 버튼을 누르고 마이크에 "오늘 날씨 어때?"를 외치면,
와우, 음성으로 Dialogflow와 대화했군요!
이제 당신은 당신만의 챗봇을 만들고 대화를 해봤습니다.
마치며
지금까지 Dialogflow 개념을 이해하고 실습을 통해 웹앱/챗봇에서 음성/텍스트로 Dialogflow API로 대화를 진행하는 부분을 학습했습니다. 또한 Dialogflow Fulfillment와 Webhook을 통해 외부 서버와 커뮤니케이션할 수 있는 포괄적인 시스템을 만들어봤지요.
여기까지 이해했다면 Dialogflow 활용 시스템을 설계/개발할 준비가 됐다고 볼 수 있겠습니다.
앞으로 더 살펴봐야 할 것은 첫째, Dialogflow 인텐트나 컨텍스트 등을 강화하고 Knowledge 등을 활용해서 엔진을 더욱 튼튼히 하는 부분과 둘째, 앱에서 Dialogflow의 응답을 분석하고 다음 액션을 취하는 등의 fulfillment 프로그래밍 영역이 있습니다. 또한 TTS와 연동하는 부분도 필요하겠죠.
전자는 도메인에 따라 디자인이 천차만별일 수 있으므로 함께 살펴보기 애매한 부분이지만,
후자는 웹앱 사용자 관점에서 대화의 성능을 높일 수 있는 부분이므로 새로운 튜토리얼로 다루도록 하겠습니다.
Notice
2021년 4월 30일자로 날씨 API 연동이 실제 기상청 API 서비스와 연동되어 위에서 소개한 코드 및 결과가 변동되었습니다. 자세한 내용을 아래 페이지를 참고하세요.
References