Actions on GoogleのInteractive Canvasを試してみる

Actions on GoogleにInteractive Canvasなるものがあるので、試してみます。
これは、Actions on Googleの発話・応答を、今までの単純なテキストやカードなどのリッチコンポーネントではなく、通常のHTMLで表示するものです。
スマートスピーカ側に画面表示装置が必要なので、限られますが、音声だけではない画面表示も含めることで表現力がアップするのを期待しています。
GoogleのSmart Displayに加えて、スマホも対応していますので、実際に動きを試してみることができます。

今回作成するのは、Googleで画像を検索するように、好きな言葉を発話して、それに関する画像を表示します。

ソースコードもろもろを以下に置いておきました。

poruruba/InteractiveCanvas

https://github.com/poruruba/InteractiveCanvas

参考となるページ

・https://developers.google.com/assistant/conversational/overview
・https://github.com/actions-on-google/assistant-conversation-nodejs
・https://actions-on-google.github.io/assistant-conversation-nodejs/3.7.0/index.html
・https://developers.google.com/assistant/interactivecanvas/reference

#画像のリストを取得する

以前の投稿 「[ESP32でお気に入りの写真チェンジャーを作る](https://qiita.com/poruruba/items/4723df3581f87332ede5)」 で、Google Custom Searchを使って実現していました。今回もこれを使います。少し変更しているので、もう一度ソースを載せておきます。

```js
'use strict';

const SEARCH_API_KEY = process.env.SEARCH_API_KEY || '【APIキー】';
const SEARCH_CSE_ID = process.env.SEARCH_CSE_ID || '【検索エンジンID】';

const HELPER_BASE = process.env.HELPER_BASE || '../../helpers/';
const Response = require(HELPER_BASE + 'response');
const BinResponse = require(HELPER_BASE + 'binresponse');

const sharp = require('sharp')
const fetch = require('node-fetch');
const { google } = require('googleapis');
const customSearch = google.customsearch('v1');

exports.handler = async (event, context, callback) => {
	if( event.path == '/search-image'){
		console.log(event.queryStringParameters);

		var keyword = event.queryStringParameters.keyword || 'ミニオン';
		var num = event.queryStringParameters.num || 10;
		var width = event.queryStringParameters.width || 320;
		var height = event.queryStringParameters.height || 240;

		var link = await search_image(keyword, num);
		console.log(link);

		var buffer = await download_image(link, width, height);

		return new BinResponse('image/jpeg', buffer);
	}else
	if( event.path == '/search-image-list'){
		console.log(event.queryStringParameters);

		var keyword = event.queryStringParameters.keyword || 'ミニオン';
		var num = event.queryStringParameters.num || 10;

		var items = await search_image_list(keyword, num);
		console.log(items);

		return new Response({ items: items });
	}
};

async function search_image(keyword, num = 10){
	var index = Math.floor(Math.random() * num);
	const result = await customSearch.cse.list({
		cx: SEARCH_CSE_ID,
		q: keyword,
		auth: SEARCH_API_KEY,
		searchType: 'image',
		safe: 'high',
		num: 1, // max:10
		start: index + 1,
	});

	return result.data.items[0].link;
}

async function search_image_list(keyword, num = 10) {
	const result = await customSearch.cse.list({
		cx: SEARCH_CSE_ID,
		q: keyword,
		auth: SEARCH_API_KEY,
		searchType: 'image',
		safe: 'high',
		num: num
	});

	return result.data.items;
}

async function download_image(url, width, height){
  const blob = await fetch(url)
  .then(response =>{
    if( !response.ok )
      throw 'status is not 200';
    return response.blob();
  });
	
  const buffer = await blob.arrayBuffer();

	return sharp(new Uint8Array(buffer))
  .resize({ width: width, height: height })
	.toFormat('jpeg')
  .toBuffer();
}
```

Swagger.yamlはこちら。

```yaml
swagger: '2.0'
info:
  version: 'first version'
  title: Lambda Laboratory Server
  
paths:
  /search-image:
    get:
      responses:
        200:
          description: Success
          schema:
            $ref: "#/definitions/CommonResponse"

  /search-image-list:
    get:
      responses:
        200:
          description: Success
          schema:
            $ref: "#/definitions/CommonResponse"
```

使うのは、「/search-image-list」のエンドポイントのほうです。

こんな感じで、呼び出すと、
　https://【立ち上げた検索サーバのURL】:20443/search-image-list?keyword=浜辺美波
こんな感じで結果が返ってきます。

```json
{
    "items": [
        {
            "kind": "customsearch#result",
            "title": "浜辺美波 (@MINAMI373HAMABE) | Twitter",
            "htmlTitle": "<b>浜辺美波</b> (@MINAMI373HAMABE) | Twitter",
            "link": "https://pbs.twimg.com/profile_images/1068457755768311808/YyfkGeb7.jpg",
            "displayLink": "twitter.com",
            "snippet": "浜辺美波 (@MINAMI373HAMABE) | Twitter",
            "htmlSnippet": "<b>浜辺美波</b> (@MINAMI373HAMABE) | Twitter",
            "mime": "image/jpeg",
            "fileFormat": "image/jpeg",
            "image": {
                "contextLink": "https://twitter.com/minami373hamabe",
                "height": 512,
                "width": 512,
                "byteSize": 32796,
                "thumbnailLink": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcT4DPIPuHnE5Hd9HzVmdl78wRuyDR3eENDAAn3L9jJ6VLJ3ZqOhc4Mz0azf&s",
                "thumbnailHeight": 131,
                "thumbnailWidth": 131
            }
        },
・・・・
```

#Action on Googleのプロジェクト作成

それでは、さっそく、プロジェクトを作成していきましょう。

Actions Console

https://console.actions.google.com/u/2/

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/7d8653f9-5b4c-1724-a4d5-873c91bde9dd.png)

「New project」ボタンを押下します。

ProjectNameは、適当に「InteractiveCanvasProject」としました。言語は、Japanese、国はJapanにします。
最後に、Create projectボタンを押下します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/53c72b53-cfbd-544e-affa-49b97a8e920c.png)

What kind of Action do you want to build? では、Gameを、
How do you want to build it? では、Blank projectを選択します。

ちょっと時間がかかりますが、作成完了して、以下が表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/d816f735-0783-b147-d69a-4e8a73fe9124.png)

まずは、Display nameには、適当な呼び名を指定するのですが、今回は「インタラクティブキャンバス」とでもしておきます。Saveボタンを押下します。

次に、カスタムインテントを作成していきましょう。
以下の3つのインテントを作成します。

・SearchImageIntent
　　キーワードを入力して、画像のリストを取得します。
・NextImageIntent
　　次の画像を表示します。
・PreviousImageIntent
　　1つ前の画像を表示します。

SearchImageIntentでは、任意の文字のキーワードの入力を受け付けるため、先にタイプを作成します。左側のナビゲーションから、Typesを選択し、＋を押下して、AnyTextというタイプを作成します。

いろんな選択肢がありますが、What kind of values will this Type support? として、単純にFree from textを選択します。Saveボタンを押下します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/fd795182-2e7b-74a8-ba50-5ba7f97108bf.png)

次に、左側のナビゲーションから、Custom Intentsを選択して、SearchImageIntentを作成します。

Add intent parameters に、Parameter nameにsearch、Data typeに先ほど作成したAnyTextを選択しておきます。

あとは、Add training phrasesに、
・何かの画像を調べて
・何かを調べて
・何かの画像
・何かの画像を探して
・何かを探して
といったように、言いそうな文章を入力します。そして、さきほどのsearchを、何か に割り当てます。何かの文字のところを選択状態にすれば、割り当てられます。最後に、Saveボタンを押下します。
 
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/0153672f-e1df-8d62-b398-d47a357f8390.png)

次は、NextImageIntentです。

Add training phrasesには、
・次の画像を表示して
・次を表示して
・次へ
・次の画像
・次
等、入れればよいかと思います。
Add intent parametersは使いません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/f2fec2bf-9114-0080-5946-50da95e13346.png)

PreviousImageIntentも同じです。

Add training phrasesには、
・前の画像を表示して
・前を表示して
・前へ
・前の画像
・前
等、入れればよいかと思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/ee1a046f-9211-c455-ed17-f8edd4fa4161.png)

次は、Sceneを作成します。
左側のナビゲーションから、Scenesを選択し、例えば、MainSceneという名前で作成します。
以下のようにUser intent handling に、先ほど作成したインテントを追加します。
その時、When intent is mached には、Call your webhook にチェックを入れ、テキストボックスに「search」と入力しておきます。これで、これから立ち上げるサーバでどのシーン・インテントからの呼び出しかが区別できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/dcf6b6c6-c6de-64a7-dac4-5c05f340c219.png)

ほかのインテントも同様です。
SearchImageIntent → search
NextImageIntent → next
PreviousImageIntent → next

ついでに、Error and status handlingも追加し、System Intentとして、NO_MATCH_1を選択し、Call your webhookにチェックをいれて「no_match」を入力します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/3e17c91b-6ab9-ff44-b89c-1374c464e327.png)

次に、左のナビゲーションから、「Main Invocation」を選択します。
When your Action is invoked として、Send promptsのチェックを外して、Call your web hookの方にチェックを入れ、「start」と入力します。
そして、Transitionには、先ほど作成した、「MainScene」を選択します。
最後に、Saveボタンを押下します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/a6757fe0-bfa2-ca0d-b9a3-77bf2a56b8b7.png)

次に、左側のナビゲーションから、Intaractive canvasを選択します。
Enable Intaractive Canvas with server webhook fulfillment の方を選択状態にします。
Saveボタンを押下します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/be77bfb6-dba3-bbcd-b63b-0314a77f8b74.png)

次に、左側のナビゲーションから、Webhookを選択します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/0e0836bf-1218-444c-d5ca-23fe38d6ef4e.png)

HTTPS endpointを選択状態にして、Confirmボタンを押下します。
これから立ち上げるサーバのURLを指定します。

https://【立ち上げるWebAPIサーバのURL】:20443/canvas-api

最後に、Saveボタンを押下します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/eafbfc8e-2091-4001-f8aa-c3205b1fc6ef.png)

#WebAPIサーバを立ち上げる

Swagger.yamlはこんな感じ。

```yaml:api\controllers\canvas-api\swagger.yaml
swagger: '2.0'
info:
  version: 'first version'
  title: Lambda Laboratory Server
  
paths:
  /canvas-api:
    post:
      x-handler: fulfillment
      parameters:
        - in: body
          name: body
          schema:
            $ref: "#/definitions/CommonRequest"
      responses:
        200:
          description: Success
          schema:
            $ref: "#/definitions/CommonResponse"
```

肝心の実装は以下の通り。

```js:api\controllers\canvas-api\index.js
'use strict';

const { URL, URLSearchParams } = require('url');
const fetch = require('node-fetch');

const base_url_html = 'https://【WebページサーバのURL】:20443';
const base_url_search = 'https://【検索サーバのURL】:20443';

const {
	conversation,
	Canvas, Card, Image, Suggestion
} = require('@assistant/conversation')

const app = conversation({ debug: true });

app.handle('start', conv => {
	console.log(conv);
	conv.add('これはインタラクティブキャンバスです。');

	if (conv.device.capabilities.includes("INTERACTIVE_CANVAS") ){
		conv.add(new Canvas({
			url: base_url_html + `/canvas_test`,
			enableFullScreen: true
		}));
	}else
	if (conv.device.capabilities.includes("RICH_RESPONSE")) {
		conv.add('〇〇を探して、と言ってください。');
	}else{
		conv.scene.next.name = 'actions.scene.END_CONVERSATION';
		conv.add('この端末はディスプレイがないため対応していません。');
	}
});

app.handle('search', async conv => {
	console.log(conv);
	var keyword = conv.intent.params.search.resolved;
	conv.add(keyword + 'の画像が検索されました。');
	var json = await do_get(base_url_search + '/search-image-list', { keyword: keyword });
	console.log(json);
	if (conv.device.capabilities.includes("INTERACTIVE_CANVAS")) {
		conv.add(new Canvas({
			data: {
				which: 'search',
				items: json.items,
				keyword: keyword
			},
		}));
	}else
	if (conv.device.capabilities.includes("RICH_RESPONSE") ){
		conv.session.params.items = json.items;
		conv.session.params.keyword = keyword;
		conv.session.params.index = 0;
		constructRichReponse(conv);
	}
});

app.handle('next', async conv => {
	console.log(conv);
	conv.add('次の画像が選択されました。');
	if (conv.device.capabilities.includes("INTERACTIVE_CANVAS")) {
		conv.add(new Canvas({
			data: {
				which: 'next',
			},
		}));
	}else
	if (conv.device.capabilities.includes("RICH_RESPONSE")) {
		conv.session.params.index++;
		if (conv.session.params.index >= conv.session.params.items.length )
			conv.session.params.index = 0;
		constructRichReponse(conv);
	}
});

app.handle('previous', async conv => {
	console.log(conv);
	conv.add('前の画像が選択されました。');
	if (conv.device.capabilities.includes("INTERACTIVE_CANVAS")) {
		conv.add(new Canvas({
			data: {
				which: 'previous',
			},
		}));
	} else
	if (conv.device.capabilities.includes("RICH_RESPONSE")) {
		conv.session.params.index--;
		if (conv.session.params.index < 0 )
			conv.session.params.index = conv.session.params.items.length - 1;
		constructRichReponse(conv);
	}
});

app.handle('no_match', async conv => {
	console.log(conv);
	conv.add('もう一度言ってください。');
});

exports.fulfillment = app;

function constructRichReponse(conv) {
	conv.add(new Card({
		image: new Image({
			url: conv.session.params.items[conv.session.params.index].link
		})
	}));
	conv.add(new Suggestion({ title: "前の画像" }));
	conv.add(new Suggestion({ title: "次の画像" }));
}

function do_get(url, qs) {
	const params = new URLSearchParams(qs);

	return fetch(url + `?` + params.toString(), {
		method: 'GET',
	})
		.then((response) => {
			if (!response.ok)
				throw 'status is not 200';
			return response.json();
			//    return response.text();
			//    return response.blob();
			//    return response.arrayBuffer();
		});
}
```

npmモジュールとして、@assistant/conversation を使っています。

app.handle('start', conv => {
　起動して最初に呼び出される。

app.handle('search', async conv => {
　○○の画像を調べて、と言われたときに呼び出される。

app.handle('next', async conv => {
　次の画像、と言われたときに呼び出される。

app.handle('previous', async conv => {
　前の画像、と言われたときに呼び出される。

app.handle('no_match', async conv => {
　上記のいずれにも引っかからなかった言葉を受け取った時に呼び出される。

startの処理において、以下の判別を行っています。

```js
	if (conv.device.capabilities.includes("INTERACTIVE_CANVAS") ){
		Interactive Canvaに対応している端末なので、インタラクティブキャンバスを表示する。
	}else
	if (conv.device.capabilities.includes("RICH_RESPONSE")) {
		InteractiveCanvasに対応していないが、リッチレスポンスには対応しているので、Imageを含むCardを表示する。
	}else{
		InteractiveCanvasにも、リッチキャンバスにも対応していないので、中断する。
	}
```

IneractiveCanvasに対応している場合は、以下のように、キャンバスとして表示するWebページのURLを指定して返してあげると、クライアント側でWebページを表示してくれます。

```js
		conv.add(new Canvas({
			url: base_url_html + `/canvas_web`,
			enableFullScreen: true
		}));
```

あとは、ソースコードを見ていただければわかると思います。
例えば、searchの処理を見てみると、

```var keyword = conv.intent.params.search.resolved;```

で、入力された言葉を取得でき、以下のdataというパラメータで、Webページ側に任意の情報を渡してあげることができます。

```js
			data: {
				which: 'search',
				items: json.items,
				keyword: keyword
			},
```

#Webページの作成

Webページは通常のWebページと同様です。
Javascriptのソースは以下の感じです。

```js:public\canvas_web\js\start.js
'use strict';

//const vConsole = new VConsole();
//window.datgui = new dat.GUI();

var vue_options = {
    el: "#top",
    mixins: [mixins_bootstrap],
    data: {
        margin: 0,
        link_list: [],
    },
    computed: {
    },
    methods: {
    },
    created: function () {
    },
    mounted: function () {
        proc_load();

        const callbacks = {
            onUpdate : (data) => {
                console.log(data);
                for( var i = 0 ; i < data.length ; i++ ){
                    switch (data[i].which) {
                        case 'search': {
                            this.link_list = data[i].items;
                            break;
                        }
                        case 'next': {
                            $('#sampleCarousel').carousel('next');
                            break;
                        }
                        case 'previous': {
                            $('#sampleCarousel').carousel('prev');
                            break;
                        }
                    }
                }

                window.interactiveCanvas.getHeaderHeightPx()
                    .then(height => {
                        console.log("getHeaderHeightPx:" + height);
                        this.margin = height;
                    })
            },
        };
        window.interactiveCanvas.ready(callbacks);
    }
};
vue_add_data(vue_options, { progress_title: '' }); // for progress-dialog
vue_add_global_components(components_bootstrap);
vue_add_global_components(components_utils);

/* add additional components */

window.vue = new Vue(vue_options);
```

大事なのは以下の部分です。

```js
        const callbacks = {
            onUpdate : (data) => {
・・・
```

コールバック関数を登録することで、サーバ側から、dataの値を受け取ることができます。

HTMLの方は以下の通りです。画像のギャラリ表示に、Bootstrapのカルーセルを使っています。

```html:public\canvas_web\index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  <meta http-equiv="Content-Security-Policy" content="default-src * data: gap: https://ssl.gstatic.com 'unsafe-eval' 'unsafe-inline'; style-src * 'unsafe-inline'; media-src *; img-src * data: content: blob:;">
  <meta name="format-detection" content="telephone=no">
  <meta name="msapplication-tap-highlight" content="no">
  <meta name="apple-mobile-web-app-capable" content="yes" />
  <meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width">

  <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
  <script src="https://code.jquery.com/jquery-1.12.4.min.js" integrity="sha384-nvAa0+6Qg9clwYCGGPpDQLVpLNn0fRaROjHqs13t4Ggj3Ez50XnGQqc/r8MhnRDZ" crossorigin="anonymous"></script>
  <!-- Latest compiled and minified CSS -->
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css" integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous">
  <!-- Optional theme -->
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap-theme.min.css" integrity="sha384-6pzBo3FDv/PJ8r2KRkGHifhEocL+1X2rVCTTkUfGk7/0pbek5mMa1upzvWbrUbOZ" crossorigin="anonymous">
  <!-- Latest compiled and minified JavaScript -->
  <script src="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/js/bootstrap.min.js" integrity="sha384-aJ21OjlMXNL5UyIl/XNwTMqvzeRMZH2w8c5cRVpzpU8Y5bApTppSuUkhZXN0VxHd" crossorigin="anonymous"></script>

  <link rel="stylesheet" href="css/start.css">
  <script src="js/methods_bootstrap.js"></script>
  <script src="js/components_bootstrap.js"></script>
  <script src="js/components_utils.js"></script>
  <script src="js/vue_utils.js"></script>
  <script src="js/gql_utils.js"></script>

  <script src="https://cdn.jsdelivr.net/npm/vconsole/dist/vconsole.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>

  <script src="https://cdn.jsdelivr.net/npm/js-cookie@2/src/js.cookie.min.js"></script>
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/toastr.min.css">
  <script src="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/toastr.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/dat-gui/0.7.7/dat.gui.min.js"></script>

  <script src="https://www.gstatic.com/assistant/interactivecanvas/api/interactive_canvas.min.js"></script>
  
  <title>Canvas Page</title>
</head>
<body>
  <div id="top" class="container-fluid" v-bind:style="{ 'margin-top': margin + 'px' }">
    <p v-if="link_list.length == 0">〇〇を探して、と言ってください</p>
    <div v-else id="top" class="container-fluid">
      <div id="sampleCarousel" class="carousel slide"  data-interval="" style="height: 100vh;">
        <ol class="carousel-indicators">
          <li v-for="(item, index) in link_list" data-target="#sampleCarousel" v-bind:data-slide-to="index" v-bind:class="[index==0 ? 'active' : '']"></li>
        </ol>
        <div class="carousel-inner" role="listbox" style="height: 100%;">
          <div class="item" v-for="(item, index) in link_list" v-bind:class="[index==0 ? 'active' : '']" style="height: 100%;">
            <img v-bind:src="item.link" class="center-block img-responsive" style="max-height: 100%; object-fit: contain;">
          </div>
        </div>
        <a class="left carousel-control" href="#sampleCarousel" data-slide="prev">
          <span class="glyphicon glyphicon-chevron-left"></span>
        </a>
        <a class="right carousel-control" href="#sampleCarousel" data-slide="next">
          <span class="glyphicon glyphicon-chevron-right"></span>
        </a>
      </div>
    </div>
      

      <!-- for progress-dialog -->
      <progress-dialog v-bind:title="progress_title" v-bind:style="{ 'margin-top': margin + 'px'}"></progress-dialog>
    </div>

    <script src="js/start.js"></script>
</body>
```

#Androidスマホで動かす

以下のような感じになります。そうそう、まだテスト中なので、Action Consoleでのログインアカウントと同じアカウントで、スマホにログインしておく必要があります。

ホームボタン長押しなどで、Googleアシスタントを起動して、「インタラクティブキャンバスにつないで」と言います。
そうすると、以下が表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/7c1cd5e6-b89f-1bd5-2fbe-488f144e2fa7.png)

次に、例えば、「いらすと屋を探して」と言います。そうすると、検索結果が取得されて以下のような画像が表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/cec8c171-49bb-33ba-503f-7a2d88f78a4b.png)

次に、「次の画像」と言うと、次の画像が表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/8a58c044-5576-11c3-a380-c59870464112.png)

以上