Skip to content

Latest commit

 

History

History
1126 lines (899 loc) · 46.8 KB

File metadata and controls

1126 lines (899 loc) · 46.8 KB

十一、适配卡和自定义图形

在整本书中,我们讨论了机器人与用户交流的不同方式。机器人可以使用文本、语音、图像、按钮或传送带。这些与正确的语气和数据相结合,成为用户快速有效地完成目标的强大界面。我们可以很容易地用正确的数据构建文本,但文本可能并不总是传达某些想法的最有效的机制。让我们以股票报价为例。比如说,当用户向 Twitter 询价时,他们在寻找什么样的数据?

他们在找最后的价格吗?他们是在寻求销量吗?他们在找出价/要价吗?也许他们想看看 52 周的最高价和最低价是多少。事实是,每个用户可能都在寻找稍微不同的东西。股票的文本描述对语音助手来说可能是有意义的。我们预计 Alexa 会说,“Twitter,代码 TWTR,交易价格为 24.47 美元,交易量为 810 万。52 周的范围是 14.12 美元到 25.56 美元。当前出价为 24.46 美元,当前要价为 24.47 美元。”你能想象用机器人接收这些数据吗?坦率地说,解析文本是痛苦的。

一个吸引人的选择是将内容放在卡片内,如图 11-1 所示。这个例子来自 TD Ameritrade Messenger bot。包含在文本消息中的许多相同的数据是通过图形来传达的,然而这种格式对于人类来说更容易使用。

img/455925_1_En_11_Chapter/455925_1_En_11_Fig1_HTML.jpg

图 11-1

股票报价卡

一张普通的英雄卡并没有留下多少空间来创建这样的界面。标题、副标题和按钮很容易,但图像却不容易。我们如何在我们的机器人中包含这样的视觉效果?在这一章中,我们将探讨两种方法:使用无头浏览器和自适应卡的自定义图像呈现,这是一种微软的连接器可以以特定于通道的方式呈现的格式。我们将首先深入研究适配卡。

适配卡

当 Bot 框架首次发布时,微软创建了英雄卡。正如我们在第 45 章中所探讨的,英雄卡是对不同消息平台用文本和按钮呈现图像的不同方式的伟大抽象。然而,很明显英雄卡有一点局限性,因为它们只由图像、标题、副标题和可选按钮组成。

为了提供更灵活的用户界面,微软创造了自适应卡。自适应卡对象模型描述了消息传递应用中更丰富的用户界面。通道连接器负责将自适应卡定义转换成通道支持的任何形式。基本上就是英雄卡的丰富多了的版本。

自适应卡在 Build 2017 大会上公布。作为聊天机器人开发者,我们现在有一种格式来描述丰富的用户界面。这种格式本身是 JSON 格式中类似 XAML 的布局引擎和类似 HTML 的概念的混合。

以下是一张餐厅卡的示例及其在图 11-2 中的呈现:

img/455925_1_En_11_Chapter/455925_1_En_11_Fig2_HTML.jpg

图 11-2

餐厅卡片渲染

{
    "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
    "type": "AdaptiveCard",
    "version": "1.0",
    "body": [
        {
            "speak": "Tom's Pie is a Pizza restaurant which is rated 9.3 by customers.",
            "type": "ColumnSet",
            "columns": [
                {
                    "type": "Column",
                    "width": 2,
                    "items": [
                        {
                            "type": "TextBlock",
                            "text": "PIZZA"
                        },
                        {
                            "type": "TextBlock",
                            "text": "Tom's Pie",

                            "weight": "bolder",
                            "size": "extraLarge",
                            "spacing": "none"
                        },
                        {
                            "type": "TextBlock",
                            "text": "4.2 ★★★☆ (93) · $$",
                            "isSubtle": true,
                            "spacing": "none"
                        },
                        {
                            "type": "TextBlock",
                            "text": "**Matt H. said** \"I'm compelled to give this place 5 stars due to the number of times I've chosen to eat here this past year!\"",
                            "size": "small",
                            "wrap": true
                        }
                    ]
                },
                {
                    "type": "Column",
                    "width": 1,
                    "items": [
                        {
                            "type": "Image",
                            "url": "https://picsum.photos/300?image=882",
                            "size": "auto"
                        }
                    ]
                }
            ]
        }
    ],
    "actions": [
        {
            "type": "Action.OpenUrl",
            "title": "More Info",
            "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
        }

    ]
}

在自适应卡中,几乎所有东西都是一个容器,可以包含其他容器或 UI 元素。结果是一个 UI 对象树,就像任何其他标准的 UI 平台一样。在这个例子中,我们有一个包含两列的容器。第一列的宽度是第二列的两倍,包含四个 TextBlock 元素。第二列只包含一个图像。最后,卡片包括一个打开网址的动作。下面是另一个例子及其效果图(图 11-3 ):

img/455925_1_En_11_Chapter/455925_1_En_11_Fig3_HTML.jpg

图 11-3

数据收集模板

{
  "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
  "type": "AdaptiveCard",
  "version": "1.0",
  "body": [
    {
      "type": "ColumnSet",
      "columns": [
        {
          "type": "Column",
          "width": 2,
          "items": [
            {
              "type": "TextBlock",
              "text": "Tell us about yourself",
              "weight": "bolder",
              "size": "medium"
            },
            {
              "type": "TextBlock",

              "text": "We just need a few more details to get you booked for the trip of a lifetime!",
              "isSubtle": true,
              "wrap": true
            },
            {
              "type": "TextBlock",
              "text": "Don't worry, we'll never share or sell your information.",
              "isSubtle": true,
              "wrap": true,
              "size": "small"
            },
            {
              "type": "TextBlock",
              "text": "Your name",
              "wrap": true
            },
            {
              "type": "Input.Text",
              "id": "myName",
              "placeholder": "Last, First"
            },
            {
              "type": "TextBlock",
              "text": "Your email",
              "wrap": true
            },
            {

              "type": "Input.Text",
              "id": "myEmail",
              "placeholder": "youremail@example.com",
              "style": "email"
            },
            {
              "type": "TextBlock",
              "text": "Phone Number"
            },
            {
              "type": "Input.Text",
              "id": "myTel",
              "placeholder": "xxx.xxx.xxxx",
              "style": "tel"
            }
          ]
        },
        {
          "type": "Column",
          "width": 1,
          "items": [
            {
              "type": "Image",
              "url": "https://upload.wikimedia.org/wikipedia/commons/b/b2/Diver_Silhouette%2C_Great_Barrier_Reef.jpg",
              "size": "auto"
            }
          ]
        }
      ]
    }
  ],
  "actions": [
    {
      "type": "Action.Submit",
      "title": "Submit"
    }

  ]
}

这是一个相似的整体布局,两列的宽度比为 2:1。第一列包含不同大小的文本以及三个输入字段。第二列包含一个图像。

我们在图 11-4 中再举一个例子,回忆一下我们对股票行情卡的讨论。

img/455925_1_En_11_Chapter/455925_1_En_11_Fig4_HTML.jpg

图 11-4

股票报价渲染

{
    "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
    "type": "AdaptiveCard",
    "version": "1.0",
    "speak": "Microsoft stock is trading at $62.30 a share, which is down .32%",
    "body": [
        {
            "type": "Container",
            "items": [
                {
                    "type": "TextBlock",
                    "text": "Microsoft Corp (NASDAQ: MSFT)",
                    "size": "medium",
                    "isSubtle": true
                },
                {
                    "type": "TextBlock",
                    "text": "September 19, 4:00 PM EST",
                    "isSubtle": true
                }
            ]
        },
        {
            "type": "Container",
            "spacing": "none",
            "items": [
                {
                    "type": "ColumnSet",
                    "columns": [
                        {
                            "type": "Column",
                            "width": "stretch",
                            "items": [
                                {
                                    "type": "TextBlock",
                                    "text": "75.30",
                                    "size": "extraLarge"
                                },
                                {
                                    "type": "TextBlock",
                                    "text": "▼ 0.20 (0.32%)",
                                    "size": "small",
                                    "color": "attention",
                                    "spacing": "none"
                                }
                            ]

                        },
                        {
                            "type": "Column",
                            "width": "auto",
                            "items": [
                                {
                                    "type": "FactSet",
                                    "facts": [
                                        {
                                            "title": "Open",
                                            "value": "62.24"
                                        },
                                        {
                                            "title": "High",
                                            "value": "62.98"
                                        },
                                        {
                                            "title": "Low",
                                            "value": "62.20"
                                        }
                                    ]
                                }
                            ]
                        }
                    ]
                }
            ]
        }
    ]
}

这个模板引入了更多的概念。首先,卡片有两个容器而不是列。第一个容器只显示两个文本块,其中包含公司名称/股票代码和报价日期。第二个容器包含两列。一个有最近的价格和变化数据,另一个有开盘/高/低数据。后一种数据存储在类型为 FactSet 的对象中,这是一个名称-值对的集合,呈现为一个紧密间隔的组。

Adaptive Cards 网站提供了各种丰富的示例。 1 同一个站点上,可视化者 2 明确表示,Bot 框架聊天机器人只是适配卡的一小部分。各个 Bot 框架通道以不同的保真度得到支持。模拟器如实地呈现了卡片,但是许多其他通道如 Facebook Messenger 会产生图像(图 11-5 )。

img/455925_1_En_11_Chapter/455925_1_En_11_Fig5_HTML.jpg

图 11-5

Messenger 将自适应卡渲染为图像

公平地说,微软的脸书连接器会向任何具有不支持功能的适配卡返回一个错误请求(400)状态代码。这真正抓住了这里的困境。拥有一个通用的富卡格式是一个积极的发展,但前提是它得到广泛的支持。在脸书这样的平台上缺乏支持是有害的。值得注意的是,可视化工具中允许的主机应用讲述了一个更广泛的自适应卡故事(图 11-6 )。

img/455925_1_En_11_Chapter/455925_1_En_11_Fig6_HTML.jpg

图 11-6

Adaptive Card Visualizer 中可能的呈现选项

请注意,前七项(网络聊天、Cortana 技能、Windows 时间表、Skype、Outlook 可操作消息、微软团队和 Windows 通知)都是微软控制范围内的系统。微软正在构建一种通用格式来呈现其众多资产中的卡片。

简而言之,如果您的应用面向许多微软系统,如 Windows 10、Teams 和 Skype,投资于可重复使用且一致的跨平台适配卡是一个好主意。

微软还提供了几个 SDK 来帮助你的定制应用渲染适配卡。例如,有 iOS SDK、客户端 JavaScript SDK 和 Windows SDK 每个人都可以使用 adaptive card JSON,并从中呈现一个原生 UI。

一个工作实例

我们现在来看一个例子,以便更好地理解自适应卡是如何呈现的,以及它们是如何将输入表单消息发送回机器人的。我们将使用模拟器作为我们的通道,因为它实现了所有重要的功能。我们将使用前一个示例中稍加修改的卡来收集用户的姓名、电话号码和电子邮件地址。

{
        "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
        "type": "AdaptiveCard",
        "version": "1.0",
        "body": [
            {
                "type": "TextBlock",
                "text": "Tell us about yourself",
                "weight": "bolder",
                "size": "medium"
            },
            {
                "type": "TextBlock",
                "text": "Don't worry, we'll never share or sell your information.",
                "isSubtle": true,
                "wrap": true,
                "size": "small"
            },
            {
                "type": "TextBlock",
                "text": "Your name",
                "wrap": true
            },
            {
                "type": "Input.Text",
                "id": "name",
                "placeholder": "First Last"
            },
            {
                "type": "TextBlock",
                "text": "Your email",
                "wrap": true
            },
            {
                "type": "Input.Text",
                "id": "email",
                "placeholder": "youremail@example.com",
                "style": "email"

            },
            {
                "type": "TextBlock",
                "text": "Phone Number"
            },
            {
                "type": "Input.Text",
                "id": "tel",
                "placeholder": "xxx.xxx.xxxx",
                "style": "tel"
            }
        ],
        "actions": [
            {
                "type": "Action.Submit",
                "title": "Submit"
            },
            {
                "type": "Action.ShowCard",
                "title": "Terms and Conditions",
                "card": {
                    "type": "AdaptiveCard",
                    "body": [
                        {
                            "type": "TextBlock",
                            "text": "We will not share your data with anyone. Ever.",
                            "size": "small",
                        }
                    ]
                }
            }
        ]
    }

我们还将允许用户单击两个项目中的任何一个:一个提交按钮来发送数据,一个条款和条件按钮在单击时显示一些额外的信息。当用户点击提交时,来自字段的数据被收集并发送给机器人,作为通过消息的属性公开的对象。先前 JSON 中定义的自适应卡发送的对象将有三个属性:name、email 和 tel。属性名对应于字段 id

因此,获取这些值的代码非常简单。它可以像简单地检查值是否存在并基于它执行逻辑一样简单。如果我们发送多张名片,因为它们会留在用户的聊天记录中,所以确保一致的对话体验也很重要。

const bot = new builder.UniversalBot(connector, [
    (session) => {

        let incoming = session.message;
        if (incoming.value) {
            // this means we are getting data from an adaptive card
            let o = incoming.value;
            session.send('Thanks ' + o.name.split(' ')[0] + ". We'll be in touch!");
        } else {
            let msg = new builder.Message(session);
            msg.addAttachment({
                contentType: 'application/vnd.microsoft.card.adaptive',
                content: adaptiveCardJson
            });
            session.send(msg);
        }
    }
]);

11-7 展示了这种对话会如何发展。请注意,除了一些小的验证之外,卡本身没有实际的逻辑。将来可能会有这样的能力,但目前所有这样的逻辑必须出现在机器人代码中。

img/455925_1_En_11_Chapter/455925_1_En_11_Fig7_HTML.jpg

图 11-7

展开条款和条件并单击提交后的输入表单自适应卡

练习 11-1

创建自定义适配卡

  1. 本练习的目标是创建一个功能正常的天气更新适配卡。您将集成一个天气 API,为聊天机器人的用户提供实时天气。创建一个机器人来收集用户的位置,也许只是一个邮政编码,并返回一条回显该位置的文本消息。

  2. 编写与 Yahoo 天气 API 集成所需的代码。你可以在 https://developer.yahoo.com/weather/ 找到使用信息。

  3. 创建一个包含服务提供的各种数据点的自适应卡。Adaptive Cards 网站提供了两个天气样本;如果你愿意,你可以使用其中的一个。完成后,在适配卡 JSON 中切换一些 UI 元素。这样做有多容易?

  4. 添加图形图像元素。例如,显示不同的图形来代表晴天和阴天。您可能会使用在线图像搜索找到一些资产,或者在本地托管一些图像。如果您在本地托管它们,请确保您设置为提供静态内容。

干得好!你现在可以用自适应卡来丰富你的机器人的对话体验。

呈现自定义图形

自适应卡简化了某些类型的布局,并允许我们声明性地定义可以渲染到图像中的自定义布局。然而,我们无法控制图像的使用方式;正如我们在 Messenger 上看到的,图像是作为独立的图像发送的,没有任何上下文按钮或卡片格式的文本。除了大小、边距和布局控制的其他小限制之外,我们没有生成图形的方法。假设我们想要生成一个图表来表示一段时间内的股票价格。使用适配卡无法做到这一点。如果我们有另一种方法呢?

创建自定义图形的最佳方式是利用我们已经熟悉的技术,如 HTML、JavaScript 和 CSS!如果我们可以直接使用 HTML 和 CSS,我们就可以创建定制的、品牌化的、漂亮的布局来表示我们对话体验中的各种概念。使用 SVG 和 JavaScript,我们将能够创建令人惊叹的数据驱动的图形,使我们的机器人内容栩栩如生。

好的,我们被卖了。但是我们如何做到这一点呢?我们将稍微绕道进入一种可以用来呈现这些工件的机制:无头浏览器。

像 Firefox 或 Chrome 这样的标准普通浏览器有许多组件:网络层;符合标准的 HTML 引擎,如 Gecko、WebKit 或 Chromium 最后是允许您查看实际内容的 UI。无头浏览器是没有 UI 组件的浏览器。通常,使用命令行或脚本语言来控制这些浏览器。无头浏览器最初也是最重要的用例是在启用了 JavaScript 和 AJAX 的环境中进行功能测试等任务。例如,搜索引擎可以使用无头浏览器来索引动态网页内容。Phantom 3 是基于 WebKit 的无头浏览器的一个例子,在 AngularJS 早期被大量使用。Firefox 4 和 Chrome 5 最近在它们的浏览器中都增加了对无头模式的支持。在这个领域越来越常见的用途之一是图像渲染。所有的无头浏览器都实现了截图功能,我们可以利用它来满足图像渲染的需求。

我们将继续我们的股票报价示例,并构建一些可以以文本形式返回报价的内容。完整的工作代码示例可以在本书的 GitHub repo 中的chapter11-image-rendering-bot文件夹下找到。为此,我们需要访问财务数据提供商。一个易于使用的提供者叫做 Intrinio,它提供免费帐户开始使用他们的 API。转到 http://intrinio.com 并点击开始免费按钮创建一个帐户来使用他们的 API。一旦我们完成了帐户创建过程,我们就可以访问我们的访问密钥,这些密钥必须通过基本的 HTTP 认证传递给 API。使用类似 https://api.intrinio.com/data_point?ticker=AAPL&item=last_price,volume 的 URL,我们得到 AAPL 的最新价格和交易量。生成的数据 JSON 如下所示:

{
    "data": [
        {
            "identifier": "AAPL",
            "item": "last_price",
            "value": 174.32
        },
        {
            "identifier": "AAPL",
            "item": "volume",
            "value": 20179172
        }
    ],
    "result_count": 2,
    "api_call_credits": 2
}

创建一个使用这个 API 的机器人可以通过使用下面的代码来完成,导致图 11-8 中的对话:

img/455925_1_En_11_Chapter/455925_1_En_11_Fig8_HTML.jpg

图 11-8

文本股票报价

require('dotenv-extended').load();

const builder = require('botbuilder');
const restify = require('restify');
const request = require('request');
const moment = require('moment');
const _ = require('underscore');
const puppeteer = require('puppeteer');
const vsprintf = require('sprintf').vsprintf;

// declare all of the data points we will be interested in
const datapoints = {
    last_price: 'last_price',
    last_year_low: '52_week_low',
    last_year_high: '52_week_high',
    ask_price: 'ask_price',
    ask_size: 'ask_size',
    bid_price: 'bid_price',
    bid_size: 'bid_size',
    volume: 'volume',

    name: 'name',
    change: 'change',
    percent_change: 'percent_change',
    last_timestamp: 'last_timestamp'
};

const url = "https://api.intrinio.com/data_point?ticker=%s&item=" + _.map(Object.keys(datapoints), p => datapoints[p]).join(',');

// Setup Restify Server
const server = restify.createServer();
server.listen(process.env.port || process.env.PORT || 3978, () => {
    console.log('%s listening to %s', server.name, server.url);
});

// Create chat bot and listen to messages
const connector = new builder.ChatConnector({
    appId: process.env.MICROSOFT_APP_ID,
    appPassword: process.env.MICROSOFT_APP_PASSWORD
});
server.post('/api/messages', connector.listen());

const bot = new builder.UniversalBot(connector, [
    session => {
        // get ticker and create request URL
        const ticker = session.message.text.toUpperCase();
        const tickerUrl = vsprintf(url, [ticker]);

        // make request to get the ticker data
        request.get(tickerUrl, {
            auth:
                {
                    user: process.env.INTRINIO_USER,
                    pass: process.env.INTRINIO_PASS
                }
        }, (err, response, body) => {
            if (err) {
                console.log('error while fetching data:\n' + err);
                session.endConversation('Error while fetching data. Please try again later.');
                return;
            }

            // parse JSON response and extract the last price
            const results = JSON.parse(body).data;
            const lastPrice = getval(results, ticker, datapoints.last_price).value;

            // send the last price as a response
            session.endConversation(vsprintf('The last price for %s is %.2f', [ ticker, lastPrice]));
        });
    }
]);

const getval = function(arr, ticker, data_point) {
    const r =  _.find(arr, p => p.identifier === ticker && p.item === data_point);
    return r;
}

const inMemoryStorage = new builder.MemoryBotStorage();
bot.set('storage', inMemoryStorage);

太好了。我们现在将创建一个自适应卡,看看如何利用我们刚刚介绍的无头浏览器来呈现更丰富的图形。

对于自适应卡,我们将使用从前面的股票更新场景修改而来的模板。我们没有在 endConversation 调用中发送字符串,而是发送回一张股票卡。 renderStockCard 函数获取从 API 返回的数据,并呈现适配卡 JSON。

const cardData = renderStockCard(results, ticker);
const msg = new builder.Message(session);
msg.addAttachment({
    contentType: 'application/vnd.microsoft.card.adaptive',
    content: cardData
});
session.endConversation(msg);

function renderStockCard(data, ticker) {
    const last_price = getval(data, ticker, datapoints.last_price).value;
    const change = getval(data, ticker, datapoints.change).value;
    const percent_change = getval(data, ticker, datapoints.percent_change).value;
    const name = getval(data, ticker, datapoints.name).value;

    const last_timestamp = getval(data, ticker, datapoints.last_timestamp).value;

    const open_price = getval(data, ticker, datapoints.open_price).value;
    const low_price = getval(data, ticker, datapoints.low_price).value;
    const high_price = getval(data, ticker, datapoints.high_price).value;
    const yearhigh = getval(data, ticker, datapoints.last_year_high).value;
    const yearlow = getval(data, ticker, datapoints.last_year_low).value;

    const bidsize = getval(data, ticker, datapoints.bid_size).value;
    const bidprice = getval(data, ticker, datapoints.bid_price).value;
    const asksize = getval(data, ticker, datapoints.ask_size).value;
    const askprice = getval(data, ticker, datapoints.ask_price).value;

    let color = 'default';

    if (change > 0) color = 'good';
    else if (change < 0) color = 'warning';

    let facts = [
        { title: 'Bid', value: vsprintf('%d x %.2f', [bidsize, bidprice]) },
        { title: 'Ask', value: vsprintf('%d x %.2f', [asksize, askprice]) },
        { title: '52-Week High', value: vsprintf('%.2f', [yearhigh]) },
        { title: '52-Week Low', value: vsprintf('%.2f', [yearlow]) }
    ];

    let card = {
        "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
        "type": "AdaptiveCard",
        "version": "1.0",
        "speak": vsprintf("%s stock is trading at $%.2f a share, which is down %.2f%%", [name, last_price, percent_change]),
        "body": [
            {
                "type": "Container",
                "items": [
                    {
                        "type": "TextBlock",
                        "text": vsprintf("%s ( %s)", [name, ticker]),
                        "size": "medium",
                        "isSubtle": false
                    },

                    {
                        "type": "TextBlock",
                        "text": moment(last_timestamp).format('LLL'),
                        "isSubtle": true
                    }
                ]
            },
            {
                "type": "Container",
                "spacing": "none",
                "items": [
                    {
                        "type": "ColumnSet",
                        "columns": [
                            {
                                "type": "Column",
                                "width": "stretch",
                                "items": [
                                    {
                                        "type": "TextBlock",
                                        "text": vsprintf("%.2f", [last_price]),
                                        "size": "extraLarge"
                                    },
                                    {
                                        "type": "TextBlock",
                                        "text": vsprintf("%.2f (%.2f%%)", [change, percent_change]),
                                        "size": "small",
                                        "color": color,
                                        "spacing": "none"
                                    }
                                ]

                            },
                            {
                                "type": "Column",
                                "width": "auto",
                                "items": [

                                    {
                                        "type": "FactSet",
                                        "facts": facts
                                    }
                                ]
                            }
                        ]
                    }
                ]
            }
        ]
    }

    return card;
}

现在,如果我们向机器人发送一个股票代码,我们将得到一个自适应卡。模拟器上的渲染看起来不错(图 11-9 )。信使渲染有点断断续续和像素化(图 11-10 )。我们还发现了两个通道呈现“警告”颜色的不一致。我们当然可以做得更好。

img/455925_1_En_11_Chapter/455925_1_En_11_Fig10_HTML.jpg

图 11-10

股票 u-update 卡的信使渲染

img/455925_1_En_11_Chapter/455925_1_En_11_Fig9_HTML.jpg

图 11-9

股票更新卡的模拟器渲染

我们现在将创建我们自己的自定义 HTML 模板。现在,作为一名工程师,我不做设计,但是图 11-11 是我想出来的卡片。我们显示与之前相同的所有数据,但是我们还为最近 30 天的数据添加了迷你图。

img/455925_1_En_11_Chapter/455925_1_En_11_Fig11_HTML.jpg

图 11-11

我们想要支持的定制报价卡

早期模板的 HTML 和 CSS 如下所示:

<html>
<head>
    <style>
        body {
            background-color: white;
            font-family: 'Roboto', sans-serif;
            margin: 0;
            padding: 0;
        }

        .card {
            color: #dddddd;
            background-color: black;
            width: 564px;
            height: 284px;
            padding: 10px;
        }

        .card .symbol {
            font-size: 48px;
            vertical-align: middle;
        }

        .card .companyname {
            font-size: 52px;
            display: inline-block;
            vertical-align: middle;
            overflow-x: hidden;
            white-space: nowrap;
            text-overflow: ellipsis;
            max-width: 380px;
        }

        .card .symbol::before {
            content: '(';
        }

        .card .symbol::after {
            content: ')';
        }

        .card .priceline {
            margin-top: 20px;
        }

        .card .price {
            font-size: 36px;
            font-weight: bold;
        }

        .card .change {
            font-size: 28px;
        }

        .card .changePct {
            font-size: 28px;
        }

        .card .positive {
            color: darkgreen;

        }

        .card .negative {
            color: darkred;
        }

        .card .changePct::before {
            content: '(';
        }

        .card .changePct::after {
            content: ')';
        }

        .card .factTable {
            margin-top: 10px;
            color: #dddddd;
            width: 100%;
        }

        .card .factTable .factTitle {
            width: 50%;
            font-size: 24px;
            padding-bottom: 5px;
        }

        .card .factTable .factValue {
            width: 50%;
            text-align: right;
            font-size: 24px;
            font-weight: bold;
            padding-bottom: 5px;
        }
        .sparkline {
            padding-left: 10px;
        }
        .sparkline embed {
            width: 300px;
            height: 40px;
        }
    </style>
    <link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet">
</head>

<body>
    <div class="card">
        <div class="header">
                <span class="companyname">Microsoft</span>
                <span class="symbol">MSFT</span>
        </div>
        <div class="priceline">
            <span class="price">88.22</span>
            <span class="change negative">-0.06</span>
            <span class="changePct negative">-0.07%</span>
            <span class="sparkline">
                <embed src="http://sparksvg.me/line.svg?174.33,174.35,175,173.03,172.23,172.26,169.23,171.08,170.6,170.57,175.01,175.01,174.35,174.54,176.42,173.97,172.22,172.27,171.7,172.67,169.37,169.32,169.01,169.64,169.8,171.05,171.85,169.48,173.07,174.09&rgba:255,255,255,0.7"
                    type="image/svg+xml">
            </span>

        </div>
        <table class="factTable">

            <tr>
                <td class="factTitle">Bid</td>
                <td class="factValue">100 x 87.98</td>
            </tr>
            <tr>
                <td class="factTitle">Ask</td>
                <td class="factValue">200 x 89.21</td>
            </tr>
            <tr>
                <td class="factTitle">52 Week Low</td>
                <td class="factValue">80.22</td>
            </tr>
            <tr>
                <td class="factTitle">52 Week High</td>
                <td class="factValue">90.73</td>
            </tr>

        </table>
    </div>

</body>

</html>

请注意,我们正在做三件事,这显然是 adaptive cards 不可能做到的:CSS 允许的对样式的细粒度控制、自定义 web 字体(在本例中是 Google 的 Roboto 字体)和绘制迷你图的 SVG 对象。此时,我们真正要做的就是在 HTML 模板中修改适当的数据并呈现出来。我们如何做到这一点?

从我们之前提到的不同选项来看,今天比较好的选项之一是 Chrome。与 headless Chrome 集成的最简单方法是使用名为 Puppeteer 的 Node.js 包。这个库可以用于许多任务,例如自动化 Chrome、截图、收集网站的时间轴数据以及运行自动化测试套件。我们将使用基本的 API 来截取一个页面的屏幕截图。

木偶样本使用 Node 版本 7.6 中引入的异步/等待 7 功能。语法等待一个承诺值在一行中返回,而不是写一串然后方法调用。呈现 HTML 片段的代码如下所示:

async function renderHtml(html, width, height) {
    var browser = await puppeteer.launch();
    const page = await browser.newPage();

    await page.setViewport({ width: width, height: height });
    await page.goto(`data:text/html,${html}`, { waitUntil: 'load' });
    const pageResultBuffer = await page.screenshot({ omitBackground: true });
    await page.close();
    browser.disconnect();
    return pageResultBuffer;
}

我们启动一个新的 headless chrome 实例,打开一个新页面,设置视窗的大小,加载 HTML,然后截图。 omitBackground 选项允许我们在 HTML 中有透明的背景,这导致了透明的屏幕截图背景。

结果对象是 Node.js 缓冲区。缓冲区只是二进制数据的集合,Node.js 提供了许多函数来使用这些数据。我们可以调用我们的 renderHtml 方法,将缓冲区转换成 base64 字符串。一旦有了这些,我们就可以简单地将 base64 图像作为 Bot Builder 附件的一部分发送出去。

renderHtml(html, 600, 312).then(cardData => {
    const base64image = cardData.toString('base64');
    const contentType = 'image/png';
    const attachment = {
        contentUrl: util.format('data:%s;base64,%s', contentType, base64image),
        contentType: contentType,
        name: ticker + '.png'
    }

    const msg = new builder.Message(session);
    msg.addAttachment(attachment);
    session.endConversation(msg);    
});

构建 HTML 是字符串操作,以确保填充正确的值。我们在 HTML 中添加了一些占位符,以便于进行字符串替换调用,将数据放入适当的位置。这里显示了其中的一个片段:

<div class="priceline">
    <span class="price">${last_price}</span>
    <span class="change ${changeClass}">${change}</span>
    <span class="changePct ${changeClass}">${percent_change}</span>
    <span class="sparkline">
        <embed src="http://sparksvg.me/line.svg?${sparklinedata}&rgba:255,255,255,0.7" type="image/svg+xml">
    </span>
</div>

下面是从 Intrinio 端点获取数据、读取卡片模板 HTML、替换正确的值、呈现 HTML 并将其作为附件发送的完整代码。一些样本结果如图 11-12 所示。

img/455925_1_En_11_Chapter/455925_1_En_11_Fig12_HTML.jpg

图 11-12

自定义 HTML 图像的不同渲染

        request.get(tickerUrl, opts, (quote_error, quote_response, quote_body) => {
            request.get(pricesTickerUrl, opts, (prices_error, prices_response, prices_body) => {
                if (quote_error) {
                    console.log('error while fetching data:\n' + quote_error);
                    session.endConversation('Error while fetching data. Please try again later.');
                    return;
                } else if (prices_error) {
                    console.log('error while fetching data:\n' + prices_error);
                    session.endConversation('Error while fetching data. Please try again later.');
                    return;
                }

                const quoteResults = JSON.parse(quote_body).data;
                const priceResults = JSON.parse(prices_body).data;

                const prices = _.map(priceResults, p => p.close);
                const sparklinedata = prices.join(',');

                fs.readFile("cardTemplate.html", "utf8", function (err, data) {
                    const last_price = getval(quoteResults, ticker, datapoints.last_price).value;
                    const change = getval(quoteResults, ticker, datapoints.change).value;
                    const percent_change = getval(quoteResults, ticker, datapoints.percent_change).value;
                    const name = getval(quoteResults, ticker, datapoints.name).value;
                    const last_timestamp = getval(quoteResults, ticker, datapoints.last_timestamp).value;
                    const yearhigh = getval(quoteResults, ticker, datapoints.last_year_high).value;
                    const yearlow = getval(quoteResults, ticker, datapoints.last_year_low).value;

                    const bidsize = getval(quoteResults, ticker, datapoints.bid_size).value;
                    const bidprice = getval(quoteResults, ticker, datapoints.bid_price).value;
                    const asksize = getval(quoteResults, ticker, datapoints.ask_size).value;
                    const askprice = getval(quoteResults, ticker, datapoints.ask_price).value;

                    data = data.replace('${bid}', vsprintf('%d x %.2f', [bidsize, bidprice]));
                    data = data.replace('${ask}', vsprintf('%d x %.2f', [asksize, askprice]));
                    data = data.replace('${52weekhigh}', vsprintf('%.2f', [yearhigh]));
                    data = data.replace('${52weeklow}', vsprintf('%.2f', [yearlow]));
                    data = data.replace('${ticker}', ticker);
                    data = data.replace('${companyName}', name);
                    data = data.replace('${last_price}', last_price);

                    let changeClass = '';
                    if(change > 0) changeClass = 'positive';
                    else if(change < 0) changeClass = 'negative';

                    data = data.replace('${changeClass}', changeClass);
                    data = data.replace('${change}', vsprintf('%.2f%%', [change]));
                    data = data.replace('${percent_change}', vsprintf('%.2f%%', [percent_change]));
                    data = data.replace('${last_timestamp}', moment(last_timestamp).format('LLL'));
                    data = data.replace('${sparklinedata}', sparklinedata);

                    renderHtml(data, 584, 304).then(cardData => {
                        const base64image = cardData.toString('base64');

                        const contentType = 'image/png';
                        const attachment = {
                            contentUrl: util.format('data:%s;base64,%s', contentType, base64image),
                            contentType: contentType,
                            name: ticker + '.png'
                        }

                        const msg = new builder.Message(session);
                        msg.addAttachment(attachment);
                        session.endConversation(msg);
                    });
                });
            });
        });

考虑到我们在这上面花费的时间很短,这些确实是很好的结果!该图像在 Messenger 上也呈现得很好(图 11-13 )。

img/455925_1_En_11_Chapter/455925_1_En_11_Fig13_HTML.jpg

图 11-13

Messenger 中的图像呈现

然而,我们设定了一个目标,那就是创建定制卡片。好,我们将代码改为如下:

const card = new builder.HeroCard(session)
    .buttons([
        builder.CardAction.postBack(session, ticker, 'Quote Again')])
    .images([
        builder.CardImage.create(session, imageUri)
    ])
    .title(ticker + ' Quote')
    .subtitle('Last Updated: ' + moment(last_timestamp).format('LLL'));

const msg = new builder.Message(session);msg.addAttachment(card.toAttachment());
session.send(msg);

这在模拟器中表现得非常好,但是我们在 Messenger 中没有得到任何结果。如果我们查看 Node 输出,我们会很快注意到脸书返回了一个 HTTP 400 ( BadRequest )响应。发生什么事了?尽管脸书支持带有嵌入式 Base64 图像的数据 URIs,但它不支持卡片图像的这种格式。我们可以在 bot 中创建一个返回图像的端点,但是脸书还有一个限制:webhook 和卡片图像的 URI 不能有相同的主机名。

解决方案是让我们的机器人在其他地方托管生成的图像。一个很好的起点是基于云的 Blob 商店,比如亚马逊的 S3 或者微软的 Azure Storage。由于我们关注的是微软的堆栈,我们将继续使用 Azure 的 Blob 存储。我们将使用相关的 Node.js 包。

npm install azure-storage --save
const blob = azureStorage.createBlobService(process.env.IMAGE_STORAGE_CONNECTION_STRING);

IMAGE _ STORAGE _ CONNECTION _ STRING是一个存储 Azure 存储连接字符串的环境变量,在创建存储帐户资源后,可以在 Azure 门户中找到该字符串。在我们将图像生成到本地文件后,我们的代码必须确保 blob 容器存在,并从我们的图像创建 blob。然后,我们使用新 blob 的 URL 作为我们图像的来源。

renderHtml(data, 584, 304).then(cardData => {
    const uniqueId = uuid();

    const name = uniqueId + '.png';
    const pathToFile = 'img/' + name;
    fs.writeFileSync(pathToFile, cardData);

    const containerName = 'image-rendering-bot';
    blob.createContainerIfNotExists(containerName, {
        publicAccessLevel: 'blob'
    }, function (error, result, response) {
        if (!error) {
            blob.createBlockBlobFromLocalFile(containerName, name, pathToFile, function (error, result, response) {
                if (!error) {
                    fs.unlinkSync(pathToFile);
                    const imageUri = blob.getUrl(containerName, name);

                    const card = new builder.HeroCard(session)
                        .buttons([
                            builder.CardAction.postBack(session, ticker, 'Quote Again')])
                        .images([

                            builder.CardImage.create(session, base64Uri)
                        ])
                        .title(ticker + ' Quote')
                        .subtitle('Last Updated: ' + moment(last_timestamp).format('LLL'));

                    const msg = new builder.Message(session);
                    msg.addAttachment(card.toAttachment());
                    session.send(msg);
                } else {
                    console.error(error);
                }
            });
        } else {
            console.error(error);
        }
    });
});

现在卡片正在按照预期渲染,如图 11-14 所示。

img/455925_1_En_11_Chapter/455925_1_En_11_Fig14_HTML.jpg

图 11-14

卡片现在渲染!

练习 11-2

使用无头 Chrome 渲染图形

在本练习中,您将从练习 11-1 的天气机器人中获取代码,并添加自定义 HTML 渲染。

  1. 在您的自适应卡中,添加一个占位符,该占位符可以包含一个在图表中表示温度预测的图像。

  2. 使用 headless chrome 渲染图像,使用折线图显示预测。您可以使用与前面相同的迷你图方法。

  3. 将结果图像存储在 blob 存储中。

  4. 确保自适应卡在指定位置包含自定义渲染图像,并且可以在模拟器和 Facebook Messenger 中渲染。

现在,您已经将自定义 HTML 呈现与自适应卡混合在一起。没人说我们不能这么做,对吧?

结论

在这一章中,我们探讨了两种通过丰富的图形来传达复杂想法和聊天机器人品牌的方法。自适应卡是一种快速入门的方式,并允许与本地支持该格式的平台进行更深入的集成。基于 HTML 的自定义图像渲染允许对生成的图形进行更多的自定义和控制,在没有本地适配卡支持的情况下尤其有价值。两者都是非常吸引人的聊天机器人体验的绝佳选择。

Footnotes [1](#Fn1_source)

适配卡样本: http://adaptivecards.io/samples/

  2

适配卡可视化器: http://adaptivecards.io/visualizer/index.html

  3

幻像 Js: http://phantomjs.org/

  4

火狐无头模式: https://developer.mozilla.org/en-US/Firefox/Headless_mode

  5

无头 Chrome 入门: https://developers.google.com/web/updates/2017/04/headless-chrome

  6

木偶师,无头 Chrome Node.js API: https://github.com/GoogleChrome/puppeteer

  7

Mozilla 开发者网络等待文档: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await