diff --git a/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx b/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx index 7b013c761d..bf965cab6c 100644 --- a/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx +++ b/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx @@ -23,7 +23,13 @@ import { IGroupedListStyles } from 'office-ui-fabric-react/lib/GroupedList'; import { ISearchBoxStyles } from 'office-ui-fabric-react/lib/SearchBox'; import { dispatcherState, userSettingsState } from '../../recoilModel'; -import { createSelectedPath, getFriendlyName } from '../../utils/dialogUtil'; +import { + createSelectedPath, + getFriendlyName, + regexRecognizerKey, + onChooseIntentKey, + qnaMatcherKey, +} from '../../utils/dialogUtil'; import { TreeItem } from './treeItem'; @@ -59,6 +65,9 @@ const root = css` // -------------------- ProjectTree -------------------- // function createGroupItem(dialog: DialogInfo, currentId: string, position: number) { + const isRegEx = (dialog.content?.recognizer?.$kind ?? '') === regexRecognizerKey; + const isNotSupported = + isRegEx && dialog.triggers.some((t) => t.type === qnaMatcherKey || t.type === onChooseIntentKey); return { key: dialog.id, name: dialog.displayName, @@ -67,14 +76,15 @@ function createGroupItem(dialog: DialogInfo, currentId: string, position: number count: dialog.triggers.length, hasMoreData: true, isCollapsed: dialog.id !== currentId, - data: dialog, + data: { ...dialog, warning: isNotSupported }, }; } -function createItem(trigger: ITrigger, index: number) { +function createItem(trigger: ITrigger, index: number, isNotSupported?: boolean) { return { ...trigger, index, + warning: isNotSupported, displayName: trigger.displayName || getFriendlyName({ $kind: trigger.type }), }; } @@ -106,8 +116,10 @@ function createItemsAndGroups( (result: { items: any[]; groups: IGroup[] }, dialog) => { result.groups.push(createGroupItem(dialog, dialogId, position)); position += dialog.triggers.length; + const isRegEx = (dialog.content?.recognizer?.$kind ?? '') === regexRecognizerKey; dialog.triggers.forEach((item, index) => { - result.items.push(createItem(item, index)); + const isNotSupported = isRegEx && (item.type === qnaMatcherKey || item.type === onChooseIntentKey); + result.items.push(createItem(item, index, isNotSupported)); }); return result; }, diff --git a/Composer/packages/client/src/components/ProjectTree/TriggerCreationModal.tsx b/Composer/packages/client/src/components/ProjectTree/TriggerCreationModal.tsx index 334d5cc119..1f497908f9 100644 --- a/Composer/packages/client/src/components/ProjectTree/TriggerCreationModal.tsx +++ b/Composer/packages/client/src/components/ProjectTree/TriggerCreationModal.tsx @@ -3,7 +3,7 @@ /** @jsx jsx */ import { jsx, css } from '@emotion/core'; -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import formatMessage from 'format-message'; import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; @@ -15,11 +15,12 @@ import { TextField } from 'office-ui-fabric-react/lib/TextField'; import { luIndexer, combineMessage } from '@bfc/indexers'; import { PlaceHolderSectionName } from '@bfc/indexers/lib/utils/luUtil'; import { DialogInfo, SDKKinds } from '@bfc/shared'; -import { LuEditor, inlineModePlaceholder } from '@bfc/code-editor'; +import { LuEditor, inlineModePlaceholder, defaultQnAPlaceholder } from '@bfc/code-editor'; import { IComboBoxOption } from 'office-ui-fabric-react/lib/ComboBox'; import { useRecoilValue } from 'recoil'; import { FontWeights } from '@uifabric/styling'; import { FontSizes } from '@uifabric/fluent-theme'; +import get from 'lodash/get'; import { generateNewDialog, @@ -33,6 +34,9 @@ import { getEventTypes, getActivityTypes, regexRecognizerKey, + qnaMatcherKey, + onChooseIntentKey, + adaptiveCardKey, } from '../../utils/dialogUtil'; import { addIntent } from '../../utils/luUtil'; import { @@ -41,6 +45,7 @@ import { localeState, projectIdState, schemasState, + qnaFilesState, } from '../../recoilModel/atoms/botState'; import { userSettingsState } from '../../recoilModel'; import { nameRegex } from '../../constants'; @@ -92,6 +97,12 @@ const intent = { }, }; +const optionRow = { + display: 'flex', + height: 15, + fontSize: 15, +}; + // -------------------- Validation Helpers -------------------- // const initialFormDataErrors = { @@ -103,6 +114,11 @@ const initialFormDataErrors = { activity: '', }; +const getQnADiagnostics = (content: string) => { + const { diagnostics } = luIndexer.parse(content); + return combineMessage(diagnostics); +}; + const getLuDiagnostics = (intent: string, triggerPhrases: string) => { const content = `#${intent}\n${triggerPhrases}`; const { diagnostics } = luIndexer.parse(content); @@ -200,19 +216,25 @@ export interface LuFilePayload { content: string; } +export interface QnAFilePayload { + id: string; + content: string; +} + // -------------------- TriggerCreationModal -------------------- // interface TriggerCreationModalProps { dialogId: string; isOpen: boolean; onDismiss: () => void; - onSubmit: (dialog: DialogInfo, luFilePayload?: LuFilePayload) => void; + onSubmit: (dialog: DialogInfo, luFilePayload?: LuFilePayload, QnAFilePayload?: QnAFilePayload) => void; } export const TriggerCreationModal: React.FC = (props) => { const { isOpen, onDismiss, onSubmit, dialogId } = props; const dialogs = useRecoilValue(dialogsState); const luFiles = useRecoilValue(luFilesState); + const qnaFiles = useRecoilValue(qnaFilesState); const locale = useRecoilValue(localeState); const projectId = useRecoilValue(projectIdState); const schemas = useRecoilValue(schemasState); @@ -220,32 +242,48 @@ export const TriggerCreationModal: React.FC = (props) const luFile = luFiles.find(({ id }) => id === `${dialogId}.${locale}`); const dialogFile = dialogs.find((dialog) => dialog.id === dialogId); const isRegEx = (dialogFile?.content?.recognizer?.$kind ?? '') === regexRecognizerKey; + const recognizer = get(dialogFile, 'content.recognizer', ''); + const isLUISnQnA = typeof recognizer === 'string' && recognizer.endsWith('.qna'); const regexIntents = dialogFile?.content?.recognizer?.intents ?? []; - const isNone = !dialogFile?.content?.recognizer; + const qnaFile = qnaFiles.find(({ id }) => id === `${dialogId}.${locale}`); const initialFormData: TriggerFormData = { errors: initialFormDataErrors, - $kind: isNone ? '' : intentTypeKey, + $kind: intentTypeKey, event: '', intent: '', triggerPhrases: '', + qnaPhrases: '', regEx: '', }; const [formData, setFormData] = useState(initialFormData); - const [selectedType, setSelectedType] = useState(isNone ? '' : intentTypeKey); + const [selectedType, setSelectedType] = useState(intentTypeKey); const showIntentName = selectedType === intentTypeKey; const showRegExDropDown = selectedType === intentTypeKey && isRegEx; - const showTriggerPhrase = selectedType === intentTypeKey && !isRegEx; + const showTriggerPhrase = selectedType === intentTypeKey && isLUISnQnA; const showEventDropDown = selectedType === eventTypeKey; const showActivityDropDown = selectedType === activityTypeKey; const showCustomEvent = selectedType === customEventKey; - + const showQnAPhrase = selectedType === qnaMatcherKey; const eventTypes: IComboBoxOption[] = getEventTypes(); const activityTypes: IDropdownOption[] = getActivityTypes(); let triggerTypeOptions: IDropdownOption[] = getTriggerTypes(); - if (isNone) { - triggerTypeOptions = triggerTypeOptions.filter((t) => t.key !== intentTypeKey); + if (isRegEx) { + let index = triggerTypeOptions.findIndex((t) => t.key === qnaMatcherKey); + triggerTypeOptions[index].data = { icon: 'Warning' }; + index = triggerTypeOptions.findIndex((t) => t.key === onChooseIntentKey); + triggerTypeOptions[index].data = { icon: 'Warning' }; + } + if (!isLUISnQnA && !isRegEx) { + triggerTypeOptions = triggerTypeOptions.filter((t) => t.key !== adaptiveCardKey); } + useEffect(() => { + setFormData({ ...formData, qnaPhrases: qnaFile ? qnaFile.content : '' }); + }, [qnaFile]); + + const onRenderOption = (option: IDropdownOption) => { + return
{option.text}
; + }; const shouldDisable = (errors: TriggerFormDataErrors) => { for (const key in errors) { @@ -270,14 +308,21 @@ export const TriggerCreationModal: React.FC = (props) } const content = luFile?.content ?? ''; const luFileId = luFile?.id || `${dialogId}.${locale}`; + if (formData.$kind === adaptiveCardKey) { + formData.$kind = intentTypeKey; + } const newDialog = generateNewDialog(dialogs, dialogId, formData, schemas.sdk?.content); - if (formData.$kind === intentTypeKey && !isRegEx) { + if (formData.$kind === intentTypeKey && isLUISnQnA) { const newContent = addIntent(content, { Name: formData.intent, Body: formData.triggerPhrases }); const updateLuFile = { id: luFileId, content: newContent, }; onSubmit(newDialog, updateLuFile); + } else if (formData.$kind === qnaMatcherKey) { + const qnaFileId = qnaFile?.id || `${dialogId}.${locale}`; + const qnaFilePayload: QnAFilePayload = { id: qnaFileId, content: formData.qnaPhrases }; + onSubmit(newDialog, undefined, qnaFilePayload); } else { onSubmit(newDialog); } @@ -325,6 +370,12 @@ export const TriggerCreationModal: React.FC = (props) setFormData({ ...formData, intent: name, errors: { ...formData.errors, ...errors } }); }; + const onQnAPhrasesChange = (body: string) => { + const errors: TriggerFormDataErrors = {}; + errors.qnaPhrases = getQnADiagnostics(body); + setFormData({ ...formData, qnaPhrases: body, errors: { ...formData.errors, ...errors } }); + }; + const onChangeRegEx = (e, pattern) => { const errors: TriggerFormDataErrors = {}; errors.regEx = validateRegExPattern(selectedType, isRegEx, pattern); @@ -363,6 +414,8 @@ export const TriggerCreationModal: React.FC = (props) options={triggerTypeOptions} styles={dropdownStyles} onChange={onSelectTriggerType} + //@ts-ignore: + onRenderOption={onRenderOption} /> {showEventDropDown && ( = (props) label={ isRegEx ? formatMessage('What is the name of this trigger (RegEx)') - : formatMessage('What is the name of this trigger (LUIS)') + : formatMessage('What is the name of this trigger (LUIS + QnA)') } styles={intent} onChange={onNameChange} @@ -435,6 +488,19 @@ export const TriggerCreationModal: React.FC = (props) /> )} + {showQnAPhrase && ( + + + + + )} diff --git a/Composer/packages/client/src/components/ProjectTree/treeItem.tsx b/Composer/packages/client/src/components/ProjectTree/treeItem.tsx index 6cbafd6190..9370f649f6 100644 --- a/Composer/packages/client/src/components/ProjectTree/treeItem.tsx +++ b/Composer/packages/client/src/components/ProjectTree/treeItem.tsx @@ -16,14 +16,14 @@ import { IContextualMenuStyles } from 'office-ui-fabric-react/lib/ContextualMenu import { ICalloutContentStyles } from 'office-ui-fabric-react/lib/Callout'; // -------------------- Styles -------------------- // - +const indent = 16; const itemText = (depth: number) => css` outline: none; :focus { outline: rgb(102, 102, 102) solid 1px; z-index: 1; } - padding-left: ${depth * 16}px; + padding-left: ${depth * indent}px; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; @@ -42,6 +42,11 @@ const content = css` label: ProjectTreeItem; `; +const leftIndent = css` + height: 100%; + width: ${indent}px; +`; + const moreMenu: Partial = { root: { marginTop: '-7px', @@ -117,6 +122,12 @@ export const overflowSet = css` justify-content: space-between; `; +const warningIcon = { + marginRight: 5, + color: '#BE880A', + fontSize: 9, +}; + // -------------------- TreeItem -------------------- // interface ITreeItemProps { @@ -129,6 +140,9 @@ interface ITreeItemProps { } const onRenderItem = (item: IOverflowSetItemProps) => { + const warningContent = formatMessage( + 'This trigger type is not supported by the RegEx recognizer and will not be fired.' + ); return (
{ onFocus={item.onFocus} >
+ {item.warning ? ( + + + + ) : ( +
+ )} {item.depth !== 0 && ( import('../../components/Modal/Dis const ExportSkillModal = React.lazy(() => import('./exportSkillModal')); const TriggerCreationModal = React.lazy(() => import('../../components/ProjectTree/TriggerCreationModal')); +const warningIcon = { + marginLeft: 5, + color: '#8A8780', + fontSize: 20, + cursor: 'pointer', +}; + +const warningRoot = { + display: 'flex', + background: '#FFF4CE', + height: 50, + alignItems: 'center', +}; + +const warningFont = { + color: SharedColors.gray40, + fontSize: 9, + paddingLeft: 10, +}; + +const changeRecognizerButton = { + root: { + marginLeft: 200, + border: '1px solid', + borderRadius: 2, + fontSize: 14, + }, +}; + function onRenderContent(subTitle, style) { return (
@@ -99,6 +131,7 @@ const getTabFromFragment = () => { }; const DesignPage: React.FC> = (props) => { + const actions = useRecoilValue(dispatcherState); const dialogs = useRecoilValue(dialogsState); const projectId = useRecoilValue(projectIdState); const schemas = useRecoilValue(schemasState); @@ -137,6 +170,7 @@ const DesignPage: React.FC(dialogs[0]); const [exportSkillModalVisible, setExportSkillModalVisible] = useState(false); + const [showWarning, setShowWarning] = useState(true); const shell = useShell('ProjectTree'); const triggerApi = useTriggerApi(shell.api); @@ -151,6 +185,7 @@ const DesignPage: React.FC { @@ -182,6 +217,27 @@ const DesignPage: React.FC { + return ( +
+ +
+ {formatMessage( + 'This trigger type is not supported by the RegEx recognizer. To ensure this trigger is fired, change the recognizer type.' + )} +
+
+ ); + }, []); + const onTriggerCreationDismiss = () => { setTriggerModalVisibility(false); }; @@ -190,7 +246,7 @@ const DesignPage: React.FC { + const onTriggerCreationSubmit = async (dialog: DialogInfo, luFile?: LuFilePayload, qnaFile?: QnAFilePayload) => { const dialogPayload = { id: dialog.id, projectId, @@ -205,6 +261,15 @@ const DesignPage: React.FC; } + const isRegEx = (currentDialog.content?.recognizer?.$kind ?? '') === regexRecognizerKey; + const selectedTrigger = currentDialog.triggers.find((t) => t.id === selected); + const isNotSupported = + isRegEx && (selectedTrigger?.type === qnaMatcherKey || selectedTrigger?.type === onChooseIntentKey); + return (
@@ -546,6 +617,8 @@ const DesignPage: React.FC + ) : isNotSupported ? ( + showWarning && changeRecognizerComponent ) : ( )} diff --git a/Composer/packages/client/src/pages/notifications/Notifications.tsx b/Composer/packages/client/src/pages/notifications/Notifications.tsx index 1cf8ad596e..b20ab123dc 100644 --- a/Composer/packages/client/src/pages/notifications/Notifications.tsx +++ b/Composer/packages/client/src/pages/notifications/Notifications.tsx @@ -37,6 +37,11 @@ const Notifications: React.FC = () => { } navigateTo(uri); }, + [NotificationType.QNA]: (item: INotification) => { + const { projectId, resourceId, diagnostic } = item; + const uri = `/bot/${projectId}/qna/${resourceId}/edit#L=${diagnostic.range?.start.line || 0}`; + navigateTo(uri); + }, [NotificationType.DIALOG]: (item: INotification) => { //path is like main.trigers[0].actions[0] //uri = id?selected=triggers[0]&focused=triggers[0].actions[0] diff --git a/Composer/packages/client/src/pages/notifications/types.ts b/Composer/packages/client/src/pages/notifications/types.ts index 2848a9b317..6efa286561 100644 --- a/Composer/packages/client/src/pages/notifications/types.ts +++ b/Composer/packages/client/src/pages/notifications/types.ts @@ -12,6 +12,7 @@ export enum NotificationType { DIALOG, LG, LU, + QNA, GENERAL, } @@ -122,3 +123,12 @@ export class LuNotification extends Notification { ?.referredLuIntents.find((lu) => lu.name === intentName)?.path; } } + +export class QnANotification extends Notification { + type = NotificationType.QNA; + constructor(projectId: string, id: string, location: string, diagnostic: Diagnostic) { + super(projectId, id, location, diagnostic); + this.dialogPath = ''; + this.message = createSingleMessage(diagnostic); + } +} diff --git a/Composer/packages/client/src/pages/notifications/useNotifications.tsx b/Composer/packages/client/src/pages/notifications/useNotifications.tsx index 957b0323ee..2985bb9e1d 100644 --- a/Composer/packages/client/src/pages/notifications/useNotifications.tsx +++ b/Composer/packages/client/src/pages/notifications/useNotifications.tsx @@ -3,48 +3,64 @@ import { useMemo } from 'react'; import { useRecoilValue } from 'recoil'; +import get from 'lodash/get'; import { dialogsState, luFilesState, + qnaFilesState, lgFilesState, projectIdState, BotDiagnosticsState, } from '../../recoilModel/atoms/botState'; -import { Notification, DialogNotification, LuNotification, LgNotification, ServerNotification } from './types'; +import { + Notification, + DialogNotification, + LuNotification, + LgNotification, + QnANotification, + ServerNotification, +} from './types'; import { getReferredFiles } from './../../utils/luUtil'; export default function useNotifications(filter?: string) { const dialogs = useRecoilValue(dialogsState); const luFiles = useRecoilValue(luFilesState); + const qnaFiles = useRecoilValue(qnaFilesState); const projectId = useRecoilValue(projectIdState); const lgFiles = useRecoilValue(lgFilesState); const diagnostics = useRecoilValue(BotDiagnosticsState); const memoized = useMemo(() => { - const notifactions: Notification[] = []; + const notifications: Notification[] = []; diagnostics.forEach((d) => { - notifactions.push(new ServerNotification(projectId, '', d.source, d)); + notifications.push(new ServerNotification(projectId, '', d.source, d)); }); dialogs.forEach((dialog) => { dialog.diagnostics.map((diagnostic) => { const location = `${dialog.id}.dialog`; - notifactions.push(new DialogNotification(projectId, dialog.id, location, diagnostic)); + notifications.push(new DialogNotification(projectId, dialog.id, location, diagnostic)); }); }); getReferredFiles(luFiles, dialogs).forEach((lufile) => { lufile.diagnostics.map((diagnostic) => { const location = `${lufile.id}.lu`; - notifactions.push(new LuNotification(projectId, lufile.id, location, diagnostic, lufile, dialogs)); + notifications.push(new LuNotification(projectId, lufile.id, location, diagnostic, lufile, dialogs)); }); }); lgFiles.forEach((lgFile) => { lgFile.diagnostics.map((diagnostic) => { const location = `${lgFile.id}.lg`; - notifactions.push(new LgNotification(projectId, lgFile.id, location, diagnostic, lgFile, dialogs)); + notifications.push(new LgNotification(projectId, lgFile.id, location, diagnostic, lgFile, dialogs)); + }); + }); + qnaFiles.forEach((qnaFile) => { + get(qnaFile, 'diagnostics', []).map((diagnostic) => { + const location = `${qnaFile.id}.qna`; + notifications.push(new QnANotification(projectId, qnaFile.id, location, diagnostic)); }); }); - return notifactions; - }, [dialogs, luFiles, lgFiles, projectId, diagnostics]); + return notifications; + }, [dialogs, luFiles, qnaFiles, lgFiles, projectId, diagnostics]); const notifications: Notification[] = filter ? memoized.filter((x) => x.severity === filter) : memoized; return notifications; diff --git a/Composer/packages/client/src/pages/qna/ImportQnAFromUrlModal.tsx b/Composer/packages/client/src/pages/qna/ImportQnAFromUrlModal.tsx new file mode 100644 index 0000000000..97dd1c8f28 --- /dev/null +++ b/Composer/packages/client/src/pages/qna/ImportQnAFromUrlModal.tsx @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx, css } from '@emotion/core'; +import React from 'react'; +import formatMessage from 'format-message'; +import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { FontWeights } from '@uifabric/styling'; +import { FontSizes, SharedColors } from '@uifabric/fluent-theme'; + +import { FieldConfig, useForm } from '../../hooks/useForm'; +const styles = { + dialog: { + title: { + fontWeight: FontWeights.bold, + fontSize: FontSizes.size20, + paddingTop: '14px', + paddingBottom: '11px', + }, + subText: { + fontSize: FontSizes.size14, + }, + }, + modal: { + main: { + maxWidth: '800px !important', + }, + }, +}; + +const dialogWindow = css` + display: flex; + flex-direction: column; + width: 400px; + min-height: 300px; +`; + +const textField = { + root: { + width: '400px', + paddingBottom: '20px', + }, +}; + +const warning = { + color: SharedColors.red10, + fontSize: FontSizes.size10, +}; + +interface ImportQnAFromUrlModalProps { + isOpen: boolean; + dialogId: string; + onDismiss: () => void; + onSubmit: (location: string, subscriptionKey: string, endpoint: string) => void; +} + +interface ImportQnAFromUrlModalFormData { + location: string; + subscriptionKey: string; + region: string; +} + +export const ImportQnAFromUrlModal: React.FC = (props) => { + const { isOpen, onDismiss, onSubmit, dialogId } = props; + const formConfig: FieldConfig = { + location: { + required: true, + defaultValue: '', + }, + subscriptionKey: { + required: true, + defaultValue: '', + }, + region: { + required: true, + defaultValue: '', + }, + }; + const { formData, updateField } = useForm(formConfig); + const disabled = dialogId === 'all'; + return ( + + ); +}; + +export default ImportQnAFromUrlModal; diff --git a/Composer/packages/client/src/pages/qna/code-editor.tsx b/Composer/packages/client/src/pages/qna/code-editor.tsx new file mode 100644 index 0000000000..84bf1df92f --- /dev/null +++ b/Composer/packages/client/src/pages/qna/code-editor.tsx @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/* eslint-disable react/display-name */ +import React, { useState, useEffect, useMemo } from 'react'; +import { useRecoilValue } from 'recoil'; +import { LuEditor, EditorDidMount, defaultQnAPlaceholder } from '@bfc/code-editor'; +import isEmpty from 'lodash/isEmpty'; +import { RouteComponentProps } from '@reach/router'; +import querystring from 'query-string'; +import debounce from 'lodash/debounce'; +import get from 'lodash/get'; +import { CodeEditorSettings } from '@bfc/shared'; +import { QNA_HELP } from '@bfc/code-editor/lib/constants'; + +import { localeState, qnaFilesState, projectIdState } from '../../recoilModel/atoms/botState'; +import { dispatcherState } from '../../recoilModel'; +import { userSettingsState } from '../../recoilModel'; +interface CodeEditorProps extends RouteComponentProps<{}> { + dialogId: string; +} + +const lspServerPath = '/lu-language-server'; +const CodeEditor: React.FC = (props) => { + const actions = useRecoilValue(dispatcherState); + const qnaFiles = useRecoilValue(qnaFilesState); + const locale = useRecoilValue(localeState); + const projectId = useRecoilValue(projectIdState); + const userSettings = useRecoilValue(userSettingsState); + const { dialogId } = props; + const file = qnaFiles.find(({ id }) => id === `${dialogId}.${locale}`); + const hash = props.location?.hash ?? ''; + const hashLine = querystring.parse(hash).L; + const line = Array.isArray(hashLine) ? +hashLine[0] : typeof hashLine === 'string' ? +hashLine : 0; + const [content, setContent] = useState(file?.content); + const currentDiagnostics = get(file, 'diagnostics', []); + const [qnaEditor, setQnAEditor] = useState(null); + useEffect(() => { + if (qnaEditor) { + window.requestAnimationFrame(() => { + qnaEditor.revealLine(line); + qnaEditor.focus(); + qnaEditor.setPosition({ lineNumber: line, column: 1 }); + }); + } + }, [line, qnaEditor]); + + useEffect(() => { + // reset content with file.content initial state + if (!file || isEmpty(file) || content) return; + const value = file.content; + setContent(value); + }, [file, projectId]); + + const editorDidMount: EditorDidMount = (_getValue, qnaEditor) => { + setQnAEditor(qnaEditor); + }; + + const handleSettingsChange = (settings: Partial) => { + actions.updateUserSettings({ codeEditor: settings }); + }; + + const onChangeContent = useMemo( + () => + debounce((newContent: string) => { + actions.updateQnAFile({ id: `${dialogId}.${locale}`, content: newContent }); + }, 500), + [projectId] + ); + + return ( + + ); +}; + +export default CodeEditor; diff --git a/Composer/packages/client/src/pages/qna/index.tsx b/Composer/packages/client/src/pages/qna/index.tsx new file mode 100644 index 0000000000..8590610445 --- /dev/null +++ b/Composer/packages/client/src/pages/qna/index.tsx @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import { useRecoilValue } from 'recoil'; +import React, { Fragment, useMemo, useCallback, Suspense, useEffect, useState } from 'react'; +import formatMessage from 'format-message'; +import { Toggle } from 'office-ui-fabric-react/lib/Toggle'; +import { RouteComponentProps, Router } from '@reach/router'; + +import { LoadingSpinner } from '../../components/LoadingSpinner'; +import { actionButton } from '../language-understanding/styles'; +import { navigateTo } from '../../utils/navigation'; +import { TestController } from '../../components/TestController/TestController'; +import { INavTreeItem } from '../../components/NavTree'; +import { Page } from '../../components/Page'; +import { + dialogsState, + qnaFilesState, + localeState, + projectIdState, + qnaAllUpViewStatusState, +} from '../../recoilModel/atoms/botState'; +import { dispatcherState } from '../../recoilModel'; +import { QnAAllUpViewStatus } from '../../recoilModel/types'; + +import TableView from './table-view'; +import { ImportQnAFromUrlModal } from './ImportQnAFromUrlModal'; + +const CodeEditor = React.lazy(() => import('./code-editor')); + +interface QnAPageProps extends RouteComponentProps<{}> { + dialogId?: string; +} + +const QnAPage: React.FC = (props) => { + const actions = useRecoilValue(dispatcherState); + const dialogs = useRecoilValue(dialogsState); + const qnaFiles = useRecoilValue(qnaFilesState); + const projectId = useRecoilValue(projectIdState); + const locale = useRecoilValue(localeState); + const qnaAllUpViewStatus = useRecoilValue(qnaAllUpViewStatusState); + const [importQnAFromUrlModalVisiability, setImportQnAFromUrlModalVisiability] = useState(false); + + const path = props.location?.pathname ?? ''; + const { dialogId = '' } = props; + const edit = /\/edit(\/)?$/.test(path); + const isRoot = dialogId === 'all'; + const qnaFile = qnaFiles.find(({ id }) => id === `${dialogId}.${locale}`); + const qnaFileContent = qnaFile ? qnaFile.content : ''; + const navLinks: INavTreeItem[] = useMemo(() => { + const newDialogLinks: INavTreeItem[] = dialogs.map((dialog) => { + return { + id: dialog.id, + name: dialog.displayName, + ariaLabel: formatMessage('qna file'), + url: `/bot/${projectId}/qna/${dialog.id}`, + }; + }); + const mainDialogIndex = newDialogLinks.findIndex((link) => link.id === 'Main'); + + if (mainDialogIndex > -1) { + const mainDialog = newDialogLinks.splice(mainDialogIndex, 1)[0]; + newDialogLinks.splice(0, 0, mainDialog); + } + newDialogLinks.splice(0, 0, { + id: 'all', + name: 'All', + ariaLabel: formatMessage('all qna files'), + url: `/bot/${projectId}/qna/all`, + }); + return newDialogLinks; + }, [dialogs]); + + useEffect(() => { + const activeDialog = dialogs.find(({ id }) => id === dialogId); + if (!activeDialog && dialogs.length && dialogId !== 'all') { + navigateTo(`/bot/${projectId}/qna/${dialogId}`); + } + }, [dialogId, dialogs, projectId]); + + const onToggleEditMode = useCallback( + (_e, checked) => { + let url = `/bot/${projectId}/qna/${dialogId}`; + if (checked) url += `/edit`; + navigateTo(url); + }, + [dialogId, projectId] + ); + + const toolbarItems = [ + { + type: 'dropdown', + text: formatMessage('Add'), + align: 'left', + dataTestid: 'AddFlyout', + buttonProps: { + iconProps: { iconName: 'Add' }, + }, + menuProps: { + items: [ + { + 'data-testid': 'FlyoutNewDialog', + key: 'importQnAFromUrl', + text: formatMessage('Import QnA From Url'), + onClick: () => { + setImportQnAFromUrlModalVisiability(true); + }, + }, + ], + }, + }, + { + type: 'element', + element: , + align: 'right', + }, + ]; + + const onRenderHeaderContent = () => { + if (!isRoot || edit) { + return ( + + ); + } + return null; + }; + + const onDismiss = () => { + setImportQnAFromUrlModalVisiability(false); + }; + + const onSubmit = (location: string, subscriptionKey: string, region: string) => { + actions.importQnAFromUrl({ + id: `${dialogId}.${locale}`, + qnaFileContent, + subscriptionKey, + url: location, + region, + }); + setImportQnAFromUrlModalVisiability(false); + }; + + return ( + + }> + + + {qnaAllUpViewStatus === QnAAllUpViewStatus.Success && } + + {qnaAllUpViewStatus === QnAAllUpViewStatus.Loading && } + {setImportQnAFromUrlModalVisiability && ( + + )} + + + ); +}; + +export default QnAPage; diff --git a/Composer/packages/client/src/pages/qna/styles.ts b/Composer/packages/client/src/pages/qna/styles.ts new file mode 100644 index 0000000000..d39d77099e --- /dev/null +++ b/Composer/packages/client/src/pages/qna/styles.ts @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import { css } from '@emotion/core'; +import { FontWeights } from '@uifabric/styling'; +export const content = css` + min-height: 28px; + outline: none; +`; + +export const formCell = css` + display: flex; + flex-direction: column; + outline: none; + :focus { + outline: rgb(102, 102, 102) solid 1px; + } + white-space: pre-wrap; + font-size: 14px; + line-height: 28px; +`; + +export const textField = { + root: { + height: 28, + marginLeft: -5, + }, + field: { + paddingLeft: 4, + marginTop: -5, + }, +}; + +export const bold = css` + font-weight: ${FontWeights.semibold}; +`; + +export const link = { + root: { + fontSize: 14, + lineHeight: 28, + }, +}; + +export const actionButton = css` + font-size: 16px; + margin: 0; + margin-left: 15px; +`; diff --git a/Composer/packages/client/src/pages/qna/table-view.tsx b/Composer/packages/client/src/pages/qna/table-view.tsx new file mode 100644 index 0000000000..5c1f133fdd --- /dev/null +++ b/Composer/packages/client/src/pages/qna/table-view.tsx @@ -0,0 +1,451 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import { useRecoilValue } from 'recoil'; +import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react'; +import { DetailsList, DetailsListLayoutMode, SelectionMode } from 'office-ui-fabric-react/lib/DetailsList'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { TooltipHost } from 'office-ui-fabric-react/lib/Tooltip'; +import { ActionButton } from 'office-ui-fabric-react/lib/Button'; +import { Link } from 'office-ui-fabric-react/lib/Link'; +import { ScrollablePane, ScrollbarVisibility } from 'office-ui-fabric-react/lib/ScrollablePane'; +import { Sticky, StickyPositionType } from 'office-ui-fabric-react/lib/Sticky'; +import formatMessage from 'format-message'; +import { RouteComponentProps } from '@reach/router'; +import get from 'lodash/get'; + +import { + addQuestion, + updateQuestion, + updateAnswer as updateAnswerUtil, + generateQnAPair, + addSection, +} from '../../utils/qnaUtil'; +import { dialogsState, qnaFilesState, projectIdState, localeState } from '../../recoilModel/atoms/botState'; +import { dispatcherState } from '../../recoilModel'; + +import { formCell, content, textField, bold, link, actionButton } from './styles'; + +interface TableViewProps extends RouteComponentProps<{}> { + dialogId: string; +} + +enum EditMode { + None, + Creating, + Updating, +} + +const TableView: React.FC = (props) => { + const actions = useRecoilValue(dispatcherState); + const dialogs = useRecoilValue(dialogsState); + const qnaFiles = useRecoilValue(qnaFilesState); + const projectId = useRecoilValue(projectIdState); + const locale = useRecoilValue(localeState); + const { dialogId } = props; + const file = qnaFiles.find(({ id }) => id === `${dialogId}.${locale}`); + const limitedNumber = useRef(5).current; + const generateQnASections = (file) => { + return get(file, 'qnaSections', []).map((qnaSection, index) => { + const qnaDialog = dialogs.find((dialog) => file.id === `${dialog.id}.${locale}`); + return { + fileId: file.fileId, + dialogId: qnaDialog?.id || '', + used: !!qnaDialog && qnaDialog, + indexId: index, + key: qnaSection.Body, + ...qnaSection, + }; + }); + }; + const allQnASections = qnaFiles.reduce((result: any[], qnaFile) => { + const res = generateQnASections(qnaFile); + return result.concat(res); + }, []); + + const singleFileQnASections = generateQnASections(file); + const qnaSections = useMemo(() => { + if (dialogId === 'all') { + return allQnASections; + } else { + return singleFileQnASections; + } + }, [dialogId, qnaFiles]); + const [showAllAlternatives, setShowAllAlternatives] = useState(Array(qnaSections.length).fill(false)); + const [qnaSectionIndex, setQnASectionIndex] = useState(-1); + const [questionIndex, setQuestionIndex] = useState(-1); //used in QnASection.Questions array + const [question, setQuestion] = useState(''); + const [editMode, setEditMode] = useState(EditMode.None); + const [answerIndex, setAnswerIndex] = useState(-1); + const [answer, setAnswer] = useState(''); + + const createOrUpdateQuestion = () => { + if (question && editMode === EditMode.Creating) { + const updatedQnAFileContent = addQuestion(question, qnaSections, qnaSectionIndex); + actions.updateQnAFile({ id: `${dialogId}.${locale}`, content: updatedQnAFileContent }); + } + if (editMode === EditMode.Updating && qnaSections[qnaSectionIndex].Questions[questionIndex] !== question) { + const updatedQnAFileContent = updateQuestion(question, questionIndex, qnaSections, qnaSectionIndex); + actions.updateQnAFile({ id: `${dialogId}.${locale}`, content: updatedQnAFileContent }); + } + cancelQuestionEditOperation(); + }; + + const updateAnswer = () => { + if (editMode === EditMode.Updating && qnaSections[qnaSectionIndex].Answer !== answer) { + const updatedQnAFileContent = updateAnswerUtil(answer, qnaSections, qnaSectionIndex); + actions.updateQnAFile({ id: `${dialogId}.${locale}`, content: updatedQnAFileContent }); + } + cancelAnswerEditOperation(); + }; + + const cancelQuestionEditOperation = () => { + setEditMode(EditMode.None); + setQuestion(''); + setQuestionIndex(-1); + setQnASectionIndex(-1); + }; + + const cancelAnswerEditOperation = () => { + setEditMode(EditMode.None); + setAnswer(''); + setAnswerIndex(-1); + setQnASectionIndex(-1); + }; + + useEffect(() => { + setShowAllAlternatives(Array(qnaSections.length).fill(false)); + }, [dialogId, projectId]); + + const toggleShowAllAlternatives = (index) => { + const newArray = showAllAlternatives.map((element, i) => { + if (i === index) { + return !element; + } else { + return element; + } + }); + setShowAllAlternatives(newArray); + }; + + const handleQuestionKeydown = (e) => { + if (e.key === 'Enter') { + createOrUpdateQuestion(); + setEditMode(EditMode.None); + e.preventDefault(); + } + if (e.key === 'Escape') { + cancelQuestionEditOperation(); + e.preventDefault(); + } + }; + + const handleQuestionOnBlur = (e) => { + createOrUpdateQuestion(); + e.preventDefault(); + }; + + const handleAddingAlternatives = (index) => { + setEditMode(EditMode.Creating); + setQnASectionIndex(index); + setQuestionIndex(-1); + }; + + const handleUpdateingAlternatives = (qnaSectionIndex, questionIndex, question) => { + setEditMode(EditMode.Updating); + setQuestion(question); + setQnASectionIndex(qnaSectionIndex); + setQuestionIndex(questionIndex); + }; + + const handleQuestionOnChange = (newValue, index) => { + if (index !== qnaSectionIndex) return; + setQuestion(newValue); + }; + + const handleAnswerKeydown = (e) => { + if (e.key === 'Enter') { + updateAnswer(); + setEditMode(EditMode.None); + setQnASectionIndex(-1); + setAnswerIndex(-1); + e.preventDefault(); + } + if (e.key === 'Escape') { + setEditMode(EditMode.None); + setQnASectionIndex(-1); + setAnswerIndex(-1); + e.preventDefault(); + } + }; + + const handleAnswerOnBlur = (e) => { + updateAnswer(); + setEditMode(EditMode.None); + setQnASectionIndex(-1); + setQuestionIndex(-1); + e.preventDefault(); + }; + + const handleUpdateingAnswer = (qnaSectionIndex, answer) => { + setEditMode(EditMode.Updating); + setAnswer(answer); + setQnASectionIndex(qnaSectionIndex); + setAnswerIndex(0); + }; + + const handleAnswerOnChange = (answer, index) => { + if (index !== qnaSectionIndex) return; + setAnswer(answer); + }; + + const getTableColums = () => { + const tableColums = [ + { + key: 'Question', + name: formatMessage('Question'), + fieldName: 'question', + minWidth: 150, + maxWidth: 250, + isResizable: true, + data: 'string', + onRender: (item, qnaIndex) => { + const questions = get(item, 'Questions', []); + const showingQuestions = showAllAlternatives[qnaIndex] ? questions : questions.slice(0, limitedNumber); + return ( +
+ {showingQuestions.map((q, qIndex) => { + if (qnaIndex !== qnaSectionIndex || questionIndex !== qIndex || editMode !== EditMode.Updating) { + return ( +
+ dialogId !== 'all' ? handleUpdateingAlternatives(qnaIndex, qIndex, q) : () => {} + } + onKeyDown={(e) => { + e.preventDefault(); + if (e.key === 'Enter') { + handleUpdateingAlternatives(qnaIndex, qIndex, q); + } + }} + > +
{q}
+
+ ); + //It is updating this qnaSection's qIndex-th Question + } else if (qnaIndex === qnaSectionIndex && questionIndex === qIndex && editMode === EditMode.Updating) { + return ( + { + handleQuestionOnBlur(e); + }} + onChange={(e, newValue) => { + handleQuestionOnChange(newValue, qnaIndex); + }} + onKeyDown={(e) => handleQuestionKeydown(e)} + /> + ); + } + })} + + {editMode === EditMode.Creating && qnaSectionIndex === qnaIndex && dialogId !== 'all' && ( + { + handleQuestionOnBlur(e); + }} + onChange={(e, newValue) => { + e.preventDefault(); + handleQuestionOnChange(newValue, qnaIndex); + }} + onKeyDown={(e) => handleQuestionKeydown(e)} + /> + )} + {!(editMode === EditMode.Creating && qnaSectionIndex === qnaIndex) && dialogId !== 'all' && ( + handleAddingAlternatives(qnaIndex)}> + {formatMessage('add alternative phrasing')} + + )} +
+ ); + }, + }, + { + key: 'Alternatives', + name: formatMessage('alternatives'), + fieldName: 'alternatives', + minWidth: 150, + maxWidth: 250, + isResizable: true, + data: 'string', + isPadded: true, + onRender: (item, qnaIndex) => { + const alternatives = get(item, 'Questions', []); + const showingAlternatives = showAllAlternatives[qnaIndex] + ? alternatives + : alternatives.slice(0, limitedNumber); + return ( + toggleShowAllAlternatives(qnaIndex)}> + {formatMessage('showing {current} of {all}', { + current: showingAlternatives.length, + all: alternatives.length, + })} + + ); + }, + }, + { + key: 'Answer', + name: formatMessage('answer'), + fieldName: 'answer', + minWidth: 150, + maxWidth: 350, + isResizable: true, + data: 'string', + isPadded: true, + onRender: (item, qnaIndex) => { + return ( +
+ {(qnaIndex !== qnaSectionIndex || answerIndex === -1 || editMode !== EditMode.Updating) && ( +
(dialogId !== 'all' ? handleUpdateingAnswer(qnaIndex, item.Answer) : () => {})} + onKeyDown={(e) => { + e.preventDefault(); + if (e.key === 'Enter') { + handleUpdateingAnswer(qnaIndex, item.Answer); + } + }} + > + {item.Answer} +
+ )} + {qnaIndex === qnaSectionIndex && answerIndex === 0 && editMode === EditMode.Updating && ( + { + handleAnswerOnBlur(e); + }} + onChange={(e, newValue) => { + handleAnswerOnChange(newValue, qnaIndex); + }} + onKeyDown={(e) => handleAnswerKeydown(e)} + /> + )} +
+ ); + }, + }, + ]; + + // all view, show used in column + if (dialogId === 'all') { + const beenUsedColumn = { + key: 'usedIn', + name: formatMessage('Used In'), + fieldName: 'usedIn', + minWidth: 100, + maxWidth: 100, + isResizable: true, + isCollapsable: true, + data: 'string', + onRender: (item) => { + return ( +
+
+ {item.dialogId} +
+
+ ); + }, + }; + tableColums.splice(3, 0, beenUsedColumn); + } + + return tableColums; + }; + + const onRenderDetailsHeader = useCallback((props, defaultRender) => { + return ( +
+ + {defaultRender({ + ...props, + onRenderColumnHeaderTooltip: (tooltipHostProps) => , + })} + +
+ ); + }, []); + + const onCreateNewTemplate = () => { + const newQnAPair = generateQnAPair(); + const content = get(file, 'content', ''); + const newContent = addSection(content, newQnAPair); + actions.updateQnAFile({ id: `${dialogId}.${locale}`, content: newContent }); + }; + + const onRenderDetailsFooter = () => { + if (dialogId === 'all') return null; + return ( +
+ { + onCreateNewTemplate(); + actions.setMessage('item added'); + }} + > + {formatMessage('New QnA Section')} + +
+ ); + }; + + const getKeyCallback = useCallback((item) => item.Body, []); + return ( +
+ + item.Body} + layoutMode={DetailsListLayoutMode.justified} + selectionMode={SelectionMode.none} + styles={{ + root: { + overflowX: 'hidden', + // hack for https://github.com/OfficeDev/office-ui-fabric-react/issues/8783 + selectors: { + 'div[role="row"]:hover': { + background: 'none', + }, + }, + }, + }} + onRenderDetailsFooter={onRenderDetailsFooter} + onRenderDetailsHeader={onRenderDetailsHeader} + /> + +
+ ); +}; + +export default TableView; diff --git a/Composer/packages/client/src/recoilModel/DispatcherWrapper.tsx b/Composer/packages/client/src/recoilModel/DispatcherWrapper.tsx index dd9c07bc33..43abf94d28 100644 --- a/Composer/packages/client/src/recoilModel/DispatcherWrapper.tsx +++ b/Composer/packages/client/src/recoilModel/DispatcherWrapper.tsx @@ -10,7 +10,15 @@ import React from 'react'; import { prepareAxios } from './../utils/auth'; import filePersistence from './persistence/FilePersistence'; import createDispatchers, { Dispatcher } from './dispatchers'; -import { dialogsState, projectIdState, luFilesState, skillManifestsState, settingsState, lgFilesState } from './atoms'; +import { + dialogsState, + projectIdState, + luFilesState, + qnaFilesState, + skillManifestsState, + settingsState, + lgFilesState, +} from './atoms'; import { BotAssets } from './types'; const getBotAssets = async (snapshot: Snapshot): Promise => { @@ -18,6 +26,7 @@ const getBotAssets = async (snapshot: Snapshot): Promise => { snapshot.getPromise(projectIdState), snapshot.getPromise(dialogsState), snapshot.getPromise(luFilesState), + snapshot.getPromise(qnaFilesState), snapshot.getPromise(lgFilesState), snapshot.getPromise(skillManifestsState), snapshot.getPromise(settingsState), @@ -26,9 +35,10 @@ const getBotAssets = async (snapshot: Snapshot): Promise => { projectId: result[0], dialogs: result[1], luFiles: result[2], - lgFiles: result[3], - skillManifests: result[4], - setting: result[5], + qnaFiles: result[3], + lgFiles: result[4], + skillManifests: result[5], + setting: result[6], }; }; diff --git a/Composer/packages/client/src/recoilModel/atoms/botState.ts b/Composer/packages/client/src/recoilModel/atoms/botState.ts index 7758e90fe8..aa22972bca 100644 --- a/Composer/packages/client/src/recoilModel/atoms/botState.ts +++ b/Composer/packages/client/src/recoilModel/atoms/botState.ts @@ -2,9 +2,9 @@ // Licensed under the MIT License. import { atom } from 'recoil'; -import { DialogInfo, Diagnostic, LgFile, LuFile, BotSchemas, Skill } from '@bfc/shared'; +import { DialogInfo, Diagnostic, LgFile, LuFile, QnAFile, BotSchemas, Skill } from '@bfc/shared'; -import { BotLoadError, DesignPageLocation } from '../../recoilModel/types'; +import { BotLoadError, DesignPageLocation, QnAAllUpViewStatus } from '../../recoilModel/types'; import { PublishType, DialogSetting, BreadcrumbItem } from './../../recoilModel/types'; import { BotStatus } from './../../constants'; @@ -69,6 +69,11 @@ export const luFilesState = atom({ default: [], }); +export const qnaFilesState = atom({ + key: getFullyQualifiedKey('qnaFiles'), + default: [], +}); + export const schemasState = atom({ key: getFullyQualifiedKey('schemas'), default: {}, @@ -190,3 +195,13 @@ export const onDelLanguageDialogCompleteState = atom({ key: getFullyQualifiedKey('onDelLanguageDialogComplete'), default: { func: undefined }, }); + +export const qnaAllUpViewStatusState = atom({ + key: getFullyQualifiedKey('qnaAllUpViewStatusState'), + default: QnAAllUpViewStatus.Success, +}); + +export const isRecognizerDropdownOpen = atom({ + key: getFullyQualifiedKey('isRecognizerDropdownOpen'), + default: false, +}); diff --git a/Composer/packages/client/src/recoilModel/dispatchers/dialogs.ts b/Composer/packages/client/src/recoilModel/dispatchers/dialogs.ts index 6fbdce67d4..fad136e29b 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/dialogs.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/dialogs.ts @@ -12,6 +12,7 @@ import { onCreateDialogCompleteState, actionsSeedState, showCreateDialogModalState, + isRecognizerDropdownOpen, } from '../atoms/botState'; import { createLgFileState, removeLgFileState } from './lg'; @@ -82,11 +83,24 @@ export const dialogsDispatcher = () => { } set(onCreateDialogCompleteState, { func: undefined }); }); + + const openRecognizerDropdown = useRecoilCallback((callbackHelpers: CallbackInterface) => async () => { + const { set } = callbackHelpers; + set(isRecognizerDropdownOpen, true); + }); + + const closeRecognizerDropdown = useRecoilCallback((callbackHelpers: CallbackInterface) => async () => { + const { set } = callbackHelpers; + set(isRecognizerDropdownOpen, false); + }); + return { removeDialog, createDialog, createDialogCancel, createDialogBegin, updateDialog, + openRecognizerDropdown, + closeRecognizerDropdown, }; }; diff --git a/Composer/packages/client/src/recoilModel/dispatchers/index.ts b/Composer/packages/client/src/recoilModel/dispatchers/index.ts index 4ee892bfd4..1f8ef387ea 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/index.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/index.ts @@ -9,6 +9,7 @@ import { storageDispatcher } from './storage'; import { exportDispatcher } from './export'; import { lgDispatcher } from './lg'; import { luDispatcher } from './lu'; +import { qnaDispatcher } from './qna'; import { navigationDispatcher } from './navigation'; import { publisherDispatcher } from './publisher'; import { settingsDispatcher } from './setting'; @@ -26,6 +27,7 @@ const createDispatchers = () => { ...exportDispatcher(), ...lgDispatcher(), ...luDispatcher(), + ...qnaDispatcher(), ...navigationDispatcher(), ...publisherDispatcher(), ...settingsDispatcher(), diff --git a/Composer/packages/client/src/recoilModel/dispatchers/project.ts b/Composer/packages/client/src/recoilModel/dispatchers/project.ts index 40cc19b14e..fc99de3be0 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/project.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/project.ts @@ -11,6 +11,7 @@ import formatMessage from 'format-message'; import lgWorker from '../parsers/lgWorker'; import luWorker from '../parsers/luWorker'; +import qnaWorker from '../parsers/qnaWorker'; import httpClient from '../../utils/httpUtil'; import { BotStatus } from '../../constants'; import { getReferredFiles } from '../../utils/luUtil'; @@ -28,6 +29,7 @@ import { settingsState, localeState, luFilesState, + qnaFilesState, skillsState, schemasState, lgFilesState, @@ -54,7 +56,7 @@ const handleProjectFailure = (callbackHelpers: CallbackInterface, ex) => { }; const checkProjectUpdates = async () => { - const workers = [filePersistence, lgWorker, luWorker]; + const workers = [filePersistence, lgWorker, luWorker, qnaWorker]; return Promise.all(workers.map((w) => w.flush())); }; @@ -127,7 +129,8 @@ export const projectDispatcher = () => { } try { - const { dialogs, luFiles, lgFiles, skillManifestFiles } = indexer.index(files, botName, locale); + const { dialogs, luFiles, lgFiles, qnaFiles, skillManifestFiles } = indexer.index(files, botName, locale); + console.log(qnaFiles); let mainDialog = ''; const verifiedDialogs = dialogs.map((dialog) => { if (dialog.isRoot) { @@ -140,6 +143,7 @@ export const projectDispatcher = () => { const newSnapshot = snapshot.map(({ set }) => { set(skillManifestsState, skillManifestFiles); set(luFilesState, initLuFilesStatus(botName, luFiles, dialogs)); + set(qnaFilesState, qnaFiles); set(lgFilesState, lgFiles); set(dialogsState, verifiedDialogs); set(botEnvironmentState, botEnvironment); diff --git a/Composer/packages/client/src/recoilModel/dispatchers/qna.ts b/Composer/packages/client/src/recoilModel/dispatchers/qna.ts new file mode 100644 index 0000000000..a2e61c8b4a --- /dev/null +++ b/Composer/packages/client/src/recoilModel/dispatchers/qna.ts @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/* eslint-disable react-hooks/rules-of-hooks */ +import { QnAFile } from '@bfc/shared'; +import { useRecoilCallback, CallbackInterface } from 'recoil'; + +import qnaWorker from '../parsers/qnaWorker'; +import { qnaFilesState, qnaAllUpViewStatusState } from '../atoms/botState'; +import { applicationErrorState } from '../atoms'; +import { QnAAllUpViewStatus } from '../types'; + +import httpClient from './../../utils/httpUtil'; +// import { Text, BotStatus } from './../../constants'; + +export const updateQnAFileState = async ( + callbackHelpers: CallbackInterface, + { id, content }: { id: string; content: string } +) => { + const { set, snapshot } = callbackHelpers; + const qnaFiles = await snapshot.getPromise(qnaFilesState); + const updatedQnAFile = (await qnaWorker.parse(id, content)) as QnAFile; + + const newQnAFiles = qnaFiles.map((file) => { + if (file.id === id) { + return updatedQnAFile; + } + return file; + }); + + set(qnaFilesState, newQnAFiles); +}; + +export const qnaDispatcher = () => { + const updateQnAFile = useRecoilCallback( + (callbackHelpers: CallbackInterface) => async ({ id, content }: { id: string; content: string }) => { + await updateQnAFileState(callbackHelpers, { id, content }); + } + ); + + const importQnAFromUrl = useRecoilCallback( + (callbackHelpers: CallbackInterface) => async ({ + id, + qnaFileContent, + subscriptionKey, + url, + region, + }: { + id: string; + qnaFileContent: string; + subscriptionKey: string; + url: string; + region: string; + }) => { + const { set } = callbackHelpers; + set(qnaAllUpViewStatusState, QnAAllUpViewStatus.Loading); + try { + const response = await httpClient.get(`/qnaContent`, { params: { subscriptionKey, url, region } }); + const content = qnaFileContent ? qnaFileContent + '\n' + response.data : response.data; + + await updateQnAFileState(callbackHelpers, { id, content }); + } catch (err) { + set(applicationErrorState, { + message: err.message, + summary: `Failed to import QnA`, + }); + } + set(qnaAllUpViewStatusState, QnAAllUpViewStatus.Success); + } + ); + + return { + updateQnAFile, + importQnAFromUrl, + }; +}; diff --git a/Composer/packages/client/src/recoilModel/parsers/qnaWorker.ts b/Composer/packages/client/src/recoilModel/parsers/qnaWorker.ts new file mode 100644 index 0000000000..44bef644c0 --- /dev/null +++ b/Composer/packages/client/src/recoilModel/parsers/qnaWorker.ts @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import Worker from './workers/qnaParser.worker.ts'; +import { BaseWorker } from './baseWorker'; +import { QnAPayload, QnAActionType } from './types'; + +// Wrapper class +class QnAWorker extends BaseWorker { + parse(id: string, content: string) { + const payload = { id, content }; + return this.sendMsg(QnAActionType.Parse, payload); + } + + addSection(content: string, newContent: string) { + const payload = { content, newContent }; + return this.sendMsg(QnAActionType.AddSection, payload); + } + + updateSection(indexId: number, content: string, newContent: string) { + const payload = { indexId, content, newContent }; + return this.sendMsg(QnAActionType.UpdateSection, payload); + } + + removeSection(indexId: number, content: string) { + const payload = { content, indexId }; + return this.sendMsg(QnAActionType.RemoveSection, payload); + } +} + +export default new QnAWorker(new Worker()); diff --git a/Composer/packages/client/src/recoilModel/parsers/types.ts b/Composer/packages/client/src/recoilModel/parsers/types.ts index a9bb927123..060bec5bcc 100644 --- a/Composer/packages/client/src/recoilModel/parsers/types.ts +++ b/Composer/packages/client/src/recoilModel/parsers/types.ts @@ -1,6 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { LuIntentSection, LgFile, FileInfo, LgTemplate } from '@bfc/shared'; +import { LuIntentSection, LgFile, QnASection, FileInfo, LgTemplate } from '@bfc/shared'; export type LuPayload = { content: string; @@ -59,6 +59,12 @@ export type IndexPayload = { locale: string; }; +export type QnAPayload = { + content: string; + id?: string; + section?: QnASection; +}; + export enum LuActionType { Parse = 'parse', AddIntent = 'add-intent', @@ -81,3 +87,10 @@ export enum LgActionType { export enum IndexerActionType { Index = 'index', } + +export enum QnAActionType { + Parse = 'parse', + AddSection = 'add-section', + UpdateSection = 'update-section', + RemoveSection = 'remove-section', +} diff --git a/Composer/packages/client/src/recoilModel/parsers/workers/qnaParser.worker.ts b/Composer/packages/client/src/recoilModel/parsers/workers/qnaParser.worker.ts new file mode 100644 index 0000000000..04b16b79b3 --- /dev/null +++ b/Composer/packages/client/src/recoilModel/parsers/workers/qnaParser.worker.ts @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import { qnaIndexer } from '@bfc/indexers'; +import * as qnaUtil from '@bfc/indexers/lib/utils/qnaUtil'; + +import { QnAActionType } from './../types'; +const ctx: Worker = self as any; + +const parse = (content: string, id: string) => { + return { id, content, ...qnaIndexer.parse(content, id) }; +}; + +ctx.onmessage = function (msg) { + const { id: msgId, type, payload } = msg.data; + const { content, id, file, indexId } = payload; + let result: any = null; + try { + switch (type) { + case QnAActionType.Parse: { + result = parse(content, id); + break; + } + case QnAActionType.AddSection: { + result = qnaUtil.addSection(file.content, content); + break; + } + case QnAActionType.UpdateSection: { + result = qnaUtil.updateSection(indexId, file.content, content); + break; + } + case QnAActionType.RemoveSection: { + result = qnaUtil.removeSection(file.content, indexId); + break; + } + } + ctx.postMessage({ id: msgId, payload: result }); + } catch (error) { + ctx.postMessage({ id: msgId, error }); + } +}; diff --git a/Composer/packages/client/src/recoilModel/persistence/FilePersistence.ts b/Composer/packages/client/src/recoilModel/persistence/FilePersistence.ts index 16ccedb084..5dfc71c474 100644 --- a/Composer/packages/client/src/recoilModel/persistence/FilePersistence.ts +++ b/Composer/packages/client/src/recoilModel/persistence/FilePersistence.ts @@ -8,7 +8,7 @@ import { DialogInfo } from '@bfc/shared'; import { DialogSetting } from '../../recoilModel/types'; import { SkillManifest } from './../../pages/design/exportSkillModal/constants'; -import { LuFile, LgFile } from './../../../../lib/shared/src/types/indexers'; +import { LuFile, LgFile, QnAFile } from './../../../../lib/shared/src/types/indexers'; import { BotAssets } from './../types'; import * as client from './http'; import { IFileChange, ChangeType, FileExtensions } from './types'; @@ -181,6 +181,12 @@ class FilePersistence { return changes; } + private getQnAChanges(current: QnAFile[], previous: QnAFile[]) { + const changeItems = this.getDifferenceItems(current, previous); + const changes = this.getFileChanges(FileExtensions.QnA, changeItems); + return changes; + } + private getLgChanges(current: LgFile[], previous: LgFile[]) { const changeItems = this.getDifferenceItems(current, previous); const changes = this.getFileChanges(FileExtensions.Lg, changeItems); @@ -210,6 +216,7 @@ class FilePersistence { private getAssetsChanges(currentAssets: BotAssets, previousAssets: BotAssets): IFileChange[] { const dialogChanges = this.getDialogChanges(currentAssets.dialogs, previousAssets.dialogs); const luChanges = this.getLuChanges(currentAssets.luFiles, previousAssets.luFiles); + const qnaChanges = this.getQnAChanges(currentAssets.qnaFiles, previousAssets.qnaFiles); const lgChanges = this.getLgChanges(currentAssets.lgFiles, previousAssets.lgFiles); const skillManifestChanges = this.getSkillManifestsChanges( currentAssets.skillManifests, @@ -219,6 +226,7 @@ class FilePersistence { const fileChanges: IFileChange[] = [ ...dialogChanges, ...luChanges, + ...qnaChanges, ...lgChanges, ...skillManifestChanges, ...settingChanges, diff --git a/Composer/packages/client/src/recoilModel/persistence/types.ts b/Composer/packages/client/src/recoilModel/persistence/types.ts index b9a5d8739e..1e7c703ebe 100644 --- a/Composer/packages/client/src/recoilModel/persistence/types.ts +++ b/Composer/packages/client/src/recoilModel/persistence/types.ts @@ -12,6 +12,7 @@ export enum FileExtensions { Manifest = '.json', Lu = '.lu', Lg = '.lg', + QnA = '.qna', Setting = 'appsettings.json', } diff --git a/Composer/packages/client/src/recoilModel/types.ts b/Composer/packages/client/src/recoilModel/types.ts index 20104490e2..ef24ca68be 100644 --- a/Composer/packages/client/src/recoilModel/types.ts +++ b/Composer/packages/client/src/recoilModel/types.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. import { JSONSchema7 } from '@bfc/extension'; -import { AppUpdaterSettings, CodeEditorSettings, DialogInfo, LuFile, LgFile, PromptTab } from '@bfc/shared'; +import { AppUpdaterSettings, CodeEditorSettings, DialogInfo, LuFile, QnAFile, LgFile, PromptTab } from '@bfc/shared'; import { AppUpdaterStatus } from '../constants'; @@ -140,6 +140,7 @@ export type BotAssets = { projectId: string; dialogs: DialogInfo[]; luFiles: LuFile[]; + qnaFiles: QnAFile[]; lgFiles: LgFile[]; skillManifests: SkillManifest[]; setting: DialogSetting; @@ -150,3 +151,8 @@ export type BoilerplateVersion = { currentVersion?: string; updateRequired?: boolean; }; + +export enum QnAAllUpViewStatus { + Loading, + Success, +} diff --git a/Composer/packages/client/src/router.tsx b/Composer/packages/client/src/router.tsx index 5618416f76..90dfa04b41 100644 --- a/Composer/packages/client/src/router.tsx +++ b/Composer/packages/client/src/router.tsx @@ -19,6 +19,7 @@ import { LoadingSpinner } from './components/LoadingSpinner'; const DesignPage = React.lazy(() => import('./pages/design/DesignPage')); const LUPage = React.lazy(() => import('./pages/language-understanding/LUPage')); +const QnAPage = React.lazy(() => import('./pages/qna')); const LGPage = React.lazy(() => import('./pages/language-generation/LGPage')); const SettingPage = React.lazy(() => import('./pages/setting/SettingsPage')); const Notifications = React.lazy(() => import('./pages/notifications/Notifications')); @@ -42,12 +43,14 @@ const Routes = (props) => { from="/bot/:projectId/language-understanding" to="/bot/:projectId/language-understanding/all" /> + + diff --git a/Composer/packages/client/src/utils/dialogUtil.ts b/Composer/packages/client/src/utils/dialogUtil.ts index 1f3a269b0a..f97da723a1 100644 --- a/Composer/packages/client/src/utils/dialogUtil.ts +++ b/Composer/packages/client/src/utils/dialogUtil.ts @@ -31,6 +31,7 @@ export interface TriggerFormData { intent: string; triggerPhrases: string; regEx: string; + qnaPhrases: string; } export interface TriggerFormDataErrors { @@ -38,6 +39,7 @@ export interface TriggerFormDataErrors { intent?: string; event?: string; triggerPhrases?: string; + qnaPhrases?: string; regEx?: string; activity?: string; } @@ -49,9 +51,14 @@ export function getDialog(dialogs: DialogInfo[], dialogId: string) { export const eventTypeKey: string = SDKKinds.OnDialogEvent; export const intentTypeKey: string = SDKKinds.OnIntent; +export const qnaTypeKey: string = SDKKinds.OnQnAMatch; export const activityTypeKey: string = SDKKinds.OnActivity; export const regexRecognizerKey: string = SDKKinds.RegexRecognizer; +export const crossTrainedRecognizerSetKey: string = SDKKinds.CrossTrainedRecognizerSet; export const customEventKey = 'OnCustomEvent'; +export const qnaMatcherKey: string = SDKKinds.OnQnAMatch; +export const onChooseIntentKey: string = SDKKinds.OnChooseIntent; +export const adaptiveCardKey = 'adaptiveCard'; function insert(content, path: string, position: number | undefined, data: any) { const current = get(content, path, []); @@ -180,6 +187,10 @@ export function getTriggerTypes(): IDropdownOption[] { key: customEventKey, text: formatMessage('Custom events'), }, + { + key: adaptiveCardKey, + text: formatMessage('Adaptive card action received'), + }, ]; return triggerTypes; } diff --git a/Composer/packages/client/src/utils/pageLinks.ts b/Composer/packages/client/src/utils/pageLinks.ts index 742231b615..732e9a9a5e 100644 --- a/Composer/packages/client/src/utils/pageLinks.ts +++ b/Composer/packages/client/src/utils/pageLinks.ts @@ -33,6 +33,13 @@ export const topLinks = (projectId: string, openedDialogId: string) => { exact: false, disabled: !botLoaded, }, + { + to: `/bot/${projectId}/qna`, + iconName: 'SkypeMessage', + labelName: formatMessage('QnA'), + exact: true, + disabled: !botLoaded, + }, { to: `/bot/${projectId}/notifications`, iconName: 'Warning', diff --git a/Composer/packages/client/src/utils/qnaUtil.ts b/Composer/packages/client/src/utils/qnaUtil.ts new file mode 100644 index 0000000000..15ebd9b3dc --- /dev/null +++ b/Composer/packages/client/src/utils/qnaUtil.ts @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * luUtil.ts is a single place use lu-parser handle lu file operation. + * it's designed have no state, input text file, output text file. + * for more usage detail, please check client/__tests__/utils/luUtil.test.ts + */ +import { QnAFile, DialogInfo } from '@bfc/shared'; + +import { getBaseName, getExtension } from './fileUtil'; +export * from '@bfc/indexers/lib/utils/qnaUtil'; + +export function getFileLocale(fileName: string) { + //file name = 'a.en-us.qna' + return getExtension(getBaseName(fileName)); +} +export function getReferredQnaFiles(qnaFiles: QnAFile[], dialogs: DialogInfo[]) { + return qnaFiles.filter((file) => !!file.content); +} diff --git a/Composer/packages/lib/code-editor/src/LuEditor.tsx b/Composer/packages/lib/code-editor/src/LuEditor.tsx index 82899c2a0f..402b823beb 100644 --- a/Composer/packages/lib/code-editor/src/LuEditor.tsx +++ b/Composer/packages/lib/code-editor/src/LuEditor.tsx @@ -15,6 +15,7 @@ import { LUOption } from './utils'; export interface LULSPEditorProps extends BaseEditorProps { luOption?: LUOption; + helpURL?: string; languageServer?: | { host?: string; @@ -69,7 +70,14 @@ const LuEditor: React.FC = (props) => { ...props.options, }; - const { luOption, languageServer, onInit: onInitProp, placeholder = defaultPlaceholder, ...restProps } = props; + const { + luOption, + languageServer, + onInit: onInitProp, + placeholder = defaultPlaceholder, + helpURL = LU_HELP, + ...restProps + } = props; const luServer = languageServer || defaultLUServer; let editorId = ''; diff --git a/Composer/packages/lib/code-editor/src/constants.ts b/Composer/packages/lib/code-editor/src/constants.ts index 691e3e4bda..a17b326138 100644 --- a/Composer/packages/lib/code-editor/src/constants.ts +++ b/Composer/packages/lib/code-editor/src/constants.ts @@ -18,3 +18,10 @@ export const defaultPlaceholder = formatMessage( ); export const LG_HELP = 'https://aka.ms/lg-file-format'; + +export const QNA_HELP = 'https: //aka.ms/qna-file-format'; +export const defaultQnAPlaceholder = formatMessage( + `> To learn more about the QnA file format, read the documentation at +> {QNA_HELP}`, + { QNA_HELP } +); diff --git a/Composer/packages/lib/indexers/package.json b/Composer/packages/lib/indexers/package.json index bfb9b7144c..7ca30a113c 100644 --- a/Composer/packages/lib/indexers/package.json +++ b/Composer/packages/lib/indexers/package.json @@ -28,9 +28,9 @@ }, "dependencies": { "@bfc/shared": "*", - "@microsoft/bf-lu": "^4.10.0-preview.141651", + "@microsoft/bf-lu": "https://botbuilder.myget.org/F/botbuilder-declarative/npm/@microsoft/bf-lu/-/@microsoft/bf-lu-1.3.6.tgz", "adaptive-expressions": "4.10.0-preview-147186", "botbuilder-lg": "4.10.0-preview-147186", "lodash": "^4.17.19" } -} +} \ No newline at end of file diff --git a/Composer/packages/lib/indexers/src/dialogIndexer.ts b/Composer/packages/lib/indexers/src/dialogIndexer.ts index f6ae3d4be9..60f9a9827c 100644 --- a/Composer/packages/lib/indexers/src/dialogIndexer.ts +++ b/Composer/packages/lib/indexers/src/dialogIndexer.ts @@ -157,6 +157,7 @@ function extractReferredDialogs(dialog): string[] { function parse(id: string, content: any) { const luFile = typeof content.recognizer === 'string' ? content.recognizer : ''; + const qnaFile = typeof content.recognizer === 'string' ? content.recognizer : ''; const lgFile = typeof content.generator === 'string' ? content.generator : ''; const diagnostics: Diagnostic[] = []; return { @@ -167,6 +168,7 @@ function parse(id: string, content: any) { lgTemplates: extractLgTemplates(id, content), referredLuIntents: extractLuIntents(content, id), luFile: getBaseName(luFile, '.lu'), + qnaFile: getBaseName(qnaFile, '.qna'), lgFile: getBaseName(lgFile, '.lg'), triggers: extractTriggers(content), intentTriggers: extractIntentTriggers(content), diff --git a/Composer/packages/lib/indexers/src/index.ts b/Composer/packages/lib/indexers/src/index.ts index b272107bdc..ad7f20a13f 100644 --- a/Composer/packages/lib/indexers/src/index.ts +++ b/Composer/packages/lib/indexers/src/index.ts @@ -5,6 +5,7 @@ import { FileInfo, importResolverGenerator } from '@bfc/shared'; import { dialogIndexer } from './dialogIndexer'; import { lgIndexer } from './lgIndexer'; import { luIndexer } from './luIndexer'; +import { qnaIndexer } from './qnaIndexer'; import { skillManifestIndexer } from './skillManifestIndexer'; import { FileExtensions } from './utils/fileExtensions'; import { getExtension, getBaseName } from './utils/help'; @@ -19,7 +20,13 @@ class Indexer { } return result; }, - { [FileExtensions.lg]: [], [FileExtensions.Lu]: [], [FileExtensions.Dialog]: [], [FileExtensions.Manifest]: [] } + { + [FileExtensions.lg]: [], + [FileExtensions.Lu]: [], + [FileExtensions.QnA]: [], + [FileExtensions.Dialog]: [], + [FileExtensions.Manifest]: [], + } ); } @@ -36,10 +43,12 @@ class Indexer { public index(files: FileInfo[], botName: string, locale: string) { const result = this.classifyFile(files); + console.log(result); return { dialogs: dialogIndexer.index(result[FileExtensions.Dialog], botName), lgFiles: lgIndexer.index(result[FileExtensions.lg], this.getLgImportResolver(result[FileExtensions.lg], locale)), luFiles: luIndexer.index(result[FileExtensions.Lu]), + qnaFiles: qnaIndexer.index(result[FileExtensions.QnA]), skillManifestFiles: skillManifestIndexer.index(result[FileExtensions.Manifest]), }; } @@ -50,5 +59,6 @@ export const indexer = new Indexer(); export * from './dialogIndexer'; export * from './lgIndexer'; export * from './luIndexer'; +export * from './qnaIndexer'; export * from './utils'; export * from './validations'; diff --git a/Composer/packages/lib/indexers/src/qnaIndexer.ts b/Composer/packages/lib/indexers/src/qnaIndexer.ts new file mode 100644 index 0000000000..0a0f034bd7 --- /dev/null +++ b/Composer/packages/lib/indexers/src/qnaIndexer.ts @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import LUParser from '@microsoft/bf-lu/lib/parser/lufile/luParser'; +import { FileInfo, QnAFile } from '@bfc/shared'; +import get from 'lodash/get'; +import { Diagnostic, Position, Range, DiagnosticSeverity } from '@bfc/shared'; + +import { getBaseName } from './utils/help'; +import { FileExtensions } from './utils/fileExtensions'; + +function convertQnADiagnostic(d: any, source: string): Diagnostic { + const severityMap = { + ERROR: DiagnosticSeverity.Error, + WARN: DiagnosticSeverity.Warning, + INFORMATION: DiagnosticSeverity.Information, + HINT: DiagnosticSeverity.Hint, + }; + const result = new Diagnostic(d.Message, source, severityMap[d.Severity]); + + const start: Position = d.Range ? new Position(d.Range.Start.Line, d.Range.Start.Character) : new Position(0, 0); + const end: Position = d.Range ? new Position(d.Range.End.Line, d.Range.End.Character) : new Position(0, 0); + result.range = new Range(start, end); + + return result; +} + +function parse(content: string, id = '') { + const { Sections, Errors } = LUParser.parse(content); + const qnaSections: any[] = []; + Sections.forEach((section) => { + const { + Answer, + Body, + FilterPairs, + Id, + QAPairId, + Questions, + SectionType, + StartLine, + StopLine, + prompts, + promptsText, + source, + } = section; + const range = { + startLineNumber: get(section, 'ParseTree.start.line', 0), + endLineNumber: get(section, 'ParseTree.stop.line', 0), + }; + qnaSections.push({ + Answer, + Body, + FilterPairs, + Id, + QAPairId, + Questions, + SectionType, + StartLine, + StopLine, + prompts, + promptsText, + source, + range, + }); + }); + const diagnostics = Errors.map((e) => convertQnADiagnostic(e, id)); + return { + empty: !Sections.length, + qnaSections, + fileId: id, + diagnostics, + }; +} + +function index(files: FileInfo[]): QnAFile[] { + if (files.length === 0) return []; + const qnaFiles: QnAFile[] = []; + for (const file of files) { + const { name, content } = file; + if (name.endsWith(FileExtensions.QnA)) { + const id = getBaseName(name, FileExtensions.QnA); + const data = parse(content, id); + qnaFiles.push({ id, content, ...data }); + } + } + return qnaFiles; +} + +export const qnaIndexer = { + index, + parse, +}; diff --git a/Composer/packages/lib/indexers/src/utils/fileExtensions.ts b/Composer/packages/lib/indexers/src/utils/fileExtensions.ts index 184672e9d2..c6dea0836f 100644 --- a/Composer/packages/lib/indexers/src/utils/fileExtensions.ts +++ b/Composer/packages/lib/indexers/src/utils/fileExtensions.ts @@ -4,6 +4,7 @@ export enum FileExtensions { Dialog = '.dialog', Lu = '.lu', + QnA = '.qna', lg = '.lg', Manifest = '.json', } diff --git a/Composer/packages/lib/indexers/src/utils/qnaUtil.ts b/Composer/packages/lib/indexers/src/utils/qnaUtil.ts new file mode 100644 index 0000000000..6cfaa57476 --- /dev/null +++ b/Composer/packages/lib/indexers/src/utils/qnaUtil.ts @@ -0,0 +1,164 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * qnaUtil.ts is a single place handle lu file operation. + * it's designed have no state, input text file, output text file. + */ + +//import isEmpty from 'lodash/isEmpty'; +import { QnASection } from '@bfc/shared'; +import { sectionHandler } from '@microsoft/bf-lu/lib/parser/composerindex'; +import cloneDeep from 'lodash/cloneDeep'; + +import { qnaIndexer } from '../qnaIndexer'; + +const { luParser, sectionOperator } = sectionHandler; + +export function checkIsSingleSection(content: string) { + const { Sections } = luParser.parse(content); + return Sections.length === 1; +} + +export function generateQnAPair() { + let result = ''; + result += `# ? newQuestion\n`; + result += '\n```'; + result += `\nnewAnswer`; + result += '\n```'; + return result; +} + +export function addSection(content: string, newContent: string) { + const resource = luParser.parse(content); + const res = new sectionOperator(resource).addSection(newContent); + return res.Content; +} + +export function updateSection(indexId: number, content: string, newContent: string) { + if (indexId < 0) return content; + const resource = luParser.parse(content); + const { Sections } = resource; + const sectionId = Sections[indexId].Id; + const res = new sectionOperator(resource).updateSection(sectionId, newContent); + return res.Content; +} + +export function removeSection(indexId: number, content: string) { + if (indexId < 0) return content; + const resource = luParser.parse(content); + const res = new sectionOperator(resource).deleteSection(indexId); + return res.Content; +} + +export function insertSection(indexId: number, content: string, newContent: string) { + if (indexId < 0) return content; + const resource = luParser.parse(content); + return new sectionOperator(resource).insertSection(indexId, newContent).Content; +} + +export function getParsedDiagnostics(newContent: string) { + const { diagnostics } = qnaIndexer.parse(newContent); + return diagnostics; +} + +export function addQuestion(newContent: string, qnaSections: QnASection[], qnaSectionIndex: number) { + const qnaFileContent = qnaSections.reduce((result, qnaSection, index) => { + if (index != qnaSectionIndex) { + result = result + '\n' + qnaSection.Body; + } else { + const newQnASection = addQuestionInQnASection(qnaSection, newContent); + result += rebuildQnaSection(newQnASection); + } + return result; + }, ''); + return qnaFileContent; +} + +export function updateQuestion( + newContent: string, + questionIndex: number, + qnaSections: QnASection[], + qnaSectionIndex: number +) { + const qnaFileContent = qnaSections.reduce((result, qnaSection, index) => { + if (index !== qnaSectionIndex) { + result = result + '\n' + qnaSection.Body; + } else { + const newQnASection = updateQuestionInQnASection(qnaSection, newContent, questionIndex); + result += rebuildQnaSection(newQnASection); + } + return result; + }, ''); + return qnaFileContent; +} + +export function updateAnswer(newContent: string, qnaSections: QnASection[], qnaSectionIndex: number) { + const qnaFileContent = qnaSections.reduce((result, qnaSection, index) => { + if (index !== qnaSectionIndex) { + result = result + '\n' + qnaSection.Body; + } else { + const newQnASection = updateAnswerInQnASection(qnaSection, newContent); + result += rebuildQnaSection(newQnASection); + } + return result; + }, ''); + return qnaFileContent; +} + +function updateAnswerInQnASection(qnaSection: QnASection, answer: string) { + const newQnASection: QnASection = cloneDeep(qnaSection); + newQnASection.Answer = answer; + return newQnASection; +} + +function updateQuestionInQnASection(qnaSection: QnASection, question: string, questionIndex: number) { + const newQnASection: QnASection = cloneDeep(qnaSection); + if (question) { + newQnASection.Questions[questionIndex] = question; + } else { + newQnASection.Questions.splice(questionIndex, 1); + } + return newQnASection; +} + +function addQuestionInQnASection(qnaSection: QnASection, question: string) { + const newQnASection: QnASection = cloneDeep(qnaSection); + newQnASection.Questions.push(question); + return newQnASection; +} + +function rebuildQnaSection(qnaSection) { + const { source, QAPairId, Questions, FilterPairs, Answer, promptsText } = qnaSection; + let result = ''; + if (source && source != 'custom editorial') { + result += `\n> !# @qna.pair.source = ${source}`; + } + if (QAPairId) { + result += `\n`; + } + if (Questions && Questions.length !== 0) { + result += `\n# ? ${Questions[0]}`; + Questions.slice(1).forEach((question) => { + result += `\n- ${question}`; + }); + } + if (FilterPairs && FilterPairs.length !== 0) { + result += `\n**Filters:**`; + FilterPairs.forEach((filterPair) => { + result += `\n-${filterPair.key}=${filterPair.value}`; + }); + } + if (Answer !== undefined) { + result += '\n```'; + result += `\n${Answer}`; + result += '\n```'; + } + if (promptsText) { + result += '\n**Prompts:**'; + promptsText.forEach((prompt) => { + result += `\n-${prompt}`; + }); + } + return result; +} diff --git a/Composer/packages/lib/shared/src/labelMap.ts b/Composer/packages/lib/shared/src/labelMap.ts index 030b11d449..11cab45ace 100644 --- a/Composer/packages/lib/shared/src/labelMap.ts +++ b/Composer/packages/lib/shared/src/labelMap.ts @@ -136,6 +136,9 @@ export const ConceptLabels: { [key in ConceptLabelKey]?: LabelOverride } = { title: formatMessage('Dialog cancelled'), subtitle: formatMessage('Cancel dialog event'), }, + [SDKKinds.OnChooseIntent]: { + title: formatMessage('Duplicated intents recognized'), + }, [SDKKinds.OnCondition]: { title: formatMessage('Handle a Condition'), }, @@ -201,6 +204,9 @@ export const ConceptLabels: { [key in ConceptLabelKey]?: LabelOverride } = { title: formatMessage('Unknown intent'), subtitle: formatMessage('Unknown intent recognized'), }, + [SDKKinds.OnQnAMatch]: { + title: formatMessage('QnA Intent recognized'), + }, [SDKKinds.QnAMakerDialog]: { title: formatMessage('Connect to QnA Knowledgebase'), }, diff --git a/Composer/packages/lib/shared/src/types/indexers.ts b/Composer/packages/lib/shared/src/types/indexers.ts index f798b070d4..d35b9a3055 100644 --- a/Composer/packages/lib/shared/src/types/indexers.ts +++ b/Composer/packages/lib/shared/src/types/indexers.ts @@ -41,6 +41,7 @@ export interface DialogInfo { lgFile: string; lgTemplates: LgTemplateJsonPath[]; luFile: string; + qnaFile: string; referredLuIntents: ReferredLuIntents[]; referredDialogs: string[]; triggers: ITrigger[]; @@ -93,6 +94,19 @@ export interface LuFile { empty: boolean; [key: string]: any; } + +export interface QnASection { + Questions: string[]; + Answer: string; + Body: string; +} + +export interface QnAFile { + id: string; + content: string; + qnaSections: QnASection[]; + [key: string]: any; +} export interface CodeRange { startLineNumber: number; endLineNumber: number; diff --git a/Composer/packages/server/package.json b/Composer/packages/server/package.json index da36e819b3..36db42c499 100644 --- a/Composer/packages/server/package.json +++ b/Composer/packages/server/package.json @@ -61,7 +61,7 @@ "@bfc/plugin-loader": "*", "@bfc/shared": "*", "@microsoft/bf-dispatcher": "^4.10.0-preview.141651", - "@microsoft/bf-lu": "^4.10.0-preview.141651", + "@microsoft/bf-lu": "https://botbuilder.myget.org/F/botbuilder-declarative/npm/@bfcomposer/bf-lu/-/@bfcomposer/bf-lu-1.4.2.tgz", "archiver": "^3.0.0", "axios": "^0.19.2", "azure-storage": "^2.10.3", @@ -94,4 +94,4 @@ "vscode-ws-jsonrpc": "^0.1.1", "ws": "^5.0.0" } -} +} \ No newline at end of file diff --git a/Composer/packages/server/src/controllers/project.ts b/Composer/packages/server/src/controllers/project.ts index 52cf573ad0..19f9b61653 100644 --- a/Composer/packages/server/src/controllers/project.ts +++ b/Composer/packages/server/src/controllers/project.ts @@ -17,6 +17,9 @@ import settings from '../settings'; import { Path } from './../utility/path'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const qnaBuild = require('@microsoft/bf-lu/lib/parser/qnabuild/builder.js'); + async function createProject(req: Request, res: Response) { let { templateId } = req.body; const { name, description, storageId, location, schemaUrl } = req.body; @@ -391,6 +394,24 @@ async function updateBoilerplate(req: Request, res: Response) { } } +async function parseQnAContent(req: Request, res: Response) { + const subscriptionKey = req.query.subscriptionKey; + const url = req.query.url; + const region = req.query.region; + const subscriptionKeyEndpoint = `https://${region}.api.cognitive.microsoft.com/qnamaker/v4.0`; + try { + const builder = new qnaBuild.Builder((message) => { + log(message); + }); + const qnaContent = await builder.importUrlReference(url, subscriptionKey, subscriptionKeyEndpoint, 'default'); + res.status(200).json(qnaContent); + } catch (e) { + res.status(400).json({ + message: e.message, + }); + } +} + export const ProjectController = { getProjectById, openProject, @@ -408,4 +429,5 @@ export const ProjectController = { getRecentProjects, updateBoilerplate, checkBoilerplateVersion, + parseQnAContent, }; diff --git a/Composer/packages/server/src/models/bot/botProject.ts b/Composer/packages/server/src/models/bot/botProject.ts index 6625889dfa..627ce89471 100644 --- a/Composer/packages/server/src/models/bot/botProject.ts +++ b/Composer/packages/server/src/models/bot/botProject.ts @@ -450,7 +450,7 @@ export class BotProject { } const fileList: FileInfo[] = []; - const patterns = ['**/*.dialog', '**/*.lg', '**/*.lu', 'manifests/*.json']; + const patterns = ['**/*.dialog', '**/*.lg', '**/*.lu', '**/*.qna', 'manifests/*.json']; for (const pattern of patterns) { // load only from the data dir, otherwise may get "build" versions from // deployment process diff --git a/Composer/packages/server/src/router/api.ts b/Composer/packages/server/src/router/api.ts index 2a0f8f9877..2da6b7fdd8 100644 --- a/Composer/packages/server/src/router/api.ts +++ b/Composer/packages/server/src/router/api.ts @@ -30,6 +30,7 @@ router.get('/projects/:projectId/export', ProjectController.exportProject); // update the boilerplate content router.get('/projects/:projectId/boilerplateVersion', ProjectController.checkBoilerplateVersion); router.post('/projects/:projectId/updateBoilerplate', ProjectController.updateBoilerplate); +router.get('/qnaContent', ProjectController.parseQnAContent); // storages router.put('/storages/currentPath', StorageController.updateCurrentPath);