实践:使用 Dialogflow 和 Actions on Google 为 Google 助理创建电视指南操作

1. 简介

您正在看电视,但找不到遥控器,或者您不想逐个浏览电视频道,看看是否有精彩的电视节目?我们来问问 Google 助理电视上有什么节目吧!在本实验中,您将使用 Dialogflow 构建一个简单的操作,并了解如何将其与 Google 助理集成。

这些练习旨在帮助您熟悉常见的云开发者体验:

  1. 创建 Dialogflow v2 代理
  2. 创建自定义实体
  3. 创建意图
  4. 使用 Firebase Functions 设置 Webhook
  5. 测试聊天机器人
  6. 启用 Google 助理集成

构建内容

我们将为 Google 助理构建一个交互式电视指南聊天机器人代理。您可以询问电视指南,了解特定频道当前正在播放的节目。例如,“MTV 在放什么?”电视指南操作会告知您当前正在播放的内容以及接下来要播放的内容。

学习内容

  • 如何使用 Dialogflow v2 创建聊天机器人
  • 如何使用 Dialogflow 创建自定义实体
  • 如何使用 Dialogflow 创建线性对话
  • 如何使用 Dialogflow 和 Firebase Functions 设置 Webhook fulfillment
  • 如何通过 Actions on Google 将应用引入 Google 助理

前提条件

  • 您需要 Google 身份 / Gmail 地址才能创建 Dialogflow 代理。
  • 虽然不需要具备 JavaScript 方面的基础知识,但如果您想更改 Webhook 实现代码,这些知识会很有用。

2. 准备工作

在浏览器中启用网络活动记录

  1. 点击:http://myaccount.google.com/activitycontrols

  1. 确保“网络与应用活动记录”处于启用状态:

bf8d16b828d6f79a.png

创建 Dialogflow 代理

  1. 打开:https://console.dialogflow.com

  1. 在左侧栏中,选择徽标正下方的“创建新代理”。如果您有现有代理,请先点击下拉菜单。

1d7c2b56a1ab95b8.png

  1. 指定代理名称:your-name-tvguide(使用您自己的名称)

35237b5c5c539ecc.png

  1. 选择默认语言:英语 - en
  2. 选择离您最近的时区作为默认时区。
  3. 点击创建

配置 Dialogflow

  1. 在左侧菜单中,点击项目名称旁边的齿轮图标。

1d7c2b56a1ab95b8.png

  1. 输入以下代理说明:我的电视指南

26f262d359c49075.png

  1. 向下滚动到日志设置,然后将两个开关都切换为“开启”,以记录 Dialogflow 的互动情况以及 Google Cloud Stackdriver 中的所有互动情况。稍后,如果我们想调试操作,就需要用到此 ID。

e80c17acc3cce993.png

  1. 点击保存

配置 Actions on Google

  1. 在右侧面板的 See how it works in Google Assistant(了解 Google 助理中的运作方式)中,点击 Google Assistant(Google 助理)链接。

5a4940338fc351e3.png

系统将打开:http://console.actions.google.com

如果您是 Actions on Google 的新用户,则需要先填写此表单:

3fd4e594fa169072.png

  1. 点击项目名称,尝试在模拟器中打开您的 action。**
  2. 在菜单栏中选择测试

6adb83ffb7adeb78.png

  1. 确保模拟器已设置为英语,然后点击与我的测试应用对话

该操作将使用基本的 Dialogflow 默认意图向您问好。这意味着与 Actions on Google 的集成设置成功完成!

3. 自定义实体

实体是指应用或设备可对其执行操作的对象。不妨将其视为参数 / 变量。在我们的电视指南中,我们将询问:“MTV 上有什么节目”。MTV 是实体和变量。我还可以要求播放其他频道,例如“国家地理”或“喜剧中心”。收集的实体将用作我向电视指南 API 网络服务发出的请求中的参数。

点击此处可详细了解 Dialogflow 实体

创建渠道实体

  1. 在 Dialogflow 控制台中,点击菜单项:实体
  2. 点击创建实体
  3. 实体名称:channel(确保所有字母均为小写)
  4. 传入频道名称。(某些频道需要同义词,以防 Google 助理理解错误)。您可以在输入内容时使用 Tab 键和 Enter 键。以参考值的形式输入频道号。频道名称作为同义词,例如:
  • 1 - 1, Net 1, Net Station 1

ee4e4955aa77232d.png

5**.** 点击蓝色“保存”按钮旁边的菜单按钮,切换到 **原始编辑** 模式。

e294b49b123e034f.png

  1. 以 CSV 格式复制并粘贴其他实体:
"2","2","Net 2, Net Station 2"
"3","3","Net 3, Net Station 3"
"4","4","RTL 4"
"5","5","Movie Channel"
"6","6","Sports Channel"
"7","7","Comedy Central"
"8","8","Cartoon Network"
"9","9","National Geographic"
"10","10","MTV"

ed78514afd5badef.png

  1. 点击保存

4. 意图

Dialogflow 使用意图来对用户意向进行分类。意图具有训练短语,即用户可能对代理说出的内容示例。例如,想要了解电视上有什么节目的用户可能会问“今天电视上有什么节目?”“What's currently playing?”,或者直接说“tvguide”。

当用户输入文字或说出话语(称为“用户表述”)时,Dialogflow 会将用户表述与代理中的最佳意图进行匹配。匹配意图也称为“意图分类”

点击此处可详细了解 Dialogflow 意图

修改默认欢迎意图

创建新的 Dialogflow 代理时,系统会自动创建两个默认意图。默认欢迎意图是您与代理开始对话时首先进入的流程。默认后备意图是指当代理无法理解您所说的内容或无法将您刚才所说的内容与任何意图匹配时,您将获得的流程。

  1. 点击默认欢迎意图

对于 Google 助理,它将自动启动并使用“默认欢迎意图”。这是因为 Dialogflow 正在监听欢迎事件。不过,您也可以通过说出输入的训练短语来调用意图。

6beee64e8910b85d.png

以下是“默认欢迎意图”的欢迎消息:

用户

Agent

“Ok Google,与您的姓名电视指南对话。”

“欢迎,我是电视指南代理。我可以告诉你电视频道目前正在播放什么内容。例如,你可以问我:“MTV 正在播放什么节目。”

  1. 向下滚动到回答
  2. 清除所有文字回答。
  3. 创建一个新的文本响应,其中包含以下问候语:

Welcome, I am the TV Guide agent. I can tell you what's currently playing on a TV channel. For example, you can ask me: What's on MTV?

84a1110a7f7edba2.png

  1. 点击保存

创建临时测试 intent

出于测试目的,我们将创建临时测试意图,以便稍后测试 Webhook。

  1. 再次点击意图菜单项。
  2. 点击创建意图
  3. 输入 intent 名称:Test Intent (请务必使用大写字母 T 和大写字母 I。- 如果您以其他方式拼写 intent,后端服务将无法正常运行!)

925e02caa4de6b99.png

  1. 点击添加训练短语
  • Test my agent
  • Test intent

2e44ddb2fae3c841.png

  1. 依次点击 Fulfillment > 启用 Fulfillment

7eb73ba04d76140e.png

这次,我们不会对回答进行硬编码。响应将来自 Cloud Functions 函数!

  1. 切换为此意图启用网络钩子调用开关。

748a82d9b4d7d253.png

  1. 点击保存

创建渠道 intent

渠道意图将包含对话的这一部分:

用户

Agent

“Comedy Central 正在播放什么节目?”

“目前,Comedy Central 正在播放《辛普森一家》,从下午 6 点开始。之后,在晚上 7 点,《恶搞之家》将开始播放。”

  1. 再次点击意图菜单项。
  2. 点击创建意图
  3. 输入 intent 名称:Channel Intent (请务必使用大写字母 T 和大写字母 I。- 如果您以其他方式拼写 intent,后端服务将无法正常运行!)
  4. 点击添加训练短语,然后添加以下内容:
  • What's on MTV?
  • What's playing on Comedy Central?
  • What show will start at 8 PM on National Geographic?
  • What is currently on TV?
  • What is airing now.
  • Anything airing on Net Station 1 right now?
  • What can I watch at 7 PM?
  • What's on channel MTV?
  • What's on TV?
  • Please give me the tv guide.
  • Tell me what is on television.
  • What's on Comedy Central from 10 AM?
  • What will be on tv at noon?
  • Anything on National Geographic?
  • TV Guide

6eee02db02831397.png

  1. 向下滚动到操作和参数

b7e917026760218a.png

请注意 Dialogflow 已知的 @channel@sys.time 实体。稍后,参数名称和参数值将发送到您的 Web 服务。例如:

channel=8

time=2020-01-29T19:00:00+01:00

  1. 渠道标记为必需

在与电视指南代理对话时,您始终需要填充槽位形参名称 channel。如果对话开头未提及频道名称,Dialogflow 会进一步询问,直到填满所有参数槽。6f36973fd789c182.png

输入以下提示:

  • For which TV channel do you want to hear the tv guide information?
  • In which TV channel are you interested?

cdb5601ead9423f8.png

  1. 按要求设置时间参数。

时间将是可选的。如果未指定时间,网络服务将返回当前时间。

  1. 点击 Fulfillment

这次,我们不会对回答进行硬编码。响应将来自云函数!因此,请翻转为此意图启用网络钩子调用开关。

  1. 点击保存

5. 网络钩子实现

如果您的代理不仅仅需要静态意图响应,则您需要使用 fulfillment 将您的 Web 服务与代理连接起来。通过连接您的 Web 服务,您可以根据用户表述执行操作,并将动态响应发回给用户。例如,如果用户想接收 MTV 的电视节目表,您的 Web 服务可以在数据库中进行检查,并向用户返回 MTV 的节目表。

  1. 点击主菜单中的 Fulfillment
  2. 启用内嵌编辑器开关

cc84351f0d03ab6f.png

若要简单地测试和实现网络钩子,您可以使用内嵌编辑器。它使用无服务器的 Cloud Functions for Firebase

  1. 点击编辑器中的 index.js 标签页,然后复制并粘贴以下 Node.js JavaScript 代码:
'use strict';

process.env.DEBUG = 'dialogflow:debug';

const {
  dialogflow,
  BasicCard,
  Button,
  Image,
  List
 } = require('actions-on-google');

const functions = require('firebase-functions');
const moment = require('moment');
const TVGUIDE_WEBSERVICE = 'https://tvguide-e4s5ds5dsa-ew.a.run.app/channel';
const { WebhookClient } = require('dialogflow-fulfillment');
var spokenText = '';
var results = null;


/* When the Test Intent gets invoked. */
function testHandler(agent) {
    let spokenText = 'This is a test message, when you see this, it means your webhook fulfillment worked!';

    if (agent.requestSource === agent.ACTIONS_ON_GOOGLE) {
        let conv = agent.conv();
        conv.ask(spokenText);
        conv.ask(new BasicCard({
            title: `Test Message`,
            subTitle: `Dialogflow Test`,
            image: new Image({
                url: 'https://dummyimage.com/600x400/000/fff',
                alt: 'Image alternate text',
            }),
            text: spokenText,
            buttons: new Button({
                title: 'This is a button',
                url: 'https://assistant.google.com/',
            }),
        }));
        // Add Actions on Google library responses to your agent's response
        agent.add(conv);
    } else {
        agent.add(spokenText);
    }
}

/* When the Channel Intent gets invoked. */
function channelHandler(agent) {
    var jsonResponse = `{"ID":10,"Listings":[{"Title":"Catfish Marathon","Date":"2018-07-13","Time":"11:00:00"},{"Title":"Videoclips","Date":"2018-07-13","Time":"12:00:00"},{"Title":"Pimp my ride","Date":"2018-07-13","Time":"12:30:00"},{"Title":"Jersey Shore","Date":"2018-07-13","Time":"13:00:00"},{"Title":"Jersey Shore","Date":"2018-07-13","Time":"13:30:00"},{"Title":"Daria","Date":"2018-07-13","Time":"13:45:00"},{"Title":"The Real World","Date":"2018-07-13","Time":"14:00:00"},{"Title":"The Osbournes","Date":"2018-07-13","Time":"15:00:00"},{"Title":"Teenwolf","Date":"2018-07-13","Time":"16:00:00"},{"Title":"MTV Unplugged","Date":"2018-07-13","Time":"16:30:00"},{"Title":"Rupauls Drag Race","Date":"2018-07-13","Time":"17:30:00"},{"Title":"Ridiculousness","Date":"2018-07-13","Time":"18:00:00"},{"Title":"Punk'd","Date":"2018-07-13","Time":"19:00:00"},{"Title":"Jersey Shore","Date":"2018-07-13","Time":"20:00:00"},{"Title":"MTV Awards","Date":"2018-07-13","Time":"20:30:00"},{"Title":"Beavis & Butthead","Date":"2018-07-13","Time":"22:00:00"}],"Name":"MTV"}`;
    var results = JSON.parse(jsonResponse);
    var listItems = {};
    spokenText = getSpeech(results);

    for (var i = 0; i < results['Listings'].length; i++) {
        listItems[`SELECT_${i}`] = {
            title: `${getSpokenTime(results['Listings'][i]['Time'])} - ${results['Listings'][i]['Title']}`,
            description: `Channel: ${results['Name']}`
        }
    }
    if (agent.requestSource === agent.ACTIONS_ON_GOOGLE) {
        let conv = agent.conv();
        conv.ask(spokenText);
        conv.ask(new List({
            title: 'TV Guide',
            items: listItems
        }));
        // Add Actions on Google library responses to your agent's response
        agent.add(conv);
    } else {
        agent.add(spokenText);
    }
}

/**
 * Return a text string to be spoken out by the Google Assistant
 * @param {object} JSON tv results
 */
var getSpeech = function(tvresults) {
    let s = "";
    if(tvresults['Listings'][0]) {
        let channelName = tvresults['Name'];
        let currentlyPlayingTime = getSpokenTime(tvresults['Listings'][0]['Time']);
        let laterPlayingTime = getSpokenTime(tvresults['Listings'][1]['Time']);
        s = `On ${channelName} from ${currentlyPlayingTime}, ${tvresults['Listings'][0]['Title']} is playing.
        Afterwards at ${laterPlayingTime}, ${tvresults['Listings'][1]['Title']} will start.`
    }

    return s;
}

/**
 * Return a natural spoken time
 * @param {string} time in 'HH:mm:ss' format
 * @returns {string} spoken time (like 8 30 pm i.s.o. 20:00:00)
 */
var getSpokenTime = function(time){
    let datetime = moment(time, 'HH:mm:ss');
    let min = moment(datetime).format('m');
    let hour = moment(datetime).format('h');
    let partOfTheDay = moment(datetime).format('a');

    if (min == '0') {
        min = '';
    }

    return `${hour} ${min} ${partOfTheDay}`;
};

exports.dialogflowFirebaseFulfillment = functions.https.onRequest((request, response) => {
    var agent = new WebhookClient({ request, response });

    console.log('Dialogflow Request headers: ' + JSON.stringify(request.headers));
    console.log('Dialogflow Request body: ' + JSON.stringify(request.body));
   
    let channelInput = request.body.queryResult.parameters.channel;
    let requestedTime = request.body.queryResult.parameters.time;
    let url = `${TVGUIDE_WEBSERVICE}/${channelInput}`;

    var intentMap = new Map();
    intentMap.set('Test Intent', testHandler);
    intentMap.set('Channel Intent', channelHandler);
    agent.handleRequest(intentMap);
});

cc84351f0d03ab6f.png

  1. 点击编辑器中的 package.json 标签页,然后复制并粘贴以下 JSON 代码段,该代码段会导入所有 Node.js 软件包管理系统 (NPM) 库:
{
  "name": "tvGuideFulfillment",
  "description": "Requesting TV Guide information from a web service.",
  "version": "1.0.0",
  "private": true,
  "license": "Apache Version 2.0",
  "author": "Google Inc.",
  "engines": {
    "node": "8"
  },
  "scripts": {
    "start": "firebase serve --only functions:dialogflowFirebaseFulfillment",
    "deploy": "firebase deploy --only functions:dialogflowFirebaseFulfillment"
  },
  "dependencies": {
    "actions-on-google": "^2.2.0",
    "firebase-admin": "^5.13.1",
    "firebase-functions": "^2.0.2",
    "request": "^2.85.0",
    "request-promise": "^4.2.5",
    "moment" : "^2.24.0",
    "dialogflow-fulfillment": "^0.6.1"
  }
}

af01460c2a023e68.png

  1. 点击部署按钮。由于系统正在部署无服务器函数,因此需要等待片刻。屏幕底部会显示一个弹出式窗口,其中会显示您的状态。
  2. 我们来测试一下网络钩子,看看代码是否有效。在右侧的模拟器中,输入:

Test my agent.

如果一切正常,您应该会看到“这是一条测试消息”。

  1. 现在,我们来测试频道 intent,提出以下问题:

What's on MTV?

一切正常时,您应该会看到:

“MTV 不插电正在 MTV 上播放,时间是下午 4 点 30 分。之后,在下午 5 点 30 分,Rupauls Drag Race 将开始播出。”

可选步骤 - Firebase

如果您使用其他渠道测试此功能,会发现电视结果相同。这是因为云函数尚未从真实的 Web 服务器中提取数据。

为此,我们需要建立出站网络连接

如果您想使用 Web 服务测试此应用,请将 Firebase 方案升级为 Blaze。注意:这些步骤是可选的。您也可以继续执行本实验的后续步骤,在 Actions on Google 中继续测试应用。

  1. 前往 Firebase 控制台:https://console.firebase.google.com

  1. 按屏幕底部的升级按钮

ad38bc6d07462abf.png

在弹出式窗口中选择 Blaze 方案。

  1. 现在我们知道 webhook 可以正常运行,接下来可以继续操作,将 index.js 的代码替换为以下代码。这样可确保您能够从 Web 服务请求电视节目指南信息:
'use strict';

process.env.DEBUG = 'dialogflow:debug';

const {
  dialogflow,
  BasicCard,
  Button,
  Image,
  List
 } = require('actions-on-google');

const functions = require('firebase-functions');
const moment = require('moment');
const { WebhookClient } = require('dialogflow-fulfillment');
const rp = require('request-promise');

const TVGUIDE_WEBSERVICE = 'https://tvguide-e4s5ds5dsa-ew.a.run.app/channel';
var spokenText = '';
var results = null;


/* When the Test Intent gets invoked. */
function testHandler(agent) {
    let spokenText = 'This is a test message, when you see this, it means your webhook fulfillment worked!';

    if (agent.requestSource === agent.ACTIONS_ON_GOOGLE) {
        let conv = agent.conv();
        conv.ask(spokenText);
        conv.ask(new BasicCard({
            title: `Test Message`,
            subTitle: `Dialogflow Test`,
            image: new Image({
                url: 'https://dummyimage.com/600x400/000/fff',
                alt: 'Image alternate text',
            }),
            text: spokenText,
            buttons: new Button({
                title: 'This is a button',
                url: 'https://assistant.google.com/',
            }),
        }));
        // Add Actions on Google library responses to your agent's response
        agent.add(conv);
    } else {
        agent.add(spokenText);
    }
}

/* When the Channel Intent gets invoked. */
function channelHandler(agent) {
    var listItems = {};
    spokenText = getSpeech(results);

    for (var i = 0; i < results['Listings'].length; i++) {
        listItems[`SELECT_${i}`] = {
            title: `${getSpokenTime(results['Listings'][i]['Time'])} - ${results['Listings'][i]['Title']}`,
            description: `Channel: ${results['Name']}`

        }
    }
    if (agent.requestSource === agent.ACTIONS_ON_GOOGLE) {
        let conv = agent.conv();
        conv.ask(spokenText);
        conv.ask(new List({
            title: 'TV Guide',
            items: listItems
        }));
        // Add Actions on Google library responses to your agent's response
        agent.add(conv);
    } else {
        agent.add(spokenText);
    }
}

/**
 * Return a text string to be spoken out by the Google Assistant
 * @param {object} JSON tv results
 */
var getSpeech = function(tvresults) {
    let s = "";
    if(tvresults && tvresults['Listings'][0]) {
        let channelName = tvresults['Name'];
        let currentlyPlayingTime = getSpokenTime(tvresults['Listings'][0]['Time']);
        let laterPlayingTime = getSpokenTime(tvresults['Listings'][1]['Time']);
        s = `On ${channelName} from ${currentlyPlayingTime}, ${tvresults['Listings'][0]['Title']} is playing.
        Afterwards at ${laterPlayingTime}, ${tvresults['Listings'][1]['Title']} will start.`
    }

    return s;
}

/**
 * Return a natural spoken time
 * @param {string} time in 'HH:mm:ss' format
 * @returns {string} spoken time (like 8 30 pm i.s.o. 20:00:00)
 */
var getSpokenTime = function(time){
    let datetime = moment(time, 'HH:mm:ss');
    let min = moment(datetime).format('m');
    let hour = moment(datetime).format('h');
    let partOfTheDay = moment(datetime).format('a');

    if (min == '0') {
        min = '';
    }

    return `${hour} ${min} ${partOfTheDay}`;
};

exports.dialogflowFirebaseFulfillment = functions.https.onRequest((request, response) => {
    var agent = new WebhookClient({ request, response });

    console.log('Dialogflow Request headers: ' + JSON.stringify(request.headers));
    console.log('Dialogflow Request body: ' + JSON.stringify(request.body));
   
    let channelInput = request.body.queryResult.parameters.channel;
    let requestedTime = request.body.queryResult.parameters.time;
    let url = `${TVGUIDE_WEBSERVICE}/${channelInput}`;

    if (requestedTime) {
        console.log(requestedTime);
        let offsetMin = moment().utcOffset(requestedTime)._offset;
        console.log(offsetMin);
        let time = moment(requestedTime).utc().add(offsetMin,'m').format('HH:mm:ss');
        url = `${TVGUIDE_WEBSERVICE}/${channelInput}/${time}`;
      }
    
      console.log(url);
  
      var options = {
          uri: encodeURI(url),
          json: true
      };
       
      // request promise calls an URL and returns the JSON response.
      rp(options)
        .then(function(tvresults) {
            console.log(tvresults);
            // the JSON response, will need to be formatted in 'spoken' text strings.
            spokenText = getSpeech(tvresults);
            results = tvresults;
        })
        .catch(function (err) {
            console.error(err);
        })
        .finally(function(){
            // kick start the Dialogflow app
            // based on an intent match, execute
            var intentMap = new Map();
            intentMap.set('Test Intent', testHandler);
            intentMap.set('Channel Intent', channelHandler);
            agent.handleRequest(intentMap);
        });
});

6. Actions on Google

Actions on Google 是 Google 助理的开发平台。它允许第三方开发“操作”功能,即为 Google 助理提供扩展功能的 applet。

您需要通过让 Google 打开或与应用对话来调用 Google 操作。

这样会打开您的操作,更改语音,并使您离开“原生”Google 助理范围。也就是说,从现在开始,您向代理提出的所有要求都需要由您自己创建。如果您想让 Google 助理提供 Google 天气信息,就不能突然提出此要求;您应先退出(关闭)操作范围(您的应用)。

在 Google 助理模拟器中测试您的 action

我们来测试以下对话:

用户

Google 助理

“Hey Google,与your-name-tv-guide对话。”

“没问题。让我获取 your-name-tv-guide。”

用户

Your-Name-TV-Guide Agent

-

“欢迎,我是电视指南....”

测试我的代理

“这是一条测试消息,如果您看到此消息,则表示您的 Webhook 实现正常运行!”

MTV 有什么节目?

下午 4 点 30 分起,MTV 正在播放 MTV Unplugged。之后,在下午 5 点 30 分,Rupauls Drag Race 将开始。

  1. 切换回 Google 助理模拟器

打开: https://console.actions.google.com

  1. 点击麦克风图标,然后提出以下问题:

c3b200803c7ba95e.png

  • Talk to my test agent
  • Test my agent

Google 助理应回答:

5d93c6d037c8c8eb.png

  1. 现在,我们来问一下:
  • What's on Comedy Central?

此时应返回:

目前,美国喜剧中心频道正在播放《辛普森一家》(从下午 6 点开始)。之后,晚上 7 点,《恶搞之家》将开始播出。

7. 恭喜

您已使用 Dialogflow 创建了第一个 Google 助理 action,做得好!

您可能已经注意到,您的操作是在与您的 Google 账号相关联的测试模式下运行的。如果您在 Nest 设备或 iOS/Android 手机上的 Google 助理应用中使用同一账号登录,您也可以测试操作。

现在,我们来演示一下工作坊。不过,当您真正为 Google 助理构建应用时,可以提交 Action 以供审批。如需了解详情,请参阅本指南。

所学内容

  • 如何使用 Dialogflow v2 创建聊天机器人
  • 如何使用 Dialogflow 创建自定义实体
  • 如何使用 Dialogflow 创建线性对话
  • 如何使用 Dialogflow 和 Firebase Functions 设置 Webhook fulfillment
  • 如何通过 Actions on Google 将应用引入 Google 助理

后续操作

喜欢此 Codelab 吗?快来看看这些精彩的实验吧!

通过将此代码库集成到 Google Chat 中来继续学习:

使用 G Suite 和 Dialogflow 创建电视指南 Google Chat