From 0fb1988761d9b8ae9979bca41b22195431dcdf49 Mon Sep 17 00:00:00 2001 From: Olivier Felt Date: Wed, 11 Oct 2017 17:33:22 +0200 Subject: [PATCH] Support multi-language agents The module now supports multiple agents and different languages for each one. It will now query an agent based on the locale of the user or the fallback language. The config has changed to handle multiple agents, as such API_TOKEN is now depreciated. --- README.md | 15 ++++- src/index.js | 79 ++++++++++++++++------- src/views/index.jsx | 150 +++++++++++++++++++++++++++++++++++++------ src/views/style.scss | 2 +- 4 files changed, 200 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index a05e3a5..c7eee5d 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,19 @@ botpress install api.ai The API.AI module should now be available in your bot UI ## Features +### Multi-Language Agent +You can specify different agents and the languages they supports. + +This module will try to query the agent that support the locale of the user. +If no agent contains the locale, it will look for the root language (eg: en instead of en-GB) otherwise the default language parameter is used. + +For more information see https://api.ai/docs/multi-language + +### Mode This module has two modes: **Default** (amend incoming events) and **Fulfillment** (respond automatically). -### Default Mode +#### Default Mode This mode will inject understanding metadata inside incoming messages through the API.AI middleware. @@ -27,7 +36,7 @@ bp.hear({'nlp.action': 'smalltalk.person'}, (event, next) => { }) ``` -### Fulfillment Mode +#### Fulfillment Mode This mode will check if there's an available response in the `fulfillment` property of the API.AI response and respond automatically. No code required. @@ -48,7 +57,7 @@ Get an invite and join us now! 👉[https://slack.botpress.io](https://slack.bot | ENV | Default | Description | |---|---|---| | BOTPRESS_HTTP_TIMEOUT | 5000 | The timeout to API.AI requests | -| APIAI_TOKEN | null | Override the API token | +| APIAI_TOKEN | null | Override the API token (depreciated after v2.1.3)| ## License diff --git a/src/index.js b/src/index.js index 04579ba..d61488e 100644 --- a/src/index.js +++ b/src/index.js @@ -9,43 +9,77 @@ import axios from 'axios' let config = null let service = null -const getClient = () => { +const getAgent = (lang) => { + if (config.agents.length == 0) { + // back compatibility + return {clientToken: config.accessToken} + } + + for (const agent of config.agents) { + if (agent.langs.includes(lang)) { + return agent + } + } +} + +const getAvailableLang = (lang) => { + if (!lang) { + return config.lang + } + lang = lang.replace('_', '-') // convert bp locale format to api.ai format + + for (const agent of config.agents) { + if (agent.langs.includes(lang)) { + return lang + } + } + + const l = lang.split('-') + if (l.length == 2) { + return getAvailableLang(l[0]) + } + + return config.lang +} + +const getClient = (lang) => { return axios.create({ baseURL: 'https://api.api.ai/v1', timeout: process.env.BOTPRESS_HTTP_TIMEOUT || 5000, - headers: {'Authorization': 'Bearer ' + config.accessToken} + headers: {'Authorization': 'Bearer ' + getAgent(lang).clientToken} }) } const setService = () => { - service = (userId, text) => { - return getClient().post('/query?v=20170101', { + service = (userId, lang, text) => { + return getClient(lang).post('/query?v=20170101', { query: text, - lang: config.lang, + lang: lang, sessionId: userId }) } } -const contextAdd = userId => (name, lifespan = 1) => { - return getClient().post('/contexts?v=20170101', [ +const contextAdd = (userId, lang) => (name, lifespan = 1) => { + return getClient(lang).post('/contexts?v=20170101', [ { name, lifespan } ], { params: {sessionId: userId } }) } -const contextRemove = userId => name => { - return getClient().delete('/contexts/' + name, { params: { sessionId: userId } }) +const contextRemove = (userId, lang) => name => { + return getClient(lang).delete('/contexts/' + name, { params: { sessionId: userId } }) } const incomingMiddleware = (event, next) => { if (event.type === 'message') { - let shortUserId = event.user.id - if (shortUserId.length > 36) { - shortUserId = crypto.createHash('md5').update(shortUserId).digest("hex") - } + const lang = getAvailableLang(event.user.locale) + let shortUserId = event.user.id + if (shortUserId.length > 36) { + shortUserId = crypto.createHash('md5').update(shortUserId).digest("hex") + } - service(shortUserId, event.text) + service(shortUserId, lang, event.text) .then(({data}) => { const {result} = data if (config.mode === 'fulfillment' @@ -65,8 +99,8 @@ const incomingMiddleware = (event, next) => { } else { event.nlp = Object.assign(result, { context: { - add: contextAdd(shortUserId), - remove: contextRemove(shortUserId) + add: contextAdd(shortUserId, lang), + remove: contextRemove(shortUserId, lang) } }) next() @@ -84,14 +118,14 @@ const incomingMiddleware = (event, next) => { console.log(error.stack) - event.bp.logger.warn('botpress-api.ai', 'API Error. Could not process incoming text: ' + err); + event.bp.logger.warn('botpress-api.ai', 'API Error. Could not process incoming text: ' + err) next() }) } else { event.nlp = { context: { - add: contextAdd(shortUserId), - remove: contextRemove(shortUserId) + add: contextAdd(shortUserId, lang), + remove: contextRemove(shortUserId, lang) } } @@ -102,7 +136,8 @@ const incomingMiddleware = (event, next) => { module.exports = { config: { - accessToken: { type: 'string', env: 'APIAI_TOKEN' }, + accessToken: { type: 'string', env: 'APIAI_TOKEN' }, // back compatibility + agents: { type: 'any', required: true, default: [], validation: v => _.isArray(v) }, lang: { type: 'string', default: 'en' }, mode: { type: 'choice', validation: ['fulfillment', 'default'], default: 'default' } }, @@ -131,8 +166,8 @@ module.exports = { }) router.post('/config', async (req, res) => { - const { accessToken, lang, mode } = req.body - await configurator.saveAll({ accessToken, lang, mode }) + const { agents, lang, mode } = req.body + await configurator.saveAll({ agents, lang, mode }) config = await configurator.loadAll() setService() res.sendStatus(200) diff --git a/src/views/index.jsx b/src/views/index.jsx index ef8a151..6d2e78e 100644 --- a/src/views/index.jsx +++ b/src/views/index.jsx @@ -10,7 +10,10 @@ import { FormGroup, FormControl, Alert, - Button + Button, + Glyphicon, + ListGroup, + ListGroupItem } from 'react-bootstrap' import Markdown from 'react-markdown' @@ -22,8 +25,14 @@ const supportedLanguages = { 'zh-CN': "Chinese (Simplified)", 'zh-TW': "Chinese (Traditional)", 'en': "English", + 'en-AU': "English (Australian)", + 'en-CA': "English (Canadian)", + 'en-GB': "English (Great Britain)", + 'en-US': "English (United States)", 'nl': "Dutch", 'fr': "French", + 'fr-FR': "French (French)", + 'fr-CA': "French (Canadian)", 'de': "German", 'it': "Italian", 'ja': "Japanese", @@ -35,8 +44,20 @@ const supportedLanguages = { } const documentation = { + languages: ` + ### Multi-Language Agent + + You can specify different agents and the languages they support. + For each agent you need to specify a name (doesn't need to correspond to api.ai agent name), the client access token of the agent, and the languages it support based on [api.ai reference](https://api.ai/docs/reference/language) seperated by commas (eg: fr,en,it). + + This module will try to query the agent that support the locale of the user based on \`event.user.locale\`. + If no agent contains the locale, it will look for the root language (eg: en instead of en-GB) otherwise the fallback language setting is used. + + For more information, see "[Multi-language Agents](https://api.ai/docs/multi-language)" on api.ai. + ` + , default: ` - ### Default + ### Mode Default This mode will inject understanding metadata inside incoming messages through the API.AI middleware. @@ -51,7 +72,7 @@ const documentation = { \`\`\` ` , - fulfillment: `### Fulfillment + fulfillment: `### Mode Fulfillment This mode will check if there's an available response in the \`fulfillment\` property of the API.AI response and respond automatically. No code required. @@ -72,18 +93,19 @@ export default class ApiModule extends React.Component { initialStateHash: null } - this.renderAccessToken = this.renderAccessToken.bind(this) + this.renderAgent = this.renderAgent.bind(this) this.renderRadioButton = this.renderRadioButton.bind(this) this.renderLanguage = this.renderLanguage.bind(this) - this.handleAccesTokenChange = this.handleAccesTokenChange.bind(this) + this.handleAddToAgentList = this.handleAddToAgentList.bind(this) + this.handleRemoveFromAgentList = this.handleRemoveFromAgentList.bind(this) this.handleSaveChanges = this.handleSaveChanges.bind(this) this.handleRadioChange = this.handleRadioChange.bind(this) this.handleLanguageChange = this.handleLanguageChange.bind(this) } getStateHash() { - return this.state.accessToken + ' ' + this.state.lang + ' ' + this.state.mode + return this.state.agents + ' ' + this.state.lang + ' ' + this.state.mode } getAxios() { @@ -105,10 +127,52 @@ export default class ApiModule extends React.Component { }) }) } + + handleAddToAgentList() { + const name = ReactDOM.findDOMNode(this.newAgentName) + const clientToken = ReactDOM.findDOMNode(this.newAgentClientToken) + const langs = ReactDOM.findDOMNode(this.newAgentLangs) + const item = { + name: name && name.value, + clientToken: clientToken && clientToken.value, + langs: langs && langs.value.replace(/\s/g,'').split(',') + } - handleAccesTokenChange(event) { + let errors = [] + if (_.some(_.values(item), _.isEmpty)) { + errors.push("Fields can not be empty") + } + if (_.some(item.langs, v => !(v in supportedLanguages))) { + errors.push("A locale is not a supported language") + } + if (_.some(_.map(this.state.agents, 'langs'), a => _.some(a, v => item.langs.includes(v)))) { + errors.push("A language can only be part of one agent") + } + if (_.map(this.state.agents, 'clientToken').includes(item.clientToken)) { + errors.push("A client token must be unique") + } + if (errors.length > 0) { + this.setState({ + message: { + type: 'danger', + text: errors.join("; ") + } + }) + return + } + this.setState({ - accessToken: event.target.value + agents: _.concat(this.state.agents, item) + }) + + name.value = '' + clientToken.value = '' + lang.value = '' + } + + handleRemoveFromAgentList(value) { + this.setState({ + agents: _.without(this.state.agents, value) }) } @@ -119,16 +183,25 @@ export default class ApiModule extends React.Component { } handleLanguageChange(event) { - this.setState({ - lang: event.target.value - }) + if (_.some(_.map(this.state.agents, 'langs'), a => a.includes(event.target.value))) { + this.setState({ + lang: event.target.value + }) + } else { + this.setState({ + message: { + type: 'danger', + text: "The fallback language " + event.target.key + " is not present in any agents" + } + }) + } } handleSaveChanges() { this.setState({ loading:true }) return this.getAxios().post('/api/botpress-apiai/config', { - accessToken: this.state.accessToken, + agents: this.state.agents, lang: this.state.lang, mode: this.state.mode }) @@ -149,16 +222,47 @@ export default class ApiModule extends React.Component { }) }) } - - renderAccessToken() { + + renderAgent(item) { + const handleRemove = () => this.handleRemoveFromAgentList(item) + return + {item.name + ' | ' + item.clientToken + ' | ' + item.langs.join(', ')} + + + } + + renderAgentList() { return ( - Access Token + Agents - +
+ + + Current agents: + + {this.state.agents.map(this.renderAgent)} + + + + + + Add a new agent: + this.newAgentName = r} type="text" placeholder="name"/> + this.newAgentClientToken = r} type="text" placeholder="client access token"/> + this.newAgentLangs = r} type="text" placeholder="en,fr"/> + + + +
@@ -200,13 +304,18 @@ export default class ApiModule extends React.Component { } renderLanguage() { - const supportedLanguageOptions = _.mapValues(supportedLanguages, this.renderLanguageOption) + const langs = _.flatten(_.map(this.state.agents, 'langs')) + let availableLanguages = {} + for (const lang of langs) { + availableLanguages[lang] = supportedLanguages[lang] + } + const supportedLanguageOptions = _.mapValues(availableLanguages, this.renderLanguageOption) return ( - Language + Fallback Language @@ -222,6 +331,7 @@ export default class ApiModule extends React.Component { return ( + @@ -239,7 +349,7 @@ export default class ApiModule extends React.Component { ? {opacity:1} : {opacity:0} - return + return } render() { @@ -255,7 +365,7 @@ export default class ApiModule extends React.Component { {this.renderSaveButton()}
- {this.renderAccessToken()} + {this.renderAgentList()} {this.renderLanguage()} {this.renderMode()}
diff --git a/src/views/style.scss b/src/views/style.scss index b7690ed..faf09ed 100644 --- a/src/views/style.scss +++ b/src/views/style.scss @@ -27,7 +27,7 @@ padding-top: 7px; } - button { + .saveButton { position: absolute; top: 7px; right: 10px;