diff --git a/Composer/packages/client/__tests__/components/skill.test.tsx b/Composer/packages/client/__tests__/components/skill.test.tsx index 68e7c97738..fcfe40fb60 100644 --- a/Composer/packages/client/__tests__/components/skill.test.tsx +++ b/Composer/packages/client/__tests__/components/skill.test.tsx @@ -6,7 +6,10 @@ import { act, fireEvent } from '@botframework-composer/test-utils'; import httpClient from '../../src/utils/httpUtil'; import { renderWithRecoil } from '../testUtils'; -import CreateSkillModal, { validateEndpoint, validateManifestUrl } from '../../src/components/CreateSkillModal'; +import CreateSkillModal, { + validateManifestUrl, + getSkillManifest, +} from '../../src/components/AddRemoteSkillModal/CreateSkillModal'; import { currentProjectIdState, settingsState } from '../../src/recoilModel'; jest.mock('../../src//utils/httpUtil'); @@ -50,13 +53,19 @@ describe('', () => { (httpClient.get as jest.Mock).mockResolvedValue({ endpoints: [] }); const onDismiss = jest.fn(); - const onSubmit = jest.fn(); - const { getByLabelText, getByText } = renderWithRecoil( - , + const addRemoteSkill = jest.fn(); + const addTriggerToRoot = jest.fn(); + const { getByLabelText } = renderWithRecoil( + , recoilInitState ); - const urlInput = getByLabelText('Manifest URL'); + const urlInput = getByLabelText('Skill Manifest Url'); act(() => { fireEvent.change(urlInput, { target: { value: 'https://onenote-dev.azurewebsites.net/manifests/OneNoteSync-2-1-preview-1-manifest.json' }, @@ -66,12 +75,6 @@ describe('', () => { expect(urlInput.getAttribute('value')).toBe( 'https://onenote-dev.azurewebsites.net/manifests/OneNoteSync-2-1-preview-1-manifest.json' ); - - const submitButton = getByText('Confirm'); - act(() => { - fireEvent.click(submitButton); - }); - expect(onSubmit).not.toBeCalled(); } finally { jest.runOnlyPendingTimers(); jest.useRealTimers(); @@ -79,17 +82,13 @@ describe('', () => { }); let formDataErrors; - let validationState; let setFormDataErrors; let setSkillManifest; - let setValidationState; beforeEach(() => { formDataErrors = {}; - validationState = {}; setFormDataErrors = jest.fn(); setSkillManifest = jest.fn(); - setValidationState = jest.fn(); }); describe('validateManifestUrl', () => { @@ -99,18 +98,13 @@ describe('', () => { await validateManifestUrl({ formData, formDataErrors, - projectId, setFormDataErrors, - setValidationState, - setSkillManifest, - validationState, }); expect(setFormDataErrors).toBeCalledWith( expect.objectContaining({ manifestUrl: 'URL should start with http:// or https://' }) ); expect(setSkillManifest).not.toBeCalled(); - expect(setValidationState).not.toBeCalled(); }); }); @@ -121,84 +115,38 @@ describe('', () => { validateManifestUrl({ formData, formDataErrors, - projectId, setFormDataErrors, - setValidationState, - setSkillManifest, - validationState, }); expect(setFormDataErrors).toBeCalledWith(expect.objectContaining({ manifestUrl: 'Please input a manifest URL' })); }); - it('should try and retrieve manifest if manifest URL meets other criteria', async () => { + it('should try and retrieve manifest', async () => { (httpClient.get as jest.Mock) = jest.fn().mockResolvedValue({ data: 'skill manifest' }); - const formData = { manifestUrl: 'https://skill' }; - const formDataErrors = { manifestUrl: 'error' }; + const manifestUrl = 'https://skill'; - await validateManifestUrl({ - formData, - formDataErrors, - projectId, - setFormDataErrors, - setValidationState, - setSkillManifest, - validationState, - }); - expect(setValidationState).toBeCalledWith( - expect.objectContaining({ - manifestUrl: 'Validating', - }) - ); + await getSkillManifest(projectId, manifestUrl, setSkillManifest, setFormDataErrors); expect(httpClient.get).toBeCalledWith(`/projects/${projectId}/skill/retrieveSkillManifest`, { params: { - url: formData.manifestUrl, + url: manifestUrl, }, }); expect(setSkillManifest).toBeCalledWith('skill manifest'); - expect(setValidationState).toBeCalledWith( - expect.objectContaining({ - manifestUrl: 'Validated', - }) - ); - expect(setFormDataErrors).toBeCalledWith( - expect.not.objectContaining({ - manifestUrl: 'error', - }) - ); }); it('should show error when it could not retrieve skill manifest', async () => { (httpClient.get as jest.Mock) = jest.fn().mockRejectedValue({ message: 'skill manifest' }); - const formData = { manifestUrl: 'https://skill' }; + const manifestUrl = 'https://skill'; - await validateManifestUrl({ - formData, - formDataErrors, - projectId, - setFormDataErrors, - setValidationState, - setSkillManifest, - validationState, - }); - expect(setValidationState).toBeCalledWith( - expect.objectContaining({ - manifestUrl: 'Validating', - }) - ); + await getSkillManifest(projectId, manifestUrl, setSkillManifest, setFormDataErrors); expect(httpClient.get).toBeCalledWith(`/projects/${projectId}/skill/retrieveSkillManifest`, { params: { - url: formData.manifestUrl, + url: manifestUrl, }, }); expect(setSkillManifest).not.toBeCalled(); - expect(setValidationState).toBeCalledWith( - expect.objectContaining({ - manifestUrl: 'NotValidated', - }) - ); expect(setFormDataErrors).toBeCalledWith( expect.objectContaining({ manifestUrl: 'Manifest URL can not be accessed', @@ -206,105 +154,4 @@ describe('', () => { ); }); }); - - describe('validateEndpoint', () => { - it('should set an error for missing msAppId', () => { - const formData = { endpointUrl: 'https://skill/api/messages' }; - - validateEndpoint({ - formData, - formDataErrors, - setFormDataErrors, - setValidationState, - validationState, - }); - - expect(setFormDataErrors).toBeCalledWith( - expect.objectContaining({ - endpoint: 'Please select a valid endpoint', - }) - ); - expect(setValidationState).not.toBeCalled(); - }); - - it('should set an error for missing endpointUrl', () => { - const formData = { msAppId: '00000000-0000-0000-0000-000000000000' }; - - validateEndpoint({ - formData, - formDataErrors, - setFormDataErrors, - setValidationState, - validationState, - }); - - expect(setFormDataErrors).toBeCalledWith( - expect.objectContaining({ - endpoint: 'Please select a valid endpoint', - }) - ); - expect(setValidationState).not.toBeCalled(); - }); - - it('should set an error for malformed msAppId', () => { - const formData = { endpointUrl: 'https://skill/api/messages', msAppId: 'malformed app id' }; - - validateEndpoint({ - formData, - formDataErrors, - setFormDataErrors, - setValidationState, - validationState, - }); - - expect(setFormDataErrors).toBeCalledWith( - expect.objectContaining({ - endpoint: 'Skill manifest endpoint is configured improperly', - }) - ); - expect(setValidationState).not.toBeCalled(); - }); - - it('should set an error for malformed endpointUrl', () => { - const formData = { endpointUrl: 'malformed endpoint', msAppId: '00000000-0000-0000-0000-000000000000' }; - - validateEndpoint({ - formData, - formDataErrors, - setFormDataErrors, - setValidationState, - validationState, - }); - - expect(setFormDataErrors).toBeCalledWith( - expect.objectContaining({ - endpoint: 'Skill manifest endpoint is configured improperly', - }) - ); - expect(setValidationState).not.toBeCalled(); - }); - - it('should not set an error', () => { - const formData = { endpointUrl: 'https://skill/api/messages', msAppId: '00000000-0000-0000-0000-000000000000' }; - - validateEndpoint({ - formData, - formDataErrors, - setFormDataErrors, - setValidationState, - validationState, - }); - - expect(setFormDataErrors).toBeCalledWith( - expect.not.objectContaining({ - endpoint: expect.any(String), - }) - ); - expect(setValidationState).toBeCalledWith( - expect.objectContaining({ - endpoint: 'Validated', - }) - ); - }); - }); }); diff --git a/Composer/packages/client/src/components/AddRemoteSkillModal/CreateSkillModal.tsx b/Composer/packages/client/src/components/AddRemoteSkillModal/CreateSkillModal.tsx new file mode 100644 index 0000000000..2240e90a23 --- /dev/null +++ b/Composer/packages/client/src/components/AddRemoteSkillModal/CreateSkillModal.tsx @@ -0,0 +1,284 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React, { Fragment, useRef, useState, useCallback, useEffect, useMemo } from 'react'; +import formatMessage from 'format-message'; +import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { Stack, StackItem } from 'office-ui-fabric-react/lib/Stack'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { useRecoilValue } from 'recoil'; +import debounce from 'lodash/debounce'; +import { isUsingAdaptiveRuntime, SDKKinds } from '@bfc/shared'; +import { DialogWrapper, DialogTypes } from '@bfc/ui-shared'; +import { Link } from 'office-ui-fabric-react/lib/Link'; +import { Separator } from 'office-ui-fabric-react/lib/Separator'; +import { Dropdown, IDropdownOption, ResponsiveMode } from 'office-ui-fabric-react/lib/Dropdown'; + +import { LoadingSpinner } from '../../components/LoadingSpinner'; +import { settingsState, designPageLocationState, dispatcherState, luFilesSelectorFamily } from '../../recoilModel'; +import { addSkillDialog } from '../../constants'; +import httpClient from '../../utils/httpUtil'; +import TelemetryClient from '../../telemetry/TelemetryClient'; +import { TriggerFormData } from '../../utils/dialogUtil'; +import { selectIntentDialog } from '../../constants'; + +import { SelectIntent } from './SelectIntent'; +import { SkillDetail } from './SkillDetail'; + +export interface SkillFormDataErrors { + endpoint?: string; + manifestUrl?: string; + name?: string; +} + +export const urlRegex = /^http[s]?:\/\/\w+/; +export const skillNameRegex = /^\w[-\w]*$/; +export const msAppIdRegex = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/; + +export interface CreateSkillModalProps { + projectId: string; + addRemoteSkill: (manifestUrl: string, endpointName: string) => Promise; + addTriggerToRoot: (dialogId: string, triggerFormData: TriggerFormData, skillId: string) => void; + onDismiss: () => void; +} + +export const validateManifestUrl = async ({ formData, formDataErrors, setFormDataErrors }) => { + const { manifestUrl } = formData; + const { manifestUrl: _, ...errors } = formDataErrors; + + if (!manifestUrl) { + setFormDataErrors({ ...errors, manifestUrl: formatMessage('Please input a manifest URL') }); + } else if (!urlRegex.test(manifestUrl)) { + setFormDataErrors({ ...errors, manifestUrl: formatMessage('URL should start with http:// or https://') }); + } else { + setFormDataErrors({}); + } +}; +export const getSkillManifest = async (projectId: string, manifestUrl: string, setSkillManifest, setFormDataErrors) => { + try { + const { data } = await httpClient.get(`/projects/${projectId}/skill/retrieveSkillManifest`, { + params: { + url: manifestUrl, + }, + }); + setSkillManifest(data); + if (!data.dispatchModels) { + setFormDataErrors({ manifestUrl: formatMessage('Miss dispatch modal') }); + } + } catch (error) { + setFormDataErrors({ ...error, manifestUrl: formatMessage('Manifest URL can not be accessed') }); + } +}; +const getTriggerFormData = (intent: string, content: string): TriggerFormData => ({ + errors: {}, + $kind: 'Microsoft.OnIntent', + event: '', + intent: intent, + triggerPhrases: content, + regEx: '', +}); + +const buttonStyle = { root: { marginLeft: '8px' } }; + +export const CreateSkillModal: React.FC = (props) => { + const { projectId, addRemoteSkill, addTriggerToRoot, onDismiss } = props; + + const [title, setTitle] = useState({ + subText: '', + title: addSkillDialog.SKILL_MANIFEST_FORM.title, + }); + const [showIntentSelectDialog, setShowIntentSelectDialog] = useState(false); + const [formData, setFormData] = useState<{ manifestUrl: string; endpointName: string }>({ + manifestUrl: '', + endpointName: '', + }); + const [formDataErrors, setFormDataErrors] = useState({}); + const [skillManifest, setSkillManifest] = useState(null); + const [showDetail, setShowDetail] = useState(false); + + const { languages, luFeatures, runtime } = useRecoilValue(settingsState(projectId)); + const { dialogId } = useRecoilValue(designPageLocationState(projectId)); + const luFiles = useRecoilValue(luFilesSelectorFamily(projectId)); + const { updateRecognizer } = useRecoilValue(dispatcherState); + + const debouncedValidateManifestURl = useRef(debounce(validateManifestUrl, 500)).current; + + const validationHelpers = { + formDataErrors, + setFormDataErrors, + }; + + const options: IDropdownOption[] = useMemo(() => { + return skillManifest?.endpoints?.map((item) => { + return { + key: item.name, + // eslint-disable-next-line format-message/literal-pattern + text: formatMessage(item.name), + }; + }); + }, [skillManifest]); + + const handleManifestUrlChange = (_, currentManifestUrl = '') => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { manifestUrl, ...rest } = formData; + debouncedValidateManifestURl({ + formData: { manifestUrl: currentManifestUrl }, + ...validationHelpers, + }); + setFormData({ + ...rest, + manifestUrl: currentManifestUrl, + }); + setSkillManifest(null); + }; + + const validateUrl = useCallback( + (event) => { + event.preventDefault(); + setShowDetail(true); + getSkillManifest(projectId, formData.manifestUrl, setSkillManifest, setFormDataErrors); + }, + [projectId, formData] + ); + + const handleSubmit = (event, content: string, enable: boolean) => { + event.preventDefault(); + // add a remote skill, add skill identifier into botProj file + addRemoteSkill(formData.manifestUrl, formData.endpointName).then(() => { + TelemetryClient.track('AddNewSkillCompleted'); + const skillId = location.href.match(/skill\/([^/]*)/)?.[1]; + if (skillId) { + // add trigger with connect to skill action to root bot + const triggerFormData = getTriggerFormData(skillManifest.name, content); + addTriggerToRoot(dialogId, triggerFormData, skillId); + TelemetryClient.track('AddNewTriggerCompleted', { kind: 'Microsoft.OnIntent' }); + } + }); + + if (enable) { + // update recognizor type to orchestrator + updateRecognizer(projectId, dialogId, SDKKinds.OrchestratorRecognizer); + } + }; + + useEffect(() => { + if (skillManifest?.endpoints) { + setFormData({ + ...formData, + endpointName: skillManifest.endpoints[0].name, + }); + } + }, [skillManifest]); + + return ( + + {showIntentSelectDialog ? ( + { + setTitle({ + subText: '', + title: addSkillDialog.SKILL_MANIFEST_FORM.title, + }); + setShowIntentSelectDialog(false); + }} + onDismiss={onDismiss} + onSubmit={handleSubmit} + onUpdateTitle={setTitle} + /> + ) : ( + +
+ {addSkillDialog.SKILL_MANIFEST_FORM.preSubText} + + {formatMessage('Get an overview')} + + or + + {formatMessage('learn how to build a skill')} + + {addSkillDialog.SKILL_MANIFEST_FORM.afterSubText} +
+ + +
+ + {skillManifest?.endpoints?.length > 1 && ( + { + if (option) { + console.log(option); + setFormData({ + ...formData, + endpointName: option.key as string, + }); + } + }} + /> + )} +
+ {showDetail && ( + + +
+ {skillManifest ? : } +
+
+ )} +
+ + + + + {skillManifest ? ( + isUsingAdaptiveRuntime(runtime) && luFiles.length > 0 ? ( + { + setTitle(selectIntentDialog.SELECT_INTENT(dialogId, skillManifest.name)); + setShowIntentSelectDialog(true); + }} + /> + ) : ( + { + addRemoteSkill(formData.manifestUrl, formData.endpointName); + }} + /> + ) + ) : ( + + )} + + +
+ )} +
+ ); +}; + +export default CreateSkillModal; diff --git a/Composer/packages/client/src/components/AddRemoteSkillModal/Orchestractor.tsx b/Composer/packages/client/src/components/AddRemoteSkillModal/Orchestractor.tsx new file mode 100644 index 0000000000..1c2d137298 --- /dev/null +++ b/Composer/packages/client/src/components/AddRemoteSkillModal/Orchestractor.tsx @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React, { useState } from 'react'; +import formatMessage from 'format-message'; +import { Link } from 'office-ui-fabric-react/lib/Link'; +import { Checkbox } from 'office-ui-fabric-react/lib/Checkbox'; +import { Stack, StackItem } from 'office-ui-fabric-react/lib/Stack'; +import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { useRecoilValue } from 'recoil'; + +import { dispatcherState } from '../../recoilModel'; +import { enableOrchestratorDialog } from '../../constants'; + +import { importOrchestractor } from './helper'; + +const learnMoreUrl = 'https://aka.ms/bf-composer-docs-publish-bot'; + +export const Orchestractor = (props) => { + const { projectId, onSubmit, onBack } = props; + const [enableOrchestrator, setEnableOrchestrator] = useState(true); + const { setApplicationLevelError, reloadProject } = useRecoilValue(dispatcherState); + const onChange = (ev, check) => { + setEnableOrchestrator(check); + }; + return ( + + +
+ {enableOrchestratorDialog.content} + +
{formatMessage('Learn more about Orchestractor')}
+ +
+ +
+ + + + + { + onSubmit(event, enableOrchestrator); + if (enableOrchestrator) { + // TODO. show notification + // download orchestrator first + importOrchestractor(projectId, reloadProject, setApplicationLevelError); + // TODO. update notification + } + }} + /> + + +
+ ); +}; diff --git a/Composer/packages/client/src/components/AddRemoteSkillModal/SelectIntent.tsx b/Composer/packages/client/src/components/AddRemoteSkillModal/SelectIntent.tsx new file mode 100644 index 0000000000..9b3f64fe75 --- /dev/null +++ b/Composer/packages/client/src/components/AddRemoteSkillModal/SelectIntent.tsx @@ -0,0 +1,359 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** @jsx jsx */ +import { jsx, css } from '@emotion/core'; +import React, { Fragment, useState, useMemo, useEffect, useCallback } from 'react'; +import formatMessage from 'format-message'; +import { DetailsList, SelectionMode, CheckboxVisibility } from 'office-ui-fabric-react/lib/DetailsList'; +import { Selection } from 'office-ui-fabric-react/lib/Selection'; +import { Separator } from 'office-ui-fabric-react/lib/Separator'; +import { Stack, StackItem } from 'office-ui-fabric-react/lib/Stack'; +import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { Label } from 'office-ui-fabric-react/lib/Label'; +import { LuEditor } from '@bfc/code-editor'; +import { ScrollablePane, ScrollbarVisibility } from 'office-ui-fabric-react/lib/ScrollablePane'; +import { LuFile, LuIntentSection, SDKKinds, ILUFeaturesConfig } from '@bfc/shared'; +import { useRecoilValue } from 'recoil'; + +import TelemetryClient from '../../telemetry/TelemetryClient'; +import { selectIntentDialog, enableOrchestratorDialog } from '../../constants'; +import httpClient from '../../utils/httpUtil'; +import luWorker from '../../recoilModel/parsers/luWorker'; +import { localeState, dispatcherState } from '../../recoilModel'; +import { recognizersSelectorFamily } from '../../recoilModel/selectors/recognizers'; + +import { Orchestractor } from './Orchestractor'; +import { getLuDiagnostics } from './helper'; + +const detailListContainer = css` + width: 100%; + height: 300px; + position: relative; + overflow: hidden; + flex-grow: 1; + border: 1px solid #e5e5e5; +`; + +type SelectIntentProps = { + manifest: { + dispatchModels: { + intents: string[] | object; + languages: object; + }; + } & Record; + languages: string[]; + projectId: string; + luFeatures: ILUFeaturesConfig; + rootLuFiles: LuFile[]; + dialogId: string; + onSubmit: (event: Event, content: string, enable: boolean) => void; + onDismiss: () => void; + onUpdateTitle: (title: { title: string; subText: string }) => void; + onBack: () => void; +} & Record; + +const columns = [ + { + key: 'Name', + name: 'Name', + minWidth: 300, + isResizable: false, + onRender: (item: string) => { + return {item}; + }, + }, +]; + +const getRemoteLuFiles = async (skillLanguages: object, composerLangeages: string[], setWarningMsg) => { + const luFilePromise: Promise[] = []; + try { + for (const [key, value] of Object.entries(skillLanguages)) { + if (composerLangeages.includes(key)) { + value.map((item) => { + // get lu file + luFilePromise.push( + httpClient + .get(`/utilities/retrieveRemoteFile`, { + params: { + url: item.url, + }, + }) + .catch((err) => { + console.error(err); + setWarningMsg('get remote file fail'); + }) + ); + }); + } + } + const responses = await Promise.all(luFilePromise); + const files: { id: string; content: string }[] = responses.map((response) => { + return response.data; + }); + return files; + } catch (e) { + console.log(e); + } +}; + +const getParsedLuFiles = async (files: { id: string; content: string }[], luFeatures, lufiles) => { + const promises = files.map((item) => { + return luWorker.parse(item.id, item.content, luFeatures, lufiles) as Promise; + }); + return await Promise.all(promises); +}; + +const mergeIntentsContent = (intents: LuIntentSection[]) => { + return ( + intents + .map((item) => { + return `> ${item.Name}\n${item.Body}`; + }) + .join('\n') || '' + ); +}; +export const SelectIntent: React.FC = (props) => { + const { + manifest, + onSubmit, + onDismiss, + languages, + luFeatures, + projectId, + rootLuFiles, + dialogId, + onUpdateTitle, + onBack, + } = props; + const [pageIndex, setPage] = useState(0); + const [selectedIntents, setSelectedIntents] = useState>([]); + // luFiles from manifest, language was included in root bot languages + const [luFiles, setLufiles] = useState>([]); + // current locale Lufile + const [currentLuFile, setCurrentLuFile] = useState(); + // selected intents in different languages + const [multiLanguageIntents, setMultiLanguageIntents] = useState>>({}); + // selected current locale intents content + const [displayContent, setDisplayContent] = useState(''); + const locale = useRecoilValue(localeState(projectId)); + const [showOrchestratorDialog, setShowOrchestratorDialog] = useState(false); + const { batchUpdateLuFiles } = useRecoilValue(dispatcherState); + const curRecognizers = useRecoilValue(recognizersSelectorFamily(projectId)); + const [triggerErrorMessage, setTriggerErrorMsg] = useState(''); + const [warningMsg, setWarningMsg] = useState(''); + + const hasOrchestrator = useMemo(() => { + const fileName = `${dialogId}.${locale}.lu.dialog`; + return curRecognizers.some((f) => f.id === fileName && f.content.$kind === SDKKinds.OrchestratorRecognizer); + }, [curRecognizers, dialogId, locale]); + + const selection = useMemo(() => { + return new Selection({ + onSelectionChanged: () => { + const selectedItems = selection.getSelection(); + setSelectedIntents(selectedItems as string[]); + }, + }); + }, []); + + // intents from manifest, intents can be an object or array. + const intentItems = useMemo(() => { + let res; + if (Array.isArray(manifest.dispatchModels?.intents)) { + res = manifest.dispatchModels.intents; + } else { + res = Object.keys(manifest.dispatchModels?.intents); + } + return res; + }, [manifest]); + + const updateLuFiles = useCallback(() => { + const payloads: { projectId: string; id: string; content: string }[] = []; + for (const lufile of rootLuFiles) { + const rootId = lufile.id.split('.'); + const language = rootId[rootId.length - 1]; + let append = ''; + if (language === locale) { + append = displayContent; + } else { + const intents = multiLanguageIntents[language]; + if (!intents) { + continue; + } + append = mergeIntentsContent(intents); + } + payloads.push({ + projectId, + id: lufile.id, + content: `${lufile.content}\n# ${manifest.name}\n${append}`, + }); + } + batchUpdateLuFiles(payloads); + }, [rootLuFiles, projectId, locale, displayContent, multiLanguageIntents]); + + useEffect(() => { + if (locale) { + const skillLanguages = manifest.dispatchModels?.languages; + const luFeaturesTemp = { + enableMLEntities: false, + enableListEntities: false, + enableCompositeEntities: false, + enablePrebuiltEntities: false, + enableRegexEntities: false, + }; + getRemoteLuFiles(skillLanguages, languages, setWarningMsg) + .then((items) => { + items && + getParsedLuFiles(items, luFeaturesTemp, []).then((files) => { + setLufiles(files); + files.map((file) => { + if (file.id.includes(locale)) { + setCurrentLuFile(file); + } + }); + }); + }) + .catch((e) => { + console.log(e); + setWarningMsg(formatMessage('get remote file fail')); + }); + } + }, [manifest.dispatchModels?.languages, languages, locale]); + + useEffect(() => { + if (selectedIntents.length > 0) { + const intents: LuIntentSection[] = []; + const multiLanguageIntents: Record = {}; + currentLuFile?.intents?.forEach((cur) => { + if (selectedIntents.includes(cur.Name)) { + intents.push(cur); + } + }); + for (const file of luFiles) { + const id = file.id.split('.'); + const language = id[id.length - 1]; + multiLanguageIntents[language] = []; + for (const intent of file.intents) { + if (selectedIntents.includes(intent.Name)) { + multiLanguageIntents[language].push(intent); + } + } + } + + setMultiLanguageIntents(multiLanguageIntents); + // current locale, selected intent value. + const intentsValue = mergeIntentsContent(intents); + setDisplayContent(intentsValue); + } else { + setDisplayContent(''); + setMultiLanguageIntents({}); + } + }, [selectedIntents, currentLuFile, luFiles]); + + useEffect(() => { + if (displayContent) { + const error = getLuDiagnostics('manifestName', displayContent); + setTriggerErrorMsg(error); + } else { + setTriggerErrorMsg(''); + } + }, [displayContent]); + + const handleSubmit = (ev, enableOchestractor) => { + // append remote lufile into root lu file + updateLuFiles(); + // add trigger to root + onSubmit(ev, displayContent, enableOchestractor); + }; + + return ( + + {showOrchestratorDialog ? ( + { + onUpdateTitle(selectIntentDialog.ADD_OR_EDIT_PHRASE(dialogId, manifest.name)); + setShowOrchestratorDialog(false); + }} + onSubmit={handleSubmit} + /> + ) : ( + + {pageIndex === 0 ? ( + + +
+ + + +
+
+ ) : ( + + + + )} + + + {pageIndex === 1 ? ( + { + setPage(pageIndex - 1); + onUpdateTitle(selectIntentDialog.SELECT_INTENT(dialogId, manifest.name)); + }} + /> + ) : ( + + )} + + + { + if (pageIndex === 1) { + if (hasOrchestrator) { + // skip orchestractor modal + handleSubmit(ev, true); + } else { + // show orchestractor + onUpdateTitle(enableOrchestratorDialog); + setShowOrchestratorDialog(true); + } + } else { + // show next page + setPage(pageIndex + 1); + onUpdateTitle(selectIntentDialog.ADD_OR_EDIT_PHRASE(dialogId, manifest.name)); + } + }} + /> + + +
+ )} +
+ ); +}; diff --git a/Composer/packages/client/src/components/AddRemoteSkillModal/SkillDetail.tsx b/Composer/packages/client/src/components/AddRemoteSkillModal/SkillDetail.tsx new file mode 100644 index 0000000000..de3a82cd67 --- /dev/null +++ b/Composer/packages/client/src/components/AddRemoteSkillModal/SkillDetail.tsx @@ -0,0 +1,70 @@ +// 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'; +type SkillDetailProps = { + manifest: { + dispatchModels?: { + intents: string[] | object; + languages: Record; + }; + version?: string; + activities?: Record< + string, + { + type: string; + name: string; + } + >; + publisherName?: string; + description?: string; + name?: string; + }; +}; +const container = css` + width: 100%; + margin: 10px 0px; +`; +const segment = css` + margin: 15px 0px; +`; +const title = css` + font-size: 20px; + line-height: 24px; + font-weight: 600; +`; +const subTitle = css` + font-size: 14px; + line-height: 20px; + font-weight: 600; +`; +const text = css` + font-size: 14px; + line-height: 20px; +`; +export const SkillDetail: React.FC = (props) => { + const { manifest } = props; + return ( +
+
{manifest.name || ''}
+
+
{formatMessage('Description')}
+
{manifest.description || ''}
+
+
+
{formatMessage('Version')}
+
{manifest.version || ''}
+
+
+
{formatMessage('Activities')}
+
{manifest.activities ? Object.keys(manifest.activities).join(', ') : ''}
+
+
+
{formatMessage('Publisher')}
+
{manifest.publisherName || ''}
+
+
+ ); +}; diff --git a/Composer/packages/client/src/components/AddRemoteSkillModal/helper.ts b/Composer/packages/client/src/components/AddRemoteSkillModal/helper.ts new file mode 100644 index 0000000000..fd9f6d141d --- /dev/null +++ b/Composer/packages/client/src/components/AddRemoteSkillModal/helper.ts @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import formatMessage from 'format-message'; +import { luIndexer, combineMessage } from '@bfc/indexers'; +import { OpenConfirmModal } from '@bfc/ui-shared'; + +import httpClient from '../../utils/httpUtil'; +import TelemetryClient from '../../telemetry/TelemetryClient'; + +const conflictConfirmationTitle = formatMessage('Conflicting changes detected'); +const conflictConfirmationPrompt = formatMessage( + 'This operation will overwrite changes made to previously imported files. Do you want to proceed?' +); + +export const importOrchestractor = async (projectId: string, reloadProject, setApplicationLevelError) => { + const reqBody = { + package: 'Microsoft.Bot.Builder.AI.Orchestrator', + version: '', + source: 'https://botbuilder.myget.org/F/botbuilder-v4-dotnet-daily/api/v3/index.json', + isUpdating: false, + isPreview: true, + }; + try { + const results = await httpClient.post(`projects/${projectId}/import`, reqBody); + // check to see if there was a conflict that requires confirmation + if (results.data.success === false) { + TelemetryClient.track('PackageInstallConflictFound', { ...reqBody, isUpdate: reqBody.isUpdating }); + const confirmResult = await OpenConfirmModal(conflictConfirmationTitle, conflictConfirmationPrompt); + + if (confirmResult) { + TelemetryClient.track('PackageInstallConflictResolved', { ...reqBody, isUpdate: reqBody.isUpdating }); + // update package, set isUpdating to true + await httpClient.post(`projects/${projectId}/import`, { ...reqBody, isUpdate: true }); + } + } else { + TelemetryClient.track('PackageInstalled', { ...reqBody, isUpdate: reqBody.isUpdating }); + // reload modified content + await reloadProject(projectId); + } + } catch (err) { + TelemetryClient.track('PackageInstallFailed', { ...reqBody, isUpdate: reqBody.isUpdating }); + setApplicationLevelError({ + status: err.response.status, + message: err.response && err.response.data.message ? err.response.data.message : err, + summary: formatMessage('Install Error'), + }); + } +}; + +export const getLuDiagnostics = (intent: string, triggerPhrases: string) => { + const content = `#${intent}\n${triggerPhrases}`; + const { diagnostics } = luIndexer.parse(content, '', { + enableListEntities: false, + enableCompositeEntities: false, + enableMLEntities: false, + enablePrebuiltEntities: false, + enableRegexEntities: false, + }); + return combineMessage(diagnostics); +}; diff --git a/Composer/packages/client/src/components/CreateSkillModal.tsx b/Composer/packages/client/src/components/CreateSkillModal.tsx deleted file mode 100644 index 22f9ee7423..0000000000 --- a/Composer/packages/client/src/components/CreateSkillModal.tsx +++ /dev/null @@ -1,247 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import React, { useRef, useState, useMemo } from 'react'; -import formatMessage from 'format-message'; -import { Dropdown, IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; -import { Label } from 'office-ui-fabric-react/lib/Label'; -import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; -import { Spinner, SpinnerSize } from 'office-ui-fabric-react/lib/Spinner'; -import { Stack, StackItem } from 'office-ui-fabric-react/lib/Stack'; -import { TextField } from 'office-ui-fabric-react/lib/TextField'; -import { useRecoilValue } from 'recoil'; -import debounce from 'lodash/debounce'; -import { DialogWrapper, DialogTypes } from '@bfc/ui-shared'; - -import { addSkillDialog } from '../constants'; -import httpClient from '../utils/httpUtil'; -import { skillsStateSelector } from '../recoilModel'; -import TelemetryClient from '../telemetry/TelemetryClient'; - -export interface SkillFormDataErrors { - endpoint?: string; - manifestUrl?: string; - name?: string; -} - -export const urlRegex = /^http[s]?:\/\/\w+/; -export const skillNameRegex = /^\w[-\w]*$/; -export const msAppIdRegex = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/; - -export interface CreateSkillModalProps { - projectId: string; - onSubmit: (manifestUrl: string, endpointName: string) => void; - onDismiss: () => void; -} - -enum ValidationState { - NotValidated = 'NotValidated', - Validating = 'Validating', - Validated = 'Validated', -} - -export const validateEndpoint = ({ - formData, - formDataErrors, - setFormDataErrors, - setValidationState, - validationState, -}) => { - const { msAppId, endpointUrl } = formData; - const { endpoint: _, ...errors } = formDataErrors; - - if (!msAppId || !endpointUrl) { - setFormDataErrors({ ...errors, endpoint: formatMessage('Please select a valid endpoint') }); - } else if (!urlRegex.test(endpointUrl) || !msAppIdRegex.test(msAppId)) { - setFormDataErrors({ ...errors, endpoint: formatMessage('Skill manifest endpoint is configured improperly') }); - } else { - setFormDataErrors(errors); - setValidationState({ ...validationState, endpoint: ValidationState.Validated }); - } -}; - -export const validateManifestUrl = async ({ - formData, - formDataErrors, - projectId, - setFormDataErrors, - setValidationState, - setSkillManifest, - validationState, -}) => { - const { manifestUrl } = formData; - const { manifestUrl: _, ...errors } = formDataErrors; - - if (!manifestUrl) { - setFormDataErrors({ ...errors, manifestUrl: formatMessage('Please input a manifest URL') }); - } else if (!urlRegex.test(manifestUrl)) { - setFormDataErrors({ ...errors, manifestUrl: formatMessage('URL should start with http:// or https://') }); - } else { - try { - setValidationState({ ...validationState, manifestUrl: ValidationState.Validating }); - const { data } = await httpClient.get(`/projects/${projectId}/skill/retrieveSkillManifest`, { - params: { - url: manifestUrl, - }, - }); - setFormDataErrors(errors); - setSkillManifest(data); - setValidationState({ ...validationState, manifestUrl: ValidationState.Validated }); - } catch (error) { - setFormDataErrors({ ...errors, manifestUrl: formatMessage('Manifest URL can not be accessed') }); - setValidationState({ ...validationState, manifestUrl: ValidationState.NotValidated }); - } - } -}; - -export const CreateSkillModal: React.FC = ({ projectId, onSubmit, onDismiss }) => { - const skills = useRecoilValue(skillsStateSelector); - - const [formData, setFormData] = useState<{ manifestUrl: string; endpointName: string }>({ - manifestUrl: '', - endpointName: '', - }); - const [formDataErrors, setFormDataErrors] = useState({}); - const [validationState, setValidationState] = useState({ - endpoint: ValidationState.NotValidated, - manifestUrl: ValidationState.NotValidated, - name: ValidationState.Validated, - }); - const [selectedEndpointKey, setSelectedEndpointKey] = useState(null); - const [skillManifest, setSkillManifest] = useState(null); - - const endpointOptions = useMemo(() => { - return (skillManifest?.endpoints || [])?.map(({ name, endpointUrl, msAppId }, key) => ({ - key, - text: name, - data: { - endpointUrl, - msAppId, - }, - })); - }, [skillManifest]); - - const debouncedValidateManifestURl = useRef(debounce(validateManifestUrl, 500)).current; - - const validationHelpers = { - formDataErrors, - skills, - setFormDataErrors, - setValidationState, - setSkillManifest, - validationState, - }; - - const handleManifestUrlChange = (_, currentManifestUrl = '') => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { manifestUrl, ...rest } = formData; - setValidationState((validationState) => ({ - ...validationState, - manifestUrl: ValidationState.NotValidated, - endpoint: ValidationState.NotValidated, - })); - debouncedValidateManifestURl({ - formData: { manifestUrl: currentManifestUrl }, - projectId, - ...validationHelpers, - }); - setFormData({ - ...rest, - manifestUrl: currentManifestUrl, - }); - setSkillManifest(null); - setSelectedEndpointKey(null); - }; - - const handleEndpointUrlChange = (_, option?: IDropdownOption) => { - if (option) { - const { data, key } = option; - validateEndpoint({ - formData: { - ...data, - ...formData, - }, - ...validationHelpers, - }); - setFormData({ - ...data, - ...formData, - }); - setSelectedEndpointKey(key as number); - } - }; - - const handleSubmit = (event) => { - event.preventDefault(); - - if ( - Object.values(validationState).every((validation) => validation === ValidationState.Validated) && - !Object.values(formDataErrors).some(Boolean) - ) { - onSubmit(formData.manifestUrl, formData.endpointName); - TelemetryClient.track('AddNewSkillCompleted'); - } - }; - - const isDisabled = - !formData.manifestUrl || - Object.values(formDataErrors).some(Boolean) || - !Object.values(validationState).every((validation) => validation === ValidationState.Validated); - - return ( - -
- - - - - {validationState.manifestUrl === ValidationState.Validating && ( - - )} - - - - - - - - - -
-
- ); -}; - -export default CreateSkillModal; diff --git a/Composer/packages/client/src/constants.ts b/Composer/packages/client/src/constants.ts index dc666760c7..a4de2659d6 100644 --- a/Composer/packages/client/src/constants.ts +++ b/Composer/packages/client/src/constants.ts @@ -317,7 +317,10 @@ export const addSkillDialog = { get SKILL_MANIFEST_FORM() { return { title: formatMessage('Add a skill'), - subText: formatMessage('Enter a manifest URL to add a new skill to your bot.'), + preSubText: formatMessage(`Skills extend your bot's conversational capabilities . To know more about skills`), + afterSubText: formatMessage( + `To make sure the skill will work correctly, we perform some validation checks. When you’re ready to add a skill, enter the Skill manifest URL provided to you by the skill author.` + ), }; }, get SKILL_MANIFEST_FORM_EDIT() { @@ -328,6 +331,39 @@ export const addSkillDialog = { }, }; +export const selectIntentDialog = { + SELECT_INTENT: (name: string, skill: string) => { + return { + // eslint-disable-next-line format-message/literal-pattern + title: formatMessage(`Select intents to trigger ${skill} skill`), + // eslint-disable-next-line format-message/literal-pattern + subText: formatMessage(`These intents will trigger this skill from ${name}`), + }; + }, + ADD_OR_EDIT_PHRASE: (name: string, skill: string) => { + return { + // eslint-disable-next-line format-message/literal-pattern + title: formatMessage(`Add or edit phrases to trigger ${skill} skill`), + // eslint-disable-next-line format-message/literal-pattern + subText: formatMessage(`These phrases will trigger this skill from ${name}`), + }; + }, +}; + +export const enableOrchestratorDialog = { + get title() { + return formatMessage('Enable Orchestrator'); + }, + get subText() { + return formatMessage('Enable orchestrator as the recognizer at the root dialog to add this skill'); + }, + get content() { + return formatMessage( + 'Multi-bot projects work best with the Orchestrator recognizer set at the root dialog. Orchestrator helps identify and dispatch user intents from the root dialog to the respective skill that can handle the intent. Orchestrator does not support entity extraction at the root dialog level.' + ); + }, +}; + export const repairSkillDialog = (name: string) => { return { title: formatMessage('Link to this skill has been broken'), diff --git a/Composer/packages/client/src/pages/design/Modals.tsx b/Composer/packages/client/src/pages/design/Modals.tsx index 2b0ed2b9ef..a06f4bc756 100644 --- a/Composer/packages/client/src/pages/design/Modals.tsx +++ b/Composer/packages/client/src/pages/design/Modals.tsx @@ -30,7 +30,7 @@ import { createQnAOnState, exportSkillModalInfoState } from '../../recoilModel/a import CreationModal from './creationModal'; -const CreateSkillModal = React.lazy(() => import('../../components/CreateSkillModal')); +const CreateSkillModal = React.lazy(() => import('../../components/AddRemoteSkillModal/CreateSkillModal')); const RepairSkillModal = React.lazy(() => import('../../components/RepairSkillModal')); const CreateDialogModal = React.lazy(() => import('./createDialogModal')); const DisplayManifestModal = React.lazy(() => import('../../components/Modal/DisplayManifestModal')); @@ -60,6 +60,7 @@ const Modals: React.FC = ({ projectId = '' }) => { createQnAKBsFromUrls, createQnAKBFromScratch, createTrigger, + createTriggerForRemoteSkill, createQnATrigger, createDialogCancel, } = useRecoilValue(dispatcherState); @@ -125,13 +126,16 @@ const Modals: React.FC = ({ projectId = '' }) => { )} {showAddSkillDialogModal && ( { + addRemoteSkill={async (manifestUrl, endpointName) => { setAddSkillDialogModalVisibility(false); + await addRemoteSkillToBotProject(manifestUrl, endpointName); + }} + addTriggerToRoot={(dialogId, formData, skillId) => { + createTriggerForRemoteSkill(projectId, dialogId, formData, skillId, false); }} - onSubmit={(manifestUrl, endpointName) => { + projectId={projectId} + onDismiss={() => { setAddSkillDialogModalVisibility(false); - addRemoteSkillToBotProject(manifestUrl, endpointName); }} /> )} diff --git a/Composer/packages/client/src/pages/design/SideBar.tsx b/Composer/packages/client/src/pages/design/SideBar.tsx index d18bf8a549..452d86ef31 100644 --- a/Composer/packages/client/src/pages/design/SideBar.tsx +++ b/Composer/packages/client/src/pages/design/SideBar.tsx @@ -77,7 +77,6 @@ const SideBar: React.FC = React.memo(({ projectId }) => { const undoFunction = useRecoilValue(undoFunctionState(projectId)); const rootProjectId = useRecoilValue(rootBotProjectIdSelector); const { commitChanges } = undoFunction; - const { removeDialog, updateDialog, @@ -247,6 +246,7 @@ const SideBar: React.FC = React.memo(({ projectId }) => { async function handleRemoveSkill(skillId: string) { // check if skill used in current project workspace const usedInBots = skillUsedInBotsMap[skillId]; + const confirmRemove = usedInBots.length ? await OpenConfirmModal(formatMessage('Warning'), removeSkillDialog().subText, { onRenderContent: () => { diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/project.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/project.test.tsx index d0344008c1..d06896162d 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/project.test.tsx +++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/project.test.tsx @@ -13,6 +13,9 @@ import httpClient from '../../../utils/httpUtil'; import { projectDispatcher } from '../project'; import { botProjectFileDispatcher } from '../botProjectFile'; import { publisherDispatcher } from '../publisher'; +import { triggerDispatcher } from '../trigger'; +import { settingsDispatcher } from '../setting'; +import { dialogsDispatcher } from '../dialogs'; import { renderRecoilHook } from '../../../../__tests__/testUtils'; import { recentProjectsState, @@ -207,6 +210,9 @@ describe('Project dispatcher', () => { projectDispatcher, botProjectFileDispatcher, publisherDispatcher, + triggerDispatcher, + dialogsDispatcher, + settingsDispatcher, }, }, } diff --git a/Composer/packages/client/src/recoilModel/dispatchers/botProjectFile.ts b/Composer/packages/client/src/recoilModel/dispatchers/botProjectFile.ts index fe2416d572..67d2480de3 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/botProjectFile.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/botProjectFile.ts @@ -76,9 +76,30 @@ export const botProjectFileDispatcher = () => { const rootBotSettings = await snapshot.getPromise(settingsState(rootBotProjectId)); if (rootBotSettings.skill) { const updatedSettings = produce(rootBotSettings, (draftState) => { - if (draftState.skill?.[botNameIdentifier]) { + let msAppId = ''; + if (draftState?.skill?.[botNameIdentifier]) { + msAppId = draftState.skill[botNameIdentifier].msAppId; delete draftState.skill[botNameIdentifier]; } + // remove msAppId in allowCallers + if ( + msAppId && + draftState?.skillConfiguration?.allowedCallers && + draftState?.skillConfiguration?.allowedCallers.length > 0 + ) { + draftState.skillConfiguration.allowedCallers = draftState.skillConfiguration.allowedCallers.filter( + (item) => item !== msAppId + ); + } + if ( + msAppId && + draftState?.runtimeSettings?.skills?.allowedCallers && + draftState?.runtimeSettings?.skills?.allowedCallers.length > 0 + ) { + draftState.runtimeSettings.skills.allowedCallers = draftState.runtimeSettings.skills.allowedCallers.filter( + (item) => item !== msAppId + ); + } }); setRootBotSettingState(callbackHelpers, rootBotProjectId, updatedSettings); } diff --git a/Composer/packages/client/src/recoilModel/dispatchers/lu.ts b/Composer/packages/client/src/recoilModel/dispatchers/lu.ts index 100b09d72d..7e56c9aa7e 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/lu.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/lu.ts @@ -176,6 +176,32 @@ export const removeLuFileState = async ( }; export const luDispatcher = () => { + const batchUpdateLuFiles = useRecoilCallback( + (callbackHelpers: CallbackInterface) => async ( + payloads: { + id: string; + content: string; + projectId: string; + }[] + ) => { + const { snapshot } = callbackHelpers; + payloads.map(async ({ id, content, projectId }) => { + const { luFeatures } = await snapshot.getPromise(settingsState(projectId)); + try { + const updatedFile = (await luWorker.parse(id, content, luFeatures, [])) as LuFile; + // compare to drop expired change on current id file. + /** + * Why other methods do not need double check content? + * Because this method already did set content before call luFilesAtomUpdater. + */ + updateLuFiles(callbackHelpers, projectId, { updates: [updatedFile] }); + } catch (error) { + setError(callbackHelpers, error); + } + }); + } + ); + const updateLuFile = useRecoilCallback( (callbackHelpers: CallbackInterface) => async ({ id, @@ -330,6 +356,7 @@ export const luDispatcher = () => { ); return { + batchUpdateLuFiles, updateLuFile, updateLuIntent, createLuIntent, diff --git a/Composer/packages/client/src/recoilModel/dispatchers/project.ts b/Composer/packages/client/src/recoilModel/dispatchers/project.ts index 823ed33a53..5d98733b2c 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/project.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/project.ts @@ -39,6 +39,8 @@ import { } from '../atoms'; import { botRuntimeOperationsSelector, rootBotProjectIdSelector } from '../selectors'; import { mergePropertiesManagedByRootBot, postRootBotCreation } from '../../recoilModel/dispatchers/utils/project'; +import { projectDialogsMapSelector, botDisplayNameState } from '../../recoilModel'; +import { deleteTrigger as DialogdeleteTrigger } from '../../utils/dialogUtil'; import { announcementState, boilerplateVersionState, recentProjectsState, templateIdState } from './../atoms'; import { logMessage, setError } from './../dispatchers/shared'; @@ -69,8 +71,26 @@ export const projectDispatcher = () => { const { set, snapshot } = callbackHelpers; const dispatcher = await snapshot.getPromise(dispatcherState); - await dispatcher.removeSkillFromBotProjectFile(projectIdToRemove); + const projectDialogsMap = await snapshot.getPromise(projectDialogsMapSelector); const rootBotProjectId = await snapshot.getPromise(rootBotProjectIdSelector); + // const manifestIdentifier = await snapshot.getPromise(botNameIdentifierState(projectIdToRemove)); + const triggerName = await snapshot.getPromise(botDisplayNameState(projectIdToRemove)); + const rootDialog = rootBotProjectId && projectDialogsMap[rootBotProjectId].find((dialog) => dialog.isRoot); + // remove the same identifier trigger in root bot + if (rootBotProjectId && rootDialog && rootDialog.triggers.length > 0) { + const index = rootDialog.triggers.findIndex((item) => item.displayName === triggerName); + const content = DialogdeleteTrigger( + projectDialogsMap[rootBotProjectId], + rootDialog?.id, + index, + async (trigger) => await dispatcher.deleteTrigger(rootBotProjectId, rootDialog?.id, trigger) + ); + if (content) { + await dispatcher.updateDialog({ id: rootDialog?.id, content, projectId: rootBotProjectId }); + } + } + + await dispatcher.removeSkillFromBotProjectFile(projectIdToRemove); const botRuntimeOperations = await snapshot.getPromise(botRuntimeOperationsSelector); set(botProjectIdsState, (currentProjects) => { @@ -168,6 +188,8 @@ export const projectDispatcher = () => { const { projectId } = await openRemoteSkill(callbackHelpers, manifestUrl); set(botProjectIdsState, (current) => [...current, projectId]); await dispatcher.addRemoteSkillToBotProjectFile(projectId, manifestUrl, endpointName); + // update appsetting + await dispatcher.setSkillAndAllowCaller(rootBotProjectId, projectId, endpointName); navigateToSkillBot(rootBotProjectId, projectId); } catch (ex) { handleProjectFailure(callbackHelpers, ex); diff --git a/Composer/packages/client/src/recoilModel/dispatchers/setting.ts b/Composer/packages/client/src/recoilModel/dispatchers/setting.ts index cbd11fb0c3..9f012a30d4 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/setting.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/setting.ts @@ -3,7 +3,14 @@ /* eslint-disable react-hooks/rules-of-hooks */ import { CallbackInterface, useRecoilCallback } from 'recoil'; -import { SensitiveProperties, RootBotManagedProperties, DialogSetting, PublishTarget, LibraryRef } from '@bfc/shared'; +import { + SensitiveProperties, + RootBotManagedProperties, + DialogSetting, + PublishTarget, + LibraryRef, + isUsingAdaptiveRuntime, +} from '@bfc/shared'; import get from 'lodash/get'; import set from 'lodash/set'; import has from 'lodash/has'; @@ -12,10 +19,11 @@ import cloneDeep from 'lodash/cloneDeep'; import settingStorage from '../../utils/dialogSettingStorage'; import { settingsState } from '../atoms/botState'; import { rootBotProjectIdSelector, botProjectSpaceSelector } from '../selectors/project'; +import { skillsStateSelector } from '../selectors'; +import { botNameIdentifierState } from '../atoms'; import httpClient from './../../utils/httpUtil'; import { setError } from './shared'; - export const setRootBotSettingState = async ( callbackHelpers: CallbackInterface, projectId: string, @@ -166,6 +174,75 @@ export const settingsDispatcher = () => { setRuntimeField(projectId, 'customRuntime', isOn); }); + const setSkillAndAllowCaller = useRecoilCallback( + ({ set, snapshot }: CallbackInterface) => async (projectId: string, skillId: string, endpointName: string) => { + const rootBotProjectId = await snapshot.getPromise(rootBotProjectIdSelector); + if (!rootBotProjectId) { + return; + } + const manifestIdentifier = await snapshot.getPromise(botNameIdentifierState(skillId)); + const settings = await snapshot.getPromise(settingsState(rootBotProjectId)); + const skills = await snapshot.getPromise(skillsStateSelector); + const manifest = skills[manifestIdentifier]?.manifest; + let msAppId, endpointUrl; + + if (manifest?.endpoints) { + const matchedEndpoint = manifest.endpoints.find((item) => item.name === endpointName); + endpointUrl = matchedEndpoint?.endpointUrl || ''; + msAppId = matchedEndpoint?.msAppId || ''; + } + + const isAdaptiveRuntime = isUsingAdaptiveRuntime(settings.runtime); + set(settingsState(projectId), (currentValue) => { + if (isAdaptiveRuntime) { + const callers = settings.runtimeSettings?.skills?.allowedCallers + ? [...settings.runtimeSettings?.skills?.allowedCallers] + : []; + if (!callers?.find((item) => item === msAppId)) { + callers.push(msAppId); + } + return { + ...currentValue, + skill: { + ...settings.skill, + [manifestIdentifier]: { + endpointUrl, + msAppId, + }, + }, + runtimeSettings: { + ...settings.runtimeSettings, + skills: { + allowedCallers: callers, + }, + }, + }; + } else { + const callers = settings.skillConfiguration?.allowedCallers + ? [...settings.skillConfiguration?.allowedCallers] + : []; + if (!callers?.find((item) => item === msAppId)) { + callers.push(msAppId); + } + return { + ...currentValue, + skill: { + ...settings.skill, + [manifestIdentifier]: { + endpointUrl, + msAppId, + }, + }, + skillConfiguration: { + ...settings.skillConfiguration, + allowedCallers: callers, + }, + }; + } + }); + } + ); + const setQnASettings = useRecoilCallback( (callbackHelpers: CallbackInterface) => async (projectId: string, subscriptionKey: string) => { const { set } = callbackHelpers; @@ -196,5 +273,6 @@ export const settingsDispatcher = () => { setImportedLibraries, setCustomRuntime, setQnASettings, + setSkillAndAllowCaller, }; }; diff --git a/Composer/packages/client/src/recoilModel/dispatchers/trigger.ts b/Composer/packages/client/src/recoilModel/dispatchers/trigger.ts index 38a0a8897c..f0c17cffc0 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/trigger.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/trigger.ts @@ -6,7 +6,7 @@ import { BaseSchema, deleteActions, ITriggerCondition, LgTemplate, LgTemplateSam import get from 'lodash/get'; import { schemasState, dialogState, localeState } from '../atoms/botState'; -import { dialogsSelectorFamily, luFilesSelectorFamily } from '../selectors'; +import { dialogsSelectorFamily, luFilesSelectorFamily, skillNameIdentifierByProjectIdSelector } from '../selectors'; import { onChooseIntentKey, generateNewDialog, @@ -16,6 +16,7 @@ import { } from '../../utils/dialogUtil'; import { lgFilesSelectorFamily } from '../selectors/lg'; import { dispatcherState } from '../atoms'; +import { createActionFromManifest } from '../utils/skill'; import { setError } from './shared'; @@ -37,6 +38,79 @@ const getDesignerIdFromDialogPath = (dialog, path) => { return designerId; }; +const getNewDialogWithTrigger = async ( + callbackHelpers: CallbackInterface, + projectId: string, + dialogId: string, + formData: TriggerFormData, + createLuIntent, + createLgTemplates +) => { + const { snapshot } = callbackHelpers; + const lgFiles = await snapshot.getPromise(lgFilesSelectorFamily(projectId)); + const luFiles = await snapshot.getPromise(luFilesSelectorFamily(projectId)); + const dialogs = await snapshot.getPromise(dialogsSelectorFamily(projectId)); + const dialog = await snapshot.getPromise(dialogState({ projectId, dialogId })); + const schemas = await snapshot.getPromise(schemasState(projectId)); + const locale = await snapshot.getPromise(localeState(projectId)); + + const lgFile = lgFiles.find((file) => file.id === `${dialogId}.${locale}`); + const luFile = luFiles.find((file) => file.id === `${dialogId}.${locale}`); + + if (!luFile) throw new Error(`lu file ${dialogId} not found`); + if (!lgFile) throw new Error(`lg file ${dialogId} not found`); + if (!dialog) throw new Error(`dialog ${dialogId} not found`); + const newDialog = generateNewDialog(dialogs, dialog.id, formData, schemas.sdk?.content); + const index = get(newDialog, 'content.triggers', []).length - 1; + if (formData.$kind === intentTypeKey && formData.triggerPhrases) { + const intent = { Name: formData.intent, Body: formData.triggerPhrases }; + luFile && (await createLuIntent({ id: luFile.id, intent, projectId })); + } else if (formData.$kind === qnaMatcherKey) { + const designerId1 = getDesignerIdFromDialogPath( + newDialog, + `content.triggers[${index}].actions[0].actions[1].prompt` + ); + const designerId2 = getDesignerIdFromDialogPath( + newDialog, + `content.triggers[${index}].actions[0].elseActions[0].activity` + ); + const lgTemplates: LgTemplate[] = [ + LgTemplateSamples.TextInputPromptForQnAMatcher(designerId1) as LgTemplate, + LgTemplateSamples.SendActivityForQnAMatcher(designerId2) as LgTemplate, + ]; + await createLgTemplates({ id: lgFile.id, templates: lgTemplates, projectId }); + } else if (formData.$kind === onChooseIntentKey) { + const designerId1 = getDesignerIdFromDialogPath(newDialog, `content.triggers[${index}].actions[4].prompt`); + const designerId2 = getDesignerIdFromDialogPath( + newDialog, + `content.triggers[${index}].actions[5].elseActions[0].activity` + ); + const lgTemplates1: LgTemplate[] = [ + LgTemplateSamples.TextInputPromptForOnChooseIntent(designerId1) as LgTemplate, + LgTemplateSamples.SendActivityForOnChooseIntent(designerId2) as LgTemplate, + ]; + + let lgTemplates2: LgTemplate[] = [ + LgTemplateSamples.adaptiveCardJson as LgTemplate, + LgTemplateSamples.whichOneDidYouMean as LgTemplate, + LgTemplateSamples.pickOne as LgTemplate, + LgTemplateSamples.getAnswerReadBack as LgTemplate, + LgTemplateSamples.getIntentReadBack as LgTemplate, + ]; + const commonlgFile = lgFiles.find(({ id }) => id === `common.${locale}`); + + lgTemplates2 = lgTemplates2.filter((t) => commonlgFile?.templates.findIndex((clft) => clft.name === t.name) === -1); + + await createLgTemplates({ id: `common.${locale}`, templates: lgTemplates2, projectId }); + await createLgTemplates({ id: lgFile.id, templates: lgTemplates1, projectId }); + } + return { + id: newDialog.id, + projectId, + content: newDialog.content, + }; +}; + export const triggerDispatcher = () => { const createTrigger = useRecoilCallback( (callbackHelpers: CallbackInterface) => async ( @@ -48,75 +122,19 @@ export const triggerDispatcher = () => { try { const { snapshot } = callbackHelpers; const dispatcher = await snapshot.getPromise(dispatcherState); - const lgFiles = await snapshot.getPromise(lgFilesSelectorFamily(projectId)); - const luFiles = await snapshot.getPromise(luFilesSelectorFamily(projectId)); - const dialogs = await snapshot.getPromise(dialogsSelectorFamily(projectId)); - const dialog = await snapshot.getPromise(dialogState({ projectId, dialogId })); - const schemas = await snapshot.getPromise(schemasState(projectId)); - const locale = await snapshot.getPromise(localeState(projectId)); - const { createLuIntent, createLgTemplates, updateDialog, selectTo } = dispatcher; - - const lgFile = lgFiles.find((file) => file.id === `${dialogId}.${locale}`); - const luFile = luFiles.find((file) => file.id === `${dialogId}.${locale}`); - - if (!luFile) throw new Error(`lu file ${dialogId} not found`); - if (!lgFile) throw new Error(`lg file ${dialogId} not found`); - if (!dialog) throw new Error(`dialog ${dialogId} not found`); - const newDialog = generateNewDialog(dialogs, dialog.id, formData, schemas.sdk?.content); - const index = get(newDialog, 'content.triggers', []).length - 1; - if (formData.$kind === intentTypeKey && formData.triggerPhrases) { - const intent = { Name: formData.intent, Body: formData.triggerPhrases }; - luFile && (await createLuIntent({ id: luFile.id, intent, projectId })); - } else if (formData.$kind === qnaMatcherKey) { - const designerId1 = getDesignerIdFromDialogPath( - newDialog, - `content.triggers[${index}].actions[0].actions[1].prompt` - ); - const designerId2 = getDesignerIdFromDialogPath( - newDialog, - `content.triggers[${index}].actions[0].elseActions[0].activity` - ); - const lgTemplates: LgTemplate[] = [ - LgTemplateSamples.TextInputPromptForQnAMatcher(designerId1) as LgTemplate, - LgTemplateSamples.SendActivityForQnAMatcher(designerId2) as LgTemplate, - ]; - await createLgTemplates({ id: lgFile.id, templates: lgTemplates, projectId }); - } else if (formData.$kind === onChooseIntentKey) { - const designerId1 = getDesignerIdFromDialogPath(newDialog, `content.triggers[${index}].actions[4].prompt`); - const designerId2 = getDesignerIdFromDialogPath( - newDialog, - `content.triggers[${index}].actions[5].elseActions[0].activity` - ); - const lgTemplates1: LgTemplate[] = [ - LgTemplateSamples.TextInputPromptForOnChooseIntent(designerId1) as LgTemplate, - LgTemplateSamples.SendActivityForOnChooseIntent(designerId2) as LgTemplate, - ]; - - let lgTemplates2: LgTemplate[] = [ - LgTemplateSamples.adaptiveCardJson as LgTemplate, - LgTemplateSamples.whichOneDidYouMean as LgTemplate, - LgTemplateSamples.pickOne as LgTemplate, - LgTemplateSamples.getAnswerReadBack as LgTemplate, - LgTemplateSamples.getIntentReadBack as LgTemplate, - ]; - const commonlgFile = lgFiles.find(({ id }) => id === `common.${locale}`); - - lgTemplates2 = lgTemplates2.filter( - (t) => commonlgFile?.templates.findIndex((clft) => clft.name === t.name) === -1 - ); - - await createLgTemplates({ id: `common.${locale}`, templates: lgTemplates2, projectId }); - await createLgTemplates({ id: lgFile.id, templates: lgTemplates1, projectId }); - } - const dialogPayload = { - id: newDialog.id, + const dialogPayload = await getNewDialogWithTrigger( + callbackHelpers, projectId, - content: newDialog.content, - }; + dialogId, + formData, + createLuIntent, + createLgTemplates + ); await updateDialog(dialogPayload); if (autoSelected) { - await selectTo(projectId, newDialog.id, `triggers[${index}]`); + const index = get(dialogPayload.content, 'triggers', []).length - 1; + await selectTo(projectId, dialogPayload.id, `triggers[${index}]`); } } catch (ex) { setError(callbackHelpers, ex); @@ -178,9 +196,47 @@ export const triggerDispatcher = () => { } ); + const createTriggerForRemoteSkill = useRecoilCallback( + (callbackHelpers: CallbackInterface) => async ( + projectId: string, + dialogId: string, + formData: TriggerFormData, + skillId: string, + autoSelected = true + ) => { + try { + const { snapshot } = callbackHelpers; + const { createLuIntent, createLgTemplates, updateDialog, selectTo } = await snapshot.getPromise( + dispatcherState + ); + const dialogPayload = await getNewDialogWithTrigger( + callbackHelpers, + projectId, + dialogId, + formData, + createLuIntent, + createLgTemplates + ); + const index = get(dialogPayload.content, 'triggers', []).length - 1; + const skillsByProjectId = await snapshot.getPromise(skillNameIdentifierByProjectIdSelector); + const skillIdentifier = skillsByProjectId[skillId]; + const actions: any = []; + actions.push(createActionFromManifest(skillIdentifier)); + dialogPayload.content.triggers[index].actions = actions; + + await updateDialog(dialogPayload); + if (autoSelected) { + await selectTo(projectId, dialogPayload.id, `triggers[${index}]`); + } + } catch (ex) { + setError(callbackHelpers, ex); + } + } + ); return { createTrigger, deleteTrigger, createQnATrigger, + createTriggerForRemoteSkill, }; }; diff --git a/Composer/packages/client/src/recoilModel/utils/skill.ts b/Composer/packages/client/src/recoilModel/utils/skill.ts new file mode 100644 index 0000000000..42d951a09d --- /dev/null +++ b/Composer/packages/client/src/recoilModel/utils/skill.ts @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { generateDesignerId } from '@bfc/shared'; + +export const createActionFromManifest = (manifestIdentifier) => { + // get identifier + return { + $kind: 'Microsoft.BeginSkill', + $designer: { + id: `${generateDesignerId()}`, + }, + activityProcessed: true, + botId: '=settings.MicrosoftAppId', + skillHostEndpoint: '=settings.skillHostEndpoint', + connectionName: '=settings.connectionName', + allowInterruptions: true, + skillEndpoint: `=settings.skill['${manifestIdentifier}'].endpointUrl`, + skillAppId: `=settings.skill['${manifestIdentifier}'].msAppId`, + }; +}; diff --git a/Composer/packages/server/src/controllers/utilities.ts b/Composer/packages/server/src/controllers/utilities.ts index 61ae91c18b..066da2c5c2 100644 --- a/Composer/packages/server/src/controllers/utilities.ts +++ b/Composer/packages/server/src/controllers/utilities.ts @@ -6,6 +6,7 @@ import { promisify } from 'util'; import { Request, Response } from 'express'; import { parseQnAContent } from '../models/utilities/parser'; +import { getRemoteFile as getFile } from '../models/utilities/util'; const execAsync = promisify(exec); async function getQnaContent(req: Request, res: Response) { @@ -20,6 +21,25 @@ async function getQnaContent(req: Request, res: Response) { } } +async function getRemoteFile(req: Request, res: Response) { + try { + const url: string = req.query.url; + const content = await getFile(url); + const start = decodeURI(url).lastIndexOf('/'); + const end = decodeURI(url).lastIndexOf('.'); + const id = url.substring(start + 1, end); + + res.status(200).json({ + content, + id, + }); + } catch (err) { + res.status(404).json({ + message: err.message, + }); + } +} + async function checkNodeVersion(req: Request, res: Response) { try { const command = 'node -v'; @@ -41,5 +61,6 @@ async function checkNodeVersion(req: Request, res: Response) { export const UtilitiesController = { getQnaContent, + getRemoteFile, checkNodeVersion, }; diff --git a/Composer/packages/server/src/locales/en-US.json b/Composer/packages/server/src/locales/en-US.json index b4d1912655..b84aaf885c 100644 --- a/Composer/packages/server/src/locales/en-US.json +++ b/Composer/packages/server/src/locales/en-US.json @@ -740,6 +740,9 @@ "confirmation_modal_must_have_a_title_b0816e0b": { "message": "Confirmation modal must have a title." }, + "conflicting_changes_detected_6c282985": { + "message": "Conflicting changes detected" + }, "congratulations_your_model_is_successfully_publish_52ebc297": { "message": "Congratulations! Your model is successfully published." }, @@ -1265,6 +1268,9 @@ "edit_in_json_75d0d754": { "message": "Edit in JSON" }, + "edit_in_power_virtual_agents_56ee7ac2": { + "message": "Edit in Power Virtual Agents" + }, "edit_kb_name_5e2d8c5b": { "message": "Edit KB name" }, @@ -1325,6 +1331,12 @@ "enable_multi_turn_extraction_8a168892": { "message": "Enable multi-turn extraction" }, + "enable_orchestrator_as_the_recognizer_at_the_root__1de64c98": { + "message": "Enable orchestrator as the recognizer at the root dialog to add this skill" + }, + "enable_orchestrator_cdbbd2c5": { + "message": "Enable Orchestrator" + }, "enable_speech_e30d6a2a": { "message": "Enable Speech" }, @@ -1709,6 +1721,9 @@ "get_activity_members_11339605": { "message": "Get activity members" }, + "get_an_overview_56c78cc3": { + "message": "Get an overview" + }, "get_conversation_members_71602275": { "message": "Get conversation members" }, @@ -1718,6 +1733,9 @@ "get_qna_key_583b2548": { "message": "Get QnA key" }, + "get_remote_file_fail_37ef94c5": { + "message": "get remote file fail" + }, "get_started_76ed4cb9": { "message": "Get started" }, @@ -1907,6 +1925,9 @@ "insert_template_reference_bb33720e": { "message": "Insert template reference" }, + "install_error_a9319839": { + "message": "Install Error" + }, "install_microsoft_net_core_sdk_2de509f0": { "message": "Install Microsoft .NET Core SDK" }, @@ -1952,6 +1973,9 @@ "intentname_is_missing_or_empty_e49db2f8": { "message": "intentName is missing or empty" }, + "intents_9b8593e0": { + "message": "Intents" + }, "interpolated_string_c96053f2": { "message": "Interpolated string." }, @@ -2033,6 +2057,9 @@ "learn_about_adaptive_expressions_fb1b6c3c": { "message": "Learn about Adaptive expressions" }, + "learn_how_to_build_a_skill_b1c39c82": { + "message": "learn how to build a skill" + }, "learn_how_to_publish_to_a_dev_ops_pipeline_using_c_9b4577be": { "message": "Learn how to publish to a Dev Ops pipeline using CI / CD." }, @@ -2051,6 +2078,9 @@ "learn_more_about_manifests_6e7c364b": { "message": "Learn more about manifests" }, + "learn_more_about_orchestractor_3bfa61e1": { + "message": "Learn more about Orchestractor" + }, "learn_more_about_skill_manifests_7708ce2c": { "message": "Learn more about skill manifests" }, @@ -2234,6 +2264,9 @@ "make_a_copy_77d1233": { "message": "Make a copy" }, + "make_orchestrator_my_preferred_recognizer_for_mult_2295034b": { + "message": "Make Orchestrator my preferred recognizer for multi-bot projects" + }, "manage_bot_languages_9ec36fd7": { "message": "Manage bot languages" }, @@ -2336,6 +2369,9 @@ "minimum_f31b05ab": { "message": "Minimum" }, + "miss_dispatch_modal_cf2d278e": { + "message": "Miss dispatch modal" + }, "missing_definition_for_defname_33f2b594": { "message": "Missing definition for { defName }" }, @@ -2372,6 +2408,9 @@ "msg_bf173fef": { "message": "{ msg }" }, + "multi_bot_projects_work_best_with_the_orchestrator_8b80e480": { + "message": "Multi-bot projects work best with the Orchestrator recognizer set at the root dialog. Orchestrator helps identify and dispatch user intents from the root dialog to the respective skill that can handle the intent. Orchestrator does not support entity extraction at the root dialog level." + }, "multi_choice_839b54bb": { "message": "Multi-choice" }, @@ -2597,9 +2636,6 @@ "open_e0beb7b9": { "message": "Open" }, - "open_in_power_virtual_agents_fcd881e6": { - "message": "Open in Power Virtual Agents" - }, "open_inline_editor_a5aabcfa": { "message": "Open inline editor" }, @@ -2699,9 +2735,6 @@ "please_select_a_trigger_type_67417abb": { "message": "Please select a trigger type" }, - "please_select_a_valid_endpoint_bf608af1": { - "message": "Please select a valid endpoint" - }, "please_select_a_version_of_the_manifest_schema_4a3efbb1": { "message": "Please select a version of the manifest schema" }, @@ -2861,6 +2894,9 @@ "published_4bb5209e": { "message": "Published" }, + "publisher_bf6195cf": { + "message": "Publisher" + }, "publishing_count_plural_1_one_bot_other_bots_11edc1e9": { "message": "Publishing { count, plural,\n =1 {one bot}\n other {# bots}\n}" }, @@ -3371,8 +3407,8 @@ "skill_host_endpoint_url_e68b65f6": { "message": "Skill host endpoint url" }, - "skill_manifest_endpoint_is_configured_improperly_e083731d": { - "message": "Skill manifest endpoint is configured improperly" + "skill_manifest_url_be7ef8d0": { + "message": "Skill Manifest Url" }, "skill_manifest_url_was_copied_to_the_clipboard_4cfad630": { "message": "Skill manifest URL was copied to the clipboard" @@ -3383,6 +3419,12 @@ "skills_can_be_called_by_external_bots_allow_other__aa203913": { "message": "Skills can be “called” by external bots. Allow other bots to call your skill by adding their App IDs to the list below." }, + "skills_extend_your_bot_s_conversational_capabiliti_ce5c2384": { + "message": "Skills extend your bot''s conversational capabilities . To know more about skills" + }, + "skip_bcb86160": { + "message": "Skip" + }, "something_happened_while_attempting_to_pull_e_952c7afe": { "message": "Something happened while attempting to pull: { e }" }, @@ -3710,6 +3752,9 @@ "this_operation_cannot_be_completed_the_skill_is_al_4886d311": { "message": "This operation cannot be completed. The skill is already part of the Bot Project" }, + "this_operation_will_overwrite_changes_made_to_prev_e746d44f": { + "message": "This operation will overwrite changes made to previously imported files. Do you want to proceed?" + }, "this_option_allows_your_users_to_give_multiple_val_d2dd0d58": { "message": "This option allows your users to give multiple values for this property." }, @@ -3770,6 +3815,9 @@ "to_learn_more_about_the_title_a_visit_its_document_c302e9b1": { "message": "To learn more about the { title }, visit its documentation page." }, + "to_make_sure_the_skill_will_work_correctly_we_perf_8de42615": { + "message": "To make sure the skill will work correctly, we perform some validation checks. When you’re ready to add a skill, enter the Skill manifest URL provided to you by the skill author." + }, "to_make_your_bot_available_for_others_as_a_skill_w_f2c19b9c": { "message": "To make your bot available for others as a skill, we need to generate a manifest." }, @@ -3962,9 +4010,6 @@ "user_topic_e3978941": { "message": "User Topic" }, - "validating_35b79a96": { - "message": "Validating..." - }, "validation_b10c677c": { "message": "Validation" }, diff --git a/Composer/packages/server/src/models/utilities/util.ts b/Composer/packages/server/src/models/utilities/util.ts index 17109e4d94..b099cda2da 100644 --- a/Composer/packages/server/src/models/utilities/util.ts +++ b/Composer/packages/server/src/models/utilities/util.ts @@ -1,5 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import axios from 'axios'; export const getDialogNameFromFile = (file: string) => { const tokens = file.split('.'); @@ -18,3 +19,8 @@ export const getDialogNameFromFile = (file: string) => { } return dialogName; }; + +export const getRemoteFile = async (url): Promise => { + const response = await axios.get(url); + return response.data; +}; diff --git a/Composer/packages/server/src/router/api.ts b/Composer/packages/server/src/router/api.ts index 9dbbccb4cf..0ace4beded 100644 --- a/Composer/packages/server/src/router/api.ts +++ b/Composer/packages/server/src/router/api.ts @@ -103,6 +103,7 @@ router.use('/assets/locales/', express.static(path.join(__dirname, '..', '..', ' //help api router.get('/utilities/qna/parse', UtilitiesController.getQnaContent); +router.get('/utilities/retrieveRemoteFile', UtilitiesController.getRemoteFile); router.get('/utilities/checkNode', UtilitiesController.checkNodeVersion); // extensions diff --git a/Composer/yarn.lock b/Composer/yarn.lock index c48aacafa6..c4be6bc57b 100644 --- a/Composer/yarn.lock +++ b/Composer/yarn.lock @@ -24312,4 +24312,4 @@ zip-stream@^4.0.0: dependencies: archiver-utils "^2.1.0" compress-commons "^4.0.0" - readable-stream "^3.6.0" + readable-stream "^3.6.0" \ No newline at end of file diff --git a/extensions/packageManager/src/node/index.ts b/extensions/packageManager/src/node/index.ts index c0250bb108..8ce2ad215a 100644 --- a/extensions/packageManager/src/node/index.ts +++ b/extensions/packageManager/src/node/index.ts @@ -311,6 +311,7 @@ export default async (composer: IExtensionRegistration): Promise => { const version = req.body.version; const source = req.body.source; const isUpdating = req.body.isUpdating || false; + const isPreview = req.body.isPreview || false; const mergeErrors: string[] = []; const captureErrors = (msg: string): void => { @@ -328,7 +329,8 @@ export default async (composer: IExtensionRegistration): Promise => { packageName, version, source, - currentProject + currentProject, + isPreview ); const manifestFile = runtime.identifyManifest(runtimePath, currentProject.name); diff --git a/extensions/runtimes/src/index.ts b/extensions/runtimes/src/index.ts index f0e62cde31..b63b878801 100644 --- a/extensions/runtimes/src/index.ts +++ b/extensions/runtimes/src/index.ts @@ -49,12 +49,13 @@ export default async (composer: any): Promise => { packageName: string, version: string, source: string, - _project: any + _project: any, + isPreview = false ): Promise => { // run dotnet install on the project const command = `dotnet add package "${packageName}"${version ? ' --version="' + version + '"' : ''}${ source ? ' --source="' + source + '"' : '' - }`; + }${isPreview ? ' --prerelease' : ''}`; composer.log('EXEC:', command); const { stderr: installError, stdout: installOutput } = await execAsync(command, { cwd: path.join(runtimePath, 'azurewebapp'), @@ -348,12 +349,13 @@ export default async (composer: any): Promise => { packageName: string, version: string, source: string, - _project: any + _project: any, + isPreview = false ): Promise => { // run dotnet install on the project const command = `dotnet add ${_project.name}.csproj package "${packageName}"${ version ? ' --version="' + version + '"' : '' - }${source ? ' --source="' + source + '"' : ''}`; + }${source ? ' --source="' + source + '"' : ''}${isPreview ? ' --prerelease' : ''}`; composer.log('EXEC:', command); const { stderr: installError, stdout: installOutput } = await execAsync(command, { cwd: path.join(runtimePath), @@ -478,12 +480,13 @@ export default async (composer: any): Promise => { packageName: string, version: string, source: string, - _project: any + _project: any, + isPreview = false ): Promise => { // run dotnet install on the project const command = `dotnet add ${_project.name}.csproj package "${packageName}"${ version ? ' --version="' + version + '"' : '' - }${source ? ' --source="' + source + '"' : ''}`; + }${source ? ' --source="' + source + '"' : ''}${isPreview ? ' --prerelease' : ''}`; composer.log('EXEC:', command); const { stderr: installError, stdout: installOutput } = await execAsync(command, { cwd: path.join(runtimePath), @@ -588,7 +591,8 @@ export default async (composer: any): Promise => { packageName: string, version: string, source: string, - _project: any + _project: any, + isPreview = false ): Promise => { // run dotnet install on the project const { stderr: installError, stdout: installOutput } = await execAsync( @@ -655,7 +659,8 @@ export default async (composer: any): Promise => { packageName: string, version: string, source: string, - _project: any + _project: any, + isPreview = false ): Promise => { // run dotnet install on the project const { stderr: installError, stdout: installOutput } = await execAsync(