ブラウザに表示する対話システムのシステムアイコンをアニメーションにしてみた

この記事は[Nextremer Advent Calendar 2018](https://qiita.com/advent-calendar/2018/nextremer)の14日目の記事です。

こんにちは、Nextremerの関沢です。
本記事では、対話システムの研究の際に使用したブラウザに表示する対話システムにおいて、より話しているように感じることを目的として発話に付随するシステム側のアイコンをアニメーションにします。

# 背景
対話システムを社内で検証するために、ブラウザを主にしたインタフェースを用いました。
当初、社内で使用されている実装のうち最低限の要素を持つ部分のみを使用し、ユーザアイコン・システムアイコンはどちらも静止画像でした。
しかし、口を動かす、表情を変えるなどの見た目の変化が発生しないことから話しているという感覚がなく対話らしさがあまり感じられなかったため、本記事ではアイコンをシステムが話している（と考えられる）状態の時に動かすように実装を変更しました。

# 枠組み
今回使用した枠組みは社内で使用されているものを一部変更して利用しています。
HTMLに書き出す際、Vue.jsを使用します。
MITライセンスの下で公開されているRichard Clark氏が作成したcssを使用しています。[HTML5 Reset Stylesheet | HTML5 Doctor](http://html5doctor.com/html-5-reset-stylesheet/)

# 実装
本記事ではVue.jsを用いてHTMLを書き出します。componentのtemplateにHTML記述を用意しておき、ユーザ or システムどちらの発話か（`isTypedByUser`）によって出力するHTMLを分岐させています。出力する画像は `image` でパスを引数としています。

```chat.js
Vue.component("text-log-item", {
  props: ["text", "description", "isTypedByUser", "image"],
  template: `
<li class="text-log-item" v-bind:class="{ robot: !isTypedByUser }">
  <div class="container">
    <template v-if="isTypedByUser">
      <img :src="image" />
      <div class="content">
          <p v-html="text"></p>
      </div>
    </template>
    <template v-else>
      <div class="content">
        <div class="answer">
          <p v-html="text"></p>
        </div>
        <div class="description" v-if="description">
          <p v-html="description"></p>
        </div>
      </div>
      <div class="iconarea">
      <p id="icon"><img :src="image" /></p>
      </div>
    </template>
  </div>
</li>`
});
```
この時 `img src` にアニメーション（今回は.gif）を指定すると画像の部分にgifのアニメーションが再生されます。
しかし、gifは自動でループされるため、今回は別に用意した静止画像に置き換えることで擬似的にgifを停止させます。今回は話していることを最も表すと考えられる口の動きがあるgifを作成しました。

停止画像
<img src="https://qiita-image-store.s3.amazonaws.com/0/320511/0e0e8b7a-a84b-c1ee-6af4-b6d72b3e825f.png" width=20%>


アニメーションgif
<img src="https://qiita-image-store.s3.amazonaws.com/0/320511/022363ba-1961-75a7-0eb6-1b57a26e0120.gif" width=20%>



gifの停止条件テキストを話すために必要な時間が経過した時し、簡単のためテキストの文字列の長さによって定義するものとします。

時間に基づいて画像を置換する方法としてHTMLのscriptタグを用いる方法があり、以下のような実装によって画像を変化させることができます。

```changeImage.html
<p id="icon"><img src="static/img/robot.gif" /></p>
<p><script type="text/javascript">
  function changeImg(text){
    var time = (text.length / 8) * 1000;
    var elem = document.getElementById("icon");
    setTimeout(()=>{elem.innerHTML='<img src="static/img/robot.png" />';}, time)}
  changeImg("今日はいい天気です");
</script></p>
```

<img src="https://qiita-image-store.s3.amazonaws.com/0/320511/f0be579c-35ec-7407-361c-0f2fb962d9de.gif" width=30%>

textの長さによって与えられるtimeだけ時間が経過した時にHTMLの記述 `<img src="static/img/test.gif>"` を `<img src="static/img/test.png>"` に書き換えることで実現しています（gifで表示しているためループしていますが、本来は静止画に置き換わるとそのまま変化しなくなります）。
ですが、今回Vue.jsではtemplateを使用しているためHTMLタグのscriptがtemplate内でサポートされておらず画像を切り替えることができません。
参考：[How to include a \<script\> tag on a Vue component](https://medium.com/@lassiuosukainen/how-to-include-a-script-tag-on-a-vue-component-fe10940af9e8)
また、HTMLの記述を直接書き換えることも（筆者の技量では）難しいと考えられるため、今回はimage要素を時間経過時に置き換えることで実現します。
時間経過による実行の参考：[一定時間で画像を切り換える](http://www.shurey.com/js/samples/3_img8.html)

上のコードを以下のように変更します。

```chat.js
var root = new Vue({
  el: '#root',
  data: function() {
    return {
      textLogList: [
        {
                text:decorateText(args.initial_utterance),
                isTypedByUser:false,
                image:"/static/img/robot.png"
            },
      ],
    };
  },
  methods: {
    sendMessage: async function(payload) {
      scrollDown();

      const {message} = payload;

      this.textLogList.push({
              text:decorateText(message),
              isTypedByUser:true,
              image:"/static/img/user.png"

          });
      for (let textLog of this.textLogList.slice(0, this.textLogList.length-1).reverse()) {
        if (textLog.isTypedByUser) {
          break;
        }
        changeImg(textLog, true);
      }
      await sleep();

      const params = encodeURIComponent(payload.message);
      const sender = encodeURIComponent("user");
      const at = args.access_token;
      const response = await fetch(
              `/postmessage/${at}?message=${params}&sender=${sender}&description=`,
              {credentials: "same-origin"}
          );
    },
    receivedMesseage: function(payload) {
      const data = JSON.parse(payload);
      const {message, type, description} = data;
      if (type == "received"){
        // bot側の発話出力
        this.textLogList.push({
                text:decorateText(message),
                isTypedByUser: false,
                description:decorateText(description),
                image:"/static/img/robot.gif"
            });
      }
      scrollDown();
    }
  },
  mounted: function() {
      var self = this;
      websocket.onmessage = function(ev) {
        if (ev && ev.data) {
          self.receivedMesseage(ev.data);
          lastSystemLog = self.textLogList[self.textLogList.length-1]
          if (lastSystemLog.isTypedByUser == false) {
            changeImg(lastSystemLog, false);
          }
        }
      };
      window.addEventListener('beforeunload', () => {
          websocket.close();
      })
  }
});

async function sleep(d=100) {
  return new Promise((resolve) => setTimeout(resolve, d));
}

function scrollDown() {
  setTimeout(() => {
    window.scrollTo(0, document.body.scrollHeight);
  }, 500);
}

function decorateText(text){
    return text.replace(
        /\*\*(.*?)\*\*/g, '<span style="color:red">$1</span>').replace(
        '\n', '<br/>');
}


function changeImg(textLog, stop_flag){
  if (stop_flag) {
    textLog.image="/static/img/robot.png";
  }
  else {
  var keep = textLog.text.length / 8;
  setTimeout(()=>{textLog.image="/static/img/robot.png"}, keep * 1000);
  }
}

```

![example.gif](https://qiita-image-store.s3.amazonaws.com/0/320511/06bbfda5-e686-d4eb-0260-512f5a287667.gif)

今回の対話システムでは各発話情報を `textLogList` に保管しており、登場する発話を順番に挿入しています。
それぞれの発話の画像情報を時間経過後に変化させることで、HTMLの対応する部分が書き換えられ画像が置き換えられます。

今回の実装では`function changeImg`をシステム発話の情報をリストに加えた後に呼び出すことで、リストの一番最後である最新のシステム発話の画像を時間経過で置き換えます。
この時ユーザがシステムの画像が置換される前に発話を送信すると、新たに表示されたシステム発話を含む２つの画像が動いている状態となり、システムが複数の発話を同時に行っているように見えます。
これを防ぐために、ユーザ発話が送信されたタイミングで送信前に存在しているシステム発話の画像を静止画像に置き換える処理を加えています（ `stopFlag = true` で即置換を行う）。
その結果、すでに出現している発話のアイコンは静止画像になり、最新のシステム発話のアイコンのみが動くようになります。

# おわりに
本記事では対話システムを表示するアイコンを発話長に応じて動かす方法を紹介しました。
現状では音声を用いた対話は行っていませんが、音声を含めることでより自然に感じることが期待されます。
また、私自身JavascriptやHTMLを詳しく知らない身なので勉強になりました。