Command.AI.New is now in order.

This commit is contained in:
Martin Sosic 2023-06-13 21:57:49 +02:00
parent 1a8db2abf8
commit a89f52b026
5 changed files with 196 additions and 238 deletions

View File

@ -15,8 +15,8 @@ where
import Control.Monad.IO.Class (MonadIO (liftIO))
import Control.Monad.Reader (MonadReader, ReaderT (runReaderT), asks)
import Control.Monad.State (MonadState, StateT (runStateT), gets)
import Data.List (find)
import Control.Monad.State (MonadState, StateT (runStateT), gets, modify)
import qualified Data.HashMap.Strict as H
import Data.Text (Text)
import Wasp.OpenAI (OpenAIApiKey)
import Wasp.OpenAI.ChatGPT (ChatGPTParams, ChatMessage)
@ -31,11 +31,11 @@ data CodeAgentConfig = CodeAgentConfig
_writeLog :: !(Text -> IO ())
}
runCodeAgent :: CodeAgent a -> CodeAgentConfig -> IO a
runCodeAgent codeAgent config =
runCodeAgent :: CodeAgentConfig -> CodeAgent a -> IO a
runCodeAgent config codeAgent =
fst <$> (_unCodeAgent codeAgent `runReaderT` config) `runStateT` initialState
where
initialState = CodeAgentState {_files = []}
initialState = CodeAgentState {_files = H.empty}
writeToLog :: Text -> CodeAgent ()
writeToLog msg = asks _writeLog >>= \f -> liftIO $ f msg
@ -44,16 +44,17 @@ writeToFile :: FilePath -> (Maybe Text -> Text) -> CodeAgent ()
writeToFile path updateContentFn = do
content <- updateContentFn <$> getFile path
asks _writeFile >>= \f -> liftIO $ f path content
modify $ \s -> s {_files = H.insert path content (_files s)}
writeNewFile :: (FilePath, Text) -> CodeAgent ()
writeNewFile (path, content) =
writeToFile path (maybe content $ error $ "file " <> path <> " shouldn't already exist")
getFile :: FilePath -> CodeAgent (Maybe Text)
getFile path = (snd <$>) . find ((== path) . fst) <$> getAllFiles
getFile path = gets $ H.lookup path . _files
getAllFiles :: CodeAgent [(FilePath, Text)]
getAllFiles = gets _files
getAllFiles = gets $ H.toList . _files
queryChatGPT :: ChatGPTParams -> [ChatMessage] -> CodeAgent Text
queryChatGPT params messages = do
@ -61,5 +62,5 @@ queryChatGPT params messages = do
liftIO $ ChatGPT.queryChatGPT key params messages
data CodeAgentState = CodeAgentState
{ _files :: ![(FilePath, Text)]
{ _files :: H.HashMap FilePath Text
}

View File

@ -7,22 +7,29 @@ where
-- TODO: Probably move this module out of here into general wasp lib.
import Control.Arrow (first)
import Control.Monad (forM)
import Control.Monad.IO.Class (liftIO)
import Data.Text (Text)
import qualified Data.Text as T
import NeatInterpolation (trimming)
import qualified StrongPath as SP
import Wasp.Cli.Command.AI.CodeAgent (CodeAgent, writeNewFile, writeToLog)
import Wasp.Cli.Command.AI.GenerateNewProject.Plan (Plan)
import qualified Wasp.Cli.Command.AI.GenerateNewProject.Plan as P
import Wasp.Cli.Command.CreateNewProject (readCoreWaspProjectFiles)
data NewProjectDetails = NewProjectDetails
{ _projectName :: !String,
{ _projectAppName :: !String,
_projectDescription :: !String,
_projectAuth :: !AuthProvider
}
-- TODO: Make these relative to WaspProjectDir?
type File = (FilePath, Text)
data AuthProvider = Google
-- TODO: Support more methods.
data AuthProvider = UsernameAndPassword
-- TODO: Have generateNewProject accept Chan, to which it will stream its progress?
-- It could just stream its output instead of printing it to stdout, so calling function
@ -34,13 +41,13 @@ data AuthProvider = Google
-- and also contain description of what happened (or maybe that is separate message).
generateNewProject :: NewProjectDetails -> CodeAgent ()
generateNewProject newProjectDetails = do
coreFiles <- liftIO $ map (first SP.fromRelFile) <$> readCoreWaspProjectFiles
mapM_ writeNewFile coreFiles
let waspFile = generateBaseWaspFile newProjectDetails
let waspFilePath = fst waspFile
writeNewFile waspFile
let dotEnvServerFile = generateDotEnvServerFile newProjectDetails
writeNewFile dotEnvServerFile
let otherNewProjectFiles = generateOtherNewProjectFiles newProjectDetails
mapM_ writeNewFile otherNewProjectFiles
writeToLog "Generated project skeleton."
writeToLog "Generating plan..."
@ -76,18 +83,29 @@ generateNewProject newProjectDetails = do
return page
-- TODO: what about having additional step here that goes through all the files once again and fixes any stuff in them (Wasp, JS files)? REPL?
-- TODO: add some commented out lines to wasp file that showcase other features? jobs, api, serverSetup, sockets, ... .
writeToLog "Done!"
-- TODO: OpenAI released ChatGPT 3.5-turbo with 16k context, should we use that one?
-- What about "functions" feature that they released?
generateBaseWaspFile :: NewProjectDetails -> File
generateBaseWaspFile = undefined
-- [ ChatMessage
-- { role = System,
-- content = "You are an expert Wasp developer, helping set up a new Wasp project."
-- },
-- ChatMessage
-- { role = User,
-- -- TODO: I should tell it to mark the type of each ext import: "page", "query", "action".
-- content = "Hi"
-- }
-- ]
generateDotEnvServerFile :: NewProjectDetails -> File
generateDotEnvServerFile = undefined
-- TODO: implement generateOtherNewProjectFiles based on existing CNP.createWaspProjectDir function.
generateOtherNewProjectFiles :: NewProjectDetails -> [File]
generateOtherNewProjectFiles = undefined -- Maybe add dotenvserver under this.
generatePlan :: NewProjectDetails -> CodeAgent Plan
generatePlan = undefined
@ -140,3 +158,83 @@ data Page = Page
_pageJsImpl :: String,
_pagePlan :: P.Action
}
waspFileExample =
[trimming|
Example main.wasp (comments are explanation for you):
```wasp
app todoApp {
wasp: { version: "^0.10.2" },
title: "ToDo App",
auth: {
userEntity: User,
// Define only if using social (google) auth.
externalAuthEntity: SocialLogin,
methods: {
usernameAndPassword: {},
google: {
configFn: import { config } from "@server/auth/google.js",
getUserFieldsFn: import { getUserFields } from "@server/auth/google.js"
},
},
onAuthFailedRedirectTo: "/login",
onAuthSucceededRedirectTo: "/"
}
}
// psl stands for Prisma Schema Language.
entity User {=psl
id Int @id @default(autoincrement())
username String @unique
password String
tasks Task[]
externalAuthAssociations SocialLogin[] // Only if using social auth.
psl=}
// Define only if using social auth (e.g. google).
entity SocialLogin {=psl
id Int @id @default(autoincrement())
provider String
providerId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
createdAt DateTime @default(now())
@@unique([provider, providerId, userId])
psl=}
// Ommiting entity Task to keep the example short.
route SignupRoute { path: "/signup", to: SignupPage }
page SignupPage {
component: import Signup from "@client/pages/auth/Signup.jsx" // REQ.
}
// Ommiting LoginRoute and LoginPage to keep the example short.
route HomeRoute { path: "/", to: MainPage }
page MainPage {
authRequired: true,
component: import Main from "@client/pages/Main.jsx"
}
// Queries are nodejs functions that do R in CRUD.
query getTasks {
fn: import { getTasks } from "@server/queries.js", // REQ
entities: [Task]
}
// Actions are like quries but do CUD in CRUD.
action createTask {
fn: import { createTask } from "@server/actions.js",
entities: [Task]
}
```
|]
basicWaspLangInfo =
[trimming|
Wasp is web app framework that uses React, NodeJS and Prisma.
High-level is described in main.wasp file, details in JS/JSX files.
Main Wasp features: frontend Routes and Pages, Queries and Actions (RPC), Entities.
|]

View File

@ -3,239 +3,62 @@
module Wasp.Cli.Command.AI.New
( new,
queryChatGpt,
sayHiToChatGpt,
)
where
import Control.Arrow ()
import Control.Monad.Except (MonadIO (liftIO))
import Data.Aeson ((.=))
import qualified Data.Aeson as Aeson
import Data.ByteString.UTF8 as BSU
import Data.Text (Text)
import GHC.Generics (Generic)
import NeatInterpolation (trimming)
import qualified Network.HTTP.Simple as HTTP
import StrongPath (Abs, Dir, Path', fromAbsDir, fromRelFile, relfile, (</>))
import Control.Monad.Except (MonadError (throwError), MonadIO (liftIO))
import qualified Data.Text as T
import StrongPath (fromAbsDir)
import StrongPath.Operations ()
import System.Directory (getFileSize, setCurrentDirectory)
import qualified System.Environment as System.Environment
import qualified Wasp.AppSpec.Valid as ASV
import Wasp.Cli.Command (Command)
import Wasp.Cli.Command.Common (findWaspProjectRootDirFromCwd, readWaspCompileInfo)
import Wasp.Cli.Command.Compile (analyze)
import Wasp.Cli.Command.CreateNewProject (createNewProject)
import System.Directory (setCurrentDirectory)
import System.Environment (lookupEnv)
import Wasp.Cli.Command (Command, CommandError (CommandError))
import qualified Wasp.Cli.Command.AI.CodeAgent as CA
import qualified Wasp.Cli.Command.AI.GenerateNewProject as GNP
import qualified Wasp.Cli.Command.CreateNewProject as CNP
import Wasp.Cli.Command.Message (cliSendMessageC)
import Wasp.Cli.Command.Start.Db (getDbSystem)
import qualified Wasp.Message as Msg
import Wasp.Project (WaspProjectDir)
import Wasp.Util.IO (removeFile)
import qualified Wasp.Util.IO as IOUtil
import qualified Wasp.Util.Terminal as Term
new :: Command ()
new = do
(webAppTitle, webAppDescription) <- liftIO $ do
putStrLn "Describe the web app you want to create:"
putStrLn "Title:"
title <- getLine
putStrLn "What it should work like:"
desc <- getLine
return (title, desc)
openAIApiKey <-
liftIO (lookupEnv "OPENAI_API_KEY")
>>= maybe throwMissingOpenAIApiKeyEnvVarError pure
absWaspProjectDir <- createNewEmptyProject webAppTitle
(webAppName, webAppDescription) <- liftIO $ do
putStrLn "App name (e.g. MyFirstApp):"
appName <- getLine
putStrLn "Describe your app in a couple of sentences:"
desc <- getLine
return (appName, desc)
projectInfo <- CNP.parseProjectInfo webAppName
absWaspProjectDir <- CNP.createEmptyWaspProjectDir projectInfo
liftIO $ setCurrentDirectory $ fromAbsDir absWaspProjectDir
waspFileContent <- aiWriteWaspFile absWaspProjectDir webAppTitle webAppDescription
let codeAgentConfig =
CA.CodeAgentConfig
{ CA._openAIApiKey = openAIApiKey,
-- TODO: Use more appropriate functions here.
CA._writeFile = \fp c -> putStrLn $ "\nwriteFile:\n" <> show (fp, c) <> "\n",
CA._writeLog = putStrLn . ("writeLog: " <>) . T.unpack
}
aiWriteWaspPages absWaspProjectDir waspFileContent
aiWriteWaspOperations absWaspProjectDir waspFileContent
-- Maybe write something else also: setupFn or something.
let newProjectDetails =
GNP.NewProjectDetails
{ GNP._projectAppName = webAppName,
GNP._projectDescription = webAppDescription,
GNP._projectAuth = GNP.UsernameAndPassword
}
liftIO $
CA.runCodeAgent codeAgentConfig $
GNP.generateNewProject newProjectDetails
return ()
where
-- appSpec <- analyze waspDir
-- let (appName, app) = ASV.getApp appSpec
testAppDesc =
[trimming|
Simple app that enables inputing pokemons and then on request can produce a random fight between the two of them.
It should have authentication, so each user has their own pokemon, but fights happen with random pokemon
of another user.
|]
createNewEmptyProject :: String -> Command (Path' Abs (Dir WaspProjectDir))
createNewEmptyProject webAppTitle = do
projectInfo <- CNP.parseProjectInfo webAppTitle
CNP.createWaspProjectDir projectInfo
absWaspProjectDir <- CNP.getAbsoluteWaspProjectDir projectInfo
-- Delete existing source files that we generate in the new project.
-- TODO: Instead of deleting files, I should instead have a function that generates
-- the very basic skeleton for the Wasp app, and then the normal "new app" would
-- just add files to it.
liftIO $ do
removeFile $ absWaspProjectDir </> [relfile|main.wasp|]
removeFile $ absWaspProjectDir </> [relfile|src/client/Main.css|]
removeFile $ absWaspProjectDir </> [relfile|src/client/MainPage.jsx|]
removeFile $ absWaspProjectDir </> [relfile|src/client/waspLogo.png|]
return absWaspProjectDir
-- Writes wasp file to disk, but also returns its content.
-- TODO: Also check if it compiles and if not, send errors to GPT.
aiWriteWaspFile :: Path' Abs (Dir WaspProjectDir) -> String -> Text -> Command Text
aiWriteWaspFile absWaspProjectDir appTitle appDesc = do
-- TODO: Tell GPT about Wasp in general, shortly.
-- Also give it an example of a Wasp file that is pretty rich. It can even be done as a
-- previous part of the conversation, so it has an example of what is good.
-- Then, tell it to generate a Wasp file for the given prompt, while also adding comments in
-- it for every ExtImport, where comments explain what that file is supposed to contain / do,
-- so basically to serve as instrutioncs to itself.
-- Or probably best to tell it to provide those instructions separately, in a JSON object
-- where key is the name of each page or whatever.
-- Once it does, let's feed that Wasp file to the Wasp analyzer and see if it returns any
-- errors. If it does, send it back to chat GPT for repairs.
-- Finally, write it to disk.
-- In the example wasp file, we can put in a lot of comments explaining stuff, but then we can
-- ask it to not produce those once it produces a wasp file, so we save some tokens.
-- We should also make it clear which feature in wasp file is related to which part of the
-- prompt, so it can know to skip them properly.
-- TODO: What if it fails to repair it? Well we can just pretend all is ok, let user fix it.
-- TODO: Ask chatGPT to compress our prompt for us.
let chatMessages =
[ ChatMessage
{ role = System,
content = "You are an expert Wasp developer, helping set up a new Wasp project."
},
ChatMessage
{ role = User,
-- TODO: I should tell it to mark the type of each ext import: "page", "query", "action".
content =
[trimming|
Wasp is web app framework that uses React, NodeJS and Prisma.
High-level is described in main.wasp file, details in JS/JSX files.
Main Wasp features: frontend Routes and Pages, Queries and Actions (RPC), Entities.
Example main.wasp (comments are explanation for you):
```wasp
app todoApp {
wasp: { version: "^0.10.2" },
title: "ToDo App",
auth: {
userEntity: User,
// Define only if using social (google) auth.
externalAuthEntity: SocialLogin,
methods: {
usernameAndPassword: {},
google: {
configFn: import { config } from "@server/auth/google.js",
getUserFieldsFn: import { getUserFields } from "@server/auth/google.js"
},
},
onAuthFailedRedirectTo: "/login",
onAuthSucceededRedirectTo: "/"
}
}
// psl stands for Prisma Schema Language.
entity User {=psl
id Int @id @default(autoincrement())
username String @unique
password String
tasks Task[]
externalAuthAssociations SocialLogin[] // Only if using social auth.
psl=}
// Define only if using social auth (e.g. google).
entity SocialLogin {=psl
id Int @id @default(autoincrement())
provider String
providerId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
createdAt DateTime @default(now())
@@unique([provider, providerId, userId])
psl=}
// TODO: implement entity Task.
route SignupRoute { path: "/signup", to: SignupPage }
page SignupPage {
component: import Signup from "@client/pages/auth/Signup.jsx" // REQ.
}
// TODO: implement LoginPage, analogous to SignupPage.
route HomeRoute { path: "/", to: MainPage }
page MainPage {
authRequired: true,
component: import Main from "@client/pages/Main.jsx"
}
// Queries are nodejs functions that do R in CRUD.
query getTasks {
fn: import { getTasks } from "@server/queries.js", // REQ
entities: [Task]
}
// Actions are like quries but do CUD in CRUD.
action createTask {
fn: import { createTask } from "@server/actions.js",
entities: [Task]
}
```
Now, I will describe a new Wasp app to you.
You will first respond with only the content of main.wasp file (no comments).
Then, you will print a line that goes like this: "---------------------------", to mark the ending of the wasp file content. This is very important, make sure to do this.
Finally, for every external import, provide instructions on how to implement the corresponding JS function/component.
The instructions should be in a JSON format like this:
[{ import: "import { createTask } from \"@server/tasks.js\"", in: "page", instruction: "..." }, ...].
`in` field can be "page", "query" or "action".
Everything after this sentence is app description:
${appDesc}
|]
}
]
error "TODO"
aiWriteWaspPages :: Path' Abs (Dir WaspProjectDir) -> Text -> Command ()
aiWriteWaspPages absWaspProjectDir waspFileContent = do
-- TODO: Actually it should recieve AppSpec I think, right?
-- So idea is here that for each page we give Wasp file to Chat GPT, also all the basic
-- knowledge about Wasp, and then ask it to generate JS for that page. If page already exists,
-- we also pass that and tell it to add stuff to it.
--
-- How do we get comments to it about it? Well, maybe it is smart enough to pick them up from
-- the wasp file?
-- Or, we give them separately, but then we need them in a good format. I think it will be
-- able to pick them up on its own.
-- Oh and we also need to give it info about the concept of the page itself! And example. Uff.
-- Maybe that is too much and we can't give it all that. In that case we should drop the idea
-- of passing whole Wasp file, and we need to give it only instructions for that page. We can
-- have it write those instructions separately, for each page, in that case. Yeah probably
-- that is the best.
-- Hm and we also need initial prompt by user here.
error "TODO"
aiWriteWaspOperations :: Path' Abs (Dir WaspProjectDir) -> Text -> Command ()
aiWriteWaspOperations absWaspProjectDir waspFileContent = do
-- Here we do everything analogous as we did for the pages, but it becomes extra important to be able to reuse the already written file, so we should make sure we have that going,
-- because often it is normal to put multiple operations in the same file.
error "TODO"
-- TODO: What about other ext imports? Make sure we cover all of them: jobs, setupFn (server, client), api, something else?
-- In general, gpt-3.5-turbo-0301 does not pay strong attention to the system message, and therefore important instructions are often better placed in a user message.
sayHiToChatGpt :: IO ()
sayHiToChatGpt = do
apiKey <- System.Environment.getEnv "OPENAI_API_KEY"
answer <- queryChatGpt apiKey [ChatMessage {role = User, content = "What is 2 + 2?"}]
print answer
throwMissingOpenAIApiKeyEnvVarError =
throwError $
CommandError
"Missing OPENAI_API_KEY env var"
"You can obtain this key from your OpenAI profile."

View File

@ -5,16 +5,19 @@ module Wasp.Cli.Command.CreateNewProject
parseProjectInfo,
ProjectInfo (..),
getAbsoluteWaspProjectDir,
readCoreWaspProjectFiles,
createEmptyWaspProjectDir,
)
where
import Control.Monad.Except (throwError)
import Control.Monad.IO.Class (liftIO)
import Data.List (intercalate)
import Data.Text (Text)
import Path.IO (copyDirRecur, doesDirExist)
import StrongPath (Abs, Dir, Path, Path', System, parseAbsDir, reldir, relfile, (</>))
import StrongPath (Abs, Dir, File', Path, Path', Rel, System, fromAbsDir, parseAbsDir, reldir, relfile, (</>))
import StrongPath.Path (toPathAbsDir)
import System.Directory (getCurrentDirectory)
import System.Directory (createDirectory, getCurrentDirectory)
import qualified System.FilePath as FP
import Text.Printf (printf)
import Wasp.Analyzer.Parser (isValidWaspIdentifier)
@ -22,6 +25,7 @@ import Wasp.Cli.Command (Command, CommandError (..))
import qualified Wasp.Data as Data
import Wasp.Project (WaspProjectDir)
import Wasp.Util (indent, kebabToCamelCase)
import Wasp.Util.IO (readFileStrict)
import qualified Wasp.Util.IO as IOUtil
import qualified Wasp.Util.Terminal as Term
import qualified Wasp.Version as WV
@ -63,6 +67,15 @@ parseProjectInfo name
where
appName = kebabToCamelCase name
createEmptyWaspProjectDir :: ProjectInfo -> Command (Path System Abs (Dir WaspProjectDir))
createEmptyWaspProjectDir projectInfo = do
absWaspProjectDir <- getAbsoluteWaspProjectDir projectInfo
dirExists <- doesDirExist $ toPathAbsDir absWaspProjectDir
if dirExists
then throwProjectCreationError $ show absWaspProjectDir ++ " is an existing directory"
else liftIO $ createDirectory $ fromAbsDir absWaspProjectDir
return absWaspProjectDir
createWaspProjectDir :: ProjectInfo -> Command ()
createWaspProjectDir projectInfo = do
absWaspProjectDir <- getAbsoluteWaspProjectDir projectInfo
@ -73,6 +86,28 @@ createWaspProjectDir projectInfo = do
initializeProjectFromSkeleton absWaspProjectDir
writeMainWaspFile absWaspProjectDir projectInfo
-- TODO: This module needs cleaning up now, after my changes, because there are multiple ways to do the same thing.
-- Idea: maybe have two dirs, one called "core", another called "new" that only holds additional files.
-- TODO: This is now repeating what is in templates/new which is not great.
coreWaspProjectFiles :: [Path System (Rel WaspProjectDir) File']
coreWaspProjectFiles =
[ [relfile|.gitignore|],
[relfile|.wasproot|],
[relfile|src/.waspignore|],
[relfile|src/client/tsconfig.json|],
[relfile|src/client/vite-env.d.ts|],
[relfile|src/server/tsconfig.json|],
[relfile|src/shared/tsconfig.json|]
]
readCoreWaspProjectFiles :: IO [(Path System (Rel WaspProjectDir) File', Text)]
readCoreWaspProjectFiles = do
dataDir <- Data.getAbsDataDirPath
let templatesNewDir = dataDir </> [reldir|Cli/templates/new|]
contents <- mapM (readFileStrict . (templatesNewDir </>)) coreWaspProjectFiles
return $ zip coreWaspProjectFiles contents
getAbsoluteWaspProjectDir :: ProjectInfo -> Command (Path System Abs (Dir WaspProjectDir))
getAbsoluteWaspProjectDir (ProjectInfo projectName _) = do
absCwd <- liftIO getCurrentDirectory

View File

@ -365,6 +365,7 @@ library cli-lib
, waspc
, waspls
, neat-interpolation
, unordered-containers
other-modules: Paths_waspc
exposed-modules:
Wasp.Cli.Command