From a5323a2e05a0c28662710d8a3d4cd47b3be22c4a Mon Sep 17 00:00:00 2001 From: Martin Sosic Date: Thu, 29 Jun 2023 21:25:23 +0200 Subject: [PATCH] Added JSON config via CLI for Wasp AI. --- wasp-ai/src/server/operations.ts | 7 ++- waspc/cli/exe/Main.hs | 14 +++--- waspc/cli/src/Wasp/Cli/Command/Call.hs | 4 +- .../Wasp/Cli/Command/CreateNewProject/AI.hs | 49 ++++++++++++------- .../src/Wasp/AI/GenerateNewProject/Common.hs | 46 +++++++++++++++-- .../Wasp/AI/GenerateNewProject/Skeleton.hs | 6 +-- waspc/src/Wasp/Util/Aeson.hs | 7 ++- 7 files changed, 96 insertions(+), 37 deletions(-) diff --git a/wasp-ai/src/server/operations.ts b/wasp-ai/src/server/operations.ts index c8fd295aa..bed60b802 100644 --- a/wasp-ai/src/server/operations.ts +++ b/wasp-ai/src/server/operations.ts @@ -38,14 +38,17 @@ export const startGeneratingNewApp: StartGeneratingNewApp< unconsumedStdout: "", }; + // { auth: 'UsernameAndPassword', primaryColor: string } + const projectConfig = {} + const stdoutMutex = new Mutex(); let waspCliProcess = null; if (process.env.NODE_ENV === "production") { - waspCliProcess = spawn("wasp", ["new-ai", args.appName, args.appDesc]); + waspCliProcess = spawn("wasp", ["new-ai", args.appName, args.appDesc, JSON.stringify(projectConfig)]); } else { // NOTE: In dev when we use `wasp-cli`, we want to make sure that if this app is run via `wasp` that its datadir env var does not propagate, // so we reset it here. This is problem only if you run app with `wasp` and let it call `wasp-cli` here. - waspCliProcess = spawn("wasp-cli", ["new-ai", args.appName, args.appDesc], { + waspCliProcess = spawn("wasp-cli", ["new-ai", args.appName, args.appDesc, JSON.stringify(projectConfig)], { env: { ...process.env, waspc_datadir: undefined }, }); } diff --git a/waspc/cli/exe/Main.hs b/waspc/cli/exe/Main.hs index a96cf855e..bf142a4a3 100644 --- a/waspc/cli/exe/Main.hs +++ b/waspc/cli/exe/Main.hs @@ -47,11 +47,11 @@ main = withUtf8 . (`E.catch` handleInternalErrors) $ do ("new" : newArgs) -> Command.Call.New newArgs -- new-ai / new-ai:stdout is meant to be called and consumed programatically (e.g. by our Wasp AI -- web app), while new-ai:disk is useful for us for testing. - [newAiCmd, projectName, appDescription] + [newAiCmd, projectName, appDescription, projectConfigJson] | newAiCmd `elem` ["new-ai", "new-ai:stdout"] -> - Command.Call.NewAiToStdout projectName appDescription + Command.Call.NewAiToStdout projectName appDescription projectConfigJson | newAiCmd == "new-ai:disk" -> - Command.Call.NewAiToDisk projectName appDescription + Command.Call.NewAiToDisk projectName appDescription projectConfigJson ["start"] -> Command.Call.Start ["start", "db"] -> Command.Call.StartDb ["clean"] -> Command.Call.Clean @@ -86,10 +86,10 @@ main = withUtf8 . (`E.catch` handleInternalErrors) $ do case commandCall of Command.Call.New newArgs -> runCommand $ createNewProject newArgs - Command.Call.NewAiToStdout projectName appDescription -> - runCommand $ Command.CreateNewProject.AI.createNewProjectNonInteractiveToStdout projectName appDescription - Command.Call.NewAiToDisk projectName appDescription -> - runCommand $ Command.CreateNewProject.AI.createNewProjectNonInteractiveOnDisk projectName appDescription + Command.Call.NewAiToStdout projectName appDescription projectConfigJson -> + runCommand $ Command.CreateNewProject.AI.createNewProjectNonInteractiveToStdout projectName appDescription projectConfigJson + Command.Call.NewAiToDisk projectName appDescription projectConfigJson -> + runCommand $ Command.CreateNewProject.AI.createNewProjectNonInteractiveOnDisk projectName appDescription projectConfigJson Command.Call.Start -> runCommand start Command.Call.StartDb -> runCommand Command.Start.Db.start Command.Call.Clean -> runCommand clean diff --git a/waspc/cli/src/Wasp/Cli/Command/Call.hs b/waspc/cli/src/Wasp/Cli/Command/Call.hs index bc6730058..020b39dcd 100644 --- a/waspc/cli/src/Wasp/Cli/Command/Call.hs +++ b/waspc/cli/src/Wasp/Cli/Command/Call.hs @@ -2,8 +2,8 @@ module Wasp.Cli.Command.Call where data Call = New Arguments - | NewAiToStdout String String -- projectName, appDescription - | NewAiToDisk String String -- projectName, appDescription + | NewAiToStdout String String String -- projectName, appDescription, projectConfigJson + | NewAiToDisk String String String -- projectName, appDescription, projectConfigJson | Start | StartDb | Clean diff --git a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/AI.hs b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/AI.hs index c86f3d63f..91a8b5930 100644 --- a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/AI.hs +++ b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/AI.hs @@ -7,6 +7,7 @@ where import Control.Arrow () import Control.Monad.Except (MonadError (throwError), MonadIO (liftIO)) +import Data.Function ((&)) import qualified Data.Text as T import qualified Data.Text.IO as T.IO import StrongPath (Abs, Dir, Path', fromAbsDir) @@ -17,34 +18,44 @@ import System.FilePath (takeDirectory) import System.IO (hFlush, stdout) import qualified Wasp.AI.CodeAgent as CA import qualified Wasp.AI.GenerateNewProject as GNP -import Wasp.AI.GenerateNewProject.Common (AuthProvider (..), NewProjectDetails (..)) +import Wasp.AI.GenerateNewProject.Common (NewProjectConfig, NewProjectDetails (..), emptyNewProjectConfig) import Wasp.AI.OpenAI (OpenAIApiKey) import Wasp.Cli.Command (Command, CommandError (CommandError)) import Wasp.Cli.Command.CreateNewProject.ProjectDescription (NewProjectAppName (..), obtainAvailableProjectDirPath, parseWaspProjectNameIntoAppName) import Wasp.Cli.Command.CreateNewProject.StarterTemplates (readWaspProjectSkeletonFiles) import Wasp.Cli.Common (WaspProjectDir) import qualified Wasp.Cli.Interactive as Interactive +import qualified Wasp.Util.Aeson as Utils.Aeson createNewProjectInteractiveOnDisk :: Path' Abs (Dir WaspProjectDir) -> NewProjectAppName -> Command () createNewProjectInteractiveOnDisk waspProjectDir appName = do openAIApiKey <- getOpenAIApiKey appDescription <- liftIO $ Interactive.askForRequiredInput "Describe your app in a couple of sentences" - liftIO $ createNewProjectOnDisk openAIApiKey waspProjectDir appName appDescription + liftIO $ createNewProjectOnDisk openAIApiKey waspProjectDir appName appDescription emptyNewProjectConfig -createNewProjectNonInteractiveOnDisk :: String -> String -> Command () -createNewProjectNonInteractiveOnDisk projectName appDescription = do +createNewProjectNonInteractiveOnDisk :: String -> String -> String -> Command () +createNewProjectNonInteractiveOnDisk projectName appDescription projectConfigJson = do appName <- case parseWaspProjectNameIntoAppName projectName of Right appName -> pure appName Left err -> throwError $ CommandError "Invalid project name" err + projectConfig <- + Utils.Aeson.decodeFromString projectConfigJson + & either (throwError . CommandError "Invalid project config" . ("Failed to parse JSON: " <>)) pure waspProjectDir <- obtainAvailableProjectDirPath projectName openAIApiKey <- getOpenAIApiKey - liftIO $ createNewProjectOnDisk openAIApiKey waspProjectDir appName appDescription + liftIO $ createNewProjectOnDisk openAIApiKey waspProjectDir appName appDescription projectConfig -createNewProjectOnDisk :: OpenAIApiKey -> Path' Abs (Dir WaspProjectDir) -> NewProjectAppName -> String -> IO () -createNewProjectOnDisk openAIApiKey waspProjectDir appName appDescription = do +createNewProjectOnDisk :: + OpenAIApiKey -> + Path' Abs (Dir WaspProjectDir) -> + NewProjectAppName -> + String -> + NewProjectConfig -> + IO () +createNewProjectOnDisk openAIApiKey waspProjectDir appName appDescription projectConfig = do createDirectory $ fromAbsDir waspProjectDir setCurrentDirectory $ fromAbsDir waspProjectDir - generateNewProject codeAgentConfig appName appDescription + generateNewProject codeAgentConfig appName appDescription projectConfig where codeAgentConfig = CA.CodeAgentConfig @@ -65,14 +76,18 @@ createNewProjectOnDisk openAIApiKey waspProjectDir appName appDescription = do -- | Instead of writing files to disk, it will write files (and logs) to the stdout, -- with delimiters that make it easy to programmaticaly parse the output. -createNewProjectNonInteractiveToStdout :: String -> String -> Command () -createNewProjectNonInteractiveToStdout projectName appDescription = do +createNewProjectNonInteractiveToStdout :: String -> String -> String -> Command () +createNewProjectNonInteractiveToStdout projectName appDescription projectConfigJsonStr = do openAIApiKey <- getOpenAIApiKey appName <- case parseWaspProjectNameIntoAppName projectName of Right appName -> pure appName Left err -> throwError $ CommandError "Invalid project name" err + projectConfig <- + Utils.Aeson.decodeFromString projectConfigJsonStr + & either (throwError . CommandError "Invalid project config" . ("Failed to parse JSON: " <>)) pure + let codeAgentConfig = CA.CodeAgentConfig { CA._openAIApiKey = openAIApiKey, @@ -80,7 +95,7 @@ createNewProjectNonInteractiveToStdout projectName appDescription = do CA._writeLog = writeLogToStdoutWithDelimiters } - liftIO $ generateNewProject codeAgentConfig appName appDescription + liftIO $ generateNewProject codeAgentConfig appName appDescription projectConfig where writeFileToStdoutWithDelimiters path content = writeToStdoutWithDelimiters "WRITE FILE" [T.pack path, content] @@ -100,11 +115,11 @@ createNewProjectNonInteractiveToStdout projectName appDescription = do "===/ WASP AI: " <> title <> " ====" ] -generateNewProject :: CA.CodeAgentConfig -> NewProjectAppName -> String -> IO () -generateNewProject codeAgentConfig (NewProjectAppName appName) appDescription = do +generateNewProject :: CA.CodeAgentConfig -> NewProjectAppName -> String -> NewProjectConfig -> IO () +generateNewProject codeAgentConfig (NewProjectAppName appName) appDescription projectConfig = do waspProjectSkeletonFiles <- readWaspProjectSkeletonFiles CA.runCodeAgent codeAgentConfig $ do - GNP.generateNewProject (newProjectDetails appName appDescription) waspProjectSkeletonFiles + GNP.generateNewProject (newProjectDetails projectConfig appName appDescription) waspProjectSkeletonFiles getOpenAIApiKey :: Command OpenAIApiKey getOpenAIApiKey = @@ -123,10 +138,10 @@ getOpenAIApiKey = "to .bash_profile or .profile, restart your shell, and you should be good to go." ] -newProjectDetails :: String -> String -> NewProjectDetails -newProjectDetails webAppName webAppDescription = +newProjectDetails :: NewProjectConfig -> String -> String -> NewProjectDetails +newProjectDetails projectConfig webAppName webAppDescription = NewProjectDetails { _projectAppName = webAppName, _projectDescription = webAppDescription, - _projectAuth = UsernameAndPassword + _projectConfig = projectConfig } diff --git a/waspc/src/Wasp/AI/GenerateNewProject/Common.hs b/waspc/src/Wasp/AI/GenerateNewProject/Common.hs index 8e15438db..cc5ceb0bd 100644 --- a/waspc/src/Wasp/AI/GenerateNewProject/Common.hs +++ b/waspc/src/Wasp/AI/GenerateNewProject/Common.hs @@ -1,14 +1,18 @@ module Wasp.AI.GenerateNewProject.Common ( NewProjectDetails (..), - File, + NewProjectConfig (..), AuthProvider (..), + File, + getProjectAuth, + getProjectPrimaryColor, + emptyNewProjectConfig, queryChatGPTForJSON, defaultChatGPTParams, writeToWaspFileEnd, ) where -import Data.Aeson (FromJSON) +import Data.Aeson (FromJSON, withObject, withText, (.:?)) import qualified Data.Aeson as Aeson import Data.Maybe (fromMaybe) import Data.Text (Text) @@ -21,14 +25,46 @@ import Wasp.Util (naiveTrimJSON, textToLazyBS) data NewProjectDetails = NewProjectDetails { _projectAppName :: !String, _projectDescription :: !String, - _projectAuth :: !AuthProvider + _projectConfig :: NewProjectConfig } --- TODO: Make these relative to WaspProjectDir, via StrongPath? -type File = (FilePath, Text) +data NewProjectConfig = NewProjectConfig + { projectAuth :: !(Maybe AuthProvider), + -- CSS acceptable string for color. + projectPrimaryColor :: !(Maybe String) + } + deriving (Show) + +instance Aeson.FromJSON NewProjectConfig where + parseJSON = withObject "NewProjectConfig" $ \obj -> do + auth <- obj .:? "auth" + primaryColor <- obj .:? "primaryColor" + return (NewProjectConfig {projectAuth = auth, projectPrimaryColor = primaryColor}) + +emptyNewProjectConfig :: NewProjectConfig +emptyNewProjectConfig = + NewProjectConfig + { projectAuth = Nothing, + projectPrimaryColor = Nothing + } + +getProjectAuth :: NewProjectDetails -> AuthProvider +getProjectAuth = fromMaybe UsernameAndPassword . projectAuth . _projectConfig + +getProjectPrimaryColor :: NewProjectDetails -> String +getProjectPrimaryColor = fromMaybe "#fc0" . projectPrimaryColor . _projectConfig -- TODO: Support more methods. data AuthProvider = UsernameAndPassword + deriving (Show) + +instance Aeson.FromJSON AuthProvider where + parseJSON = withText "AuthProvider" $ \case + "UsernameAndPassword" -> return UsernameAndPassword + _ -> fail "invalid auth provider" + +-- TODO: Make these relative to WaspProjectDir, via StrongPath? +type File = (FilePath, Text) queryChatGPTForJSON :: FromJSON a => ChatGPTParams -> [ChatMessage] -> CodeAgent a queryChatGPTForJSON chatGPTParams = doQueryForJSON 0 diff --git a/waspc/src/Wasp/AI/GenerateNewProject/Skeleton.hs b/waspc/src/Wasp/AI/GenerateNewProject/Skeleton.hs index 37ebdf316..50f01c454 100644 --- a/waspc/src/Wasp/AI/GenerateNewProject/Skeleton.hs +++ b/waspc/src/Wasp/AI/GenerateNewProject/Skeleton.hs @@ -11,7 +11,7 @@ import StrongPath (File', Path, Rel) import qualified StrongPath as SP import StrongPath.Types (System) import Wasp.AI.CodeAgent (CodeAgent, writeNewFile) -import Wasp.AI.GenerateNewProject.Common (AuthProvider (..), File, NewProjectDetails (..)) +import Wasp.AI.GenerateNewProject.Common (AuthProvider (..), File, NewProjectDetails (..), getProjectAuth) import Wasp.AI.GenerateNewProject.Plan (PlanRule) import Wasp.Project (WaspProjectDir) import qualified Wasp.SemanticVersion as SV @@ -28,7 +28,7 @@ generateAndWriteProjectSkeletonAndPresetFiles newProjectDetails waspProjectSkele let (waspFile@(waspFilePath, _), planRules) = generateBaseWaspFile newProjectDetails writeNewFile waspFile - case _projectAuth newProjectDetails of + case getProjectAuth newProjectDetails of UsernameAndPassword -> do writeNewFile generateLoginJsPage writeNewFile generateSignupJsPage @@ -50,7 +50,7 @@ generateBaseWaspFile newProjectDetails = ((path, content), planRules) appName = T.pack $ _projectAppName newProjectDetails appTitle = appName waspVersionRange = T.pack . show $ SV.backwardsCompatibleWith Wasp.Version.waspVersion - (appAuth, authPlanRules) = case _projectAuth newProjectDetails of + (appAuth, authPlanRules) = case getProjectAuth newProjectDetails of UsernameAndPassword -> ( [trimming| auth: { diff --git a/waspc/src/Wasp/Util/Aeson.hs b/waspc/src/Wasp/Util/Aeson.hs index 20c86a141..e24346780 100644 --- a/waspc/src/Wasp/Util/Aeson.hs +++ b/waspc/src/Wasp/Util/Aeson.hs @@ -1,13 +1,18 @@ module Wasp.Util.Aeson ( encodeToText, + decodeFromString, ) where -import Data.Aeson (ToJSON) +import Data.Aeson (FromJSON, ToJSON, eitherDecode) import Data.Aeson.Text (encodeToTextBuilder) +import qualified Data.ByteString.Lazy.UTF8 as BS import Data.Text (Text) import Data.Text.Lazy (toStrict) import Data.Text.Lazy.Builder (toLazyText) encodeToText :: ToJSON a => a -> Text encodeToText = toStrict . toLazyText . encodeToTextBuilder + +decodeFromString :: FromJSON a => String -> Either String a +decodeFromString = eitherDecode . BS.fromString