Actions are now generated.

This commit is contained in:
Martin Sosic 2023-06-15 15:44:52 +02:00
parent cbb3962508
commit 9ffc115fcd
9 changed files with 279 additions and 89 deletions

View File

@ -56,6 +56,7 @@ getFile path = gets $ H.lookup path . _files
getAllFiles :: CodeAgent [(FilePath, Text)]
getAllFiles = gets $ H.toList . _files
-- TODO: Make it so that if ChatGPT replies with being too busy, we try again.
queryChatGPT :: ChatGPTParams -> [ChatMessage] -> CodeAgent Text
queryChatGPT params messages = do
key <- asks _openAIApiKey

View File

@ -10,7 +10,9 @@ import Data.Text (Text)
import qualified Data.Text as T
import NeatInterpolation (trimming)
import Wasp.Cli.Command.AI.CodeAgent (CodeAgent, writeToLog)
import Wasp.Cli.Command.AI.GenerateNewProject.Action (Action, generateAndWriteAction)
import Wasp.Cli.Command.AI.GenerateNewProject.Common (NewProjectDetails (..))
import Wasp.Cli.Command.AI.GenerateNewProject.Entity (writeEntitiesToWaspFile)
import Wasp.Cli.Command.AI.GenerateNewProject.Plan (generatePlan)
import qualified Wasp.Cli.Command.AI.GenerateNewProject.Plan as Plan
import Wasp.Cli.Command.AI.GenerateNewProject.Skeleton (generateAndWriteProjectSkeleton)
@ -25,18 +27,18 @@ import Wasp.Cli.Command.AI.GenerateNewProject.Skeleton (generateAndWriteProjectS
-- and also contain description of what happened (or maybe that is separate message).
generateNewProject :: NewProjectDetails -> CodeAgent ()
generateNewProject newProjectDetails = do
waspFilePath <- generateAndWriteProjectSkeleton newProjectDetails
(waspFilePath, planRules) <- generateAndWriteProjectSkeleton newProjectDetails
writeToLog "Generated project skeleton."
writeToLog "Generating plan..."
plan <- generatePlan newProjectDetails
plan <- generatePlan newProjectDetails planRules
writeToLog $ "Plan generated!\n" <> summarizePlan plan
writeEntitiesToWaspFile waspFilePath (Plan.entities plan)
writeToLog "Added entities to wasp file."
writeToLog "Generating actions..."
actions <- forM (Plan.actions plan) $ generateAndWriteAction waspFilePath plan
actions <- forM (Plan.actions plan) $ generateAndWriteAction newProjectDetails waspFilePath plan
writeToLog "Generating queries..."
queries <- forM (Plan.queries plan) $ generateAndWriteQuery waspFilePath plan
@ -70,16 +72,9 @@ generateNewProject newProjectDetails = do
showT :: Show a => a -> Text
showT = T.pack . show
generateAndWriteAction waspFilePath plan actionPlan = do
action <- generateAction newProjectDetails (Plan.entities plan) actionPlan
writeActionToFile action
writeActionToWaspFile waspFilePath action
writeToLog $ "Generated action: " <> T.pack (Plan.actionName actionPlan)
return action
generateAndWriteQuery waspFilePath plan queryPlan = do
query <- generateQuery newProjectDetails (Plan.entities plan) queryPlan
writeQueryToFile query
writeQueryToFile query -- TODO: Do we need to convert @server/ from path into src/server/?
writeQueryToWaspFile waspFilePath query
writeToLog $ "Generated query: " <> T.pack (Plan.queryName queryPlan)
return query
@ -91,26 +86,6 @@ generateNewProject newProjectDetails = do
writeToLog $ "Generated page: " <> T.pack (Plan.pageName pagePlan)
return page
writeEntitiesToWaspFile :: FilePath -> [Plan.Entity] -> CodeAgent ()
writeEntitiesToWaspFile waspFilePath entities = do
-- TODO: assemble code for each entity and write it to wasp file.
undefined
generateAction :: NewProjectDetails -> [Plan.Entity] -> Plan.Action -> CodeAgent Action
generateAction = undefined
writeActionToFile :: Action -> CodeAgent ()
writeActionToFile = undefined
writeActionToWaspFile :: FilePath -> Action -> CodeAgent ()
writeActionToWaspFile waspFilePath action = undefined
data Action = Action
{ _actionWaspDecl :: String,
_actionJsImpl :: String,
_actionPlan :: Plan.Action
}
generateQuery :: NewProjectDetails -> [Plan.Entity] -> Plan.Query -> CodeAgent Query
generateQuery = undefined

View File

@ -0,0 +1,149 @@
{-# LANGUAGE DeriveGeneric #-}
module Wasp.Cli.Command.AI.GenerateNewProject.Action
( generateAndWriteAction,
Action,
)
where
import Data.Aeson (FromJSON)
import Data.List (isPrefixOf)
import Data.Maybe (fromMaybe)
import qualified Data.Text as T
import GHC.Generics (Generic)
import NeatInterpolation (trimming)
import Wasp.Cli.Command.AI.CodeAgent (CodeAgent, writeToFile, writeToLog)
import Wasp.Cli.Command.AI.GenerateNewProject.Common
( NewProjectDetails (..),
defaultChatGPTParams,
queryChatGPTForJSON,
)
import Wasp.Cli.Command.AI.GenerateNewProject.Common.Prompts (appDescriptionStartMarkerLine)
import qualified Wasp.Cli.Command.AI.GenerateNewProject.Common.Prompts as Prompts
import Wasp.Cli.Command.AI.GenerateNewProject.Entity (entityPlanToWaspDecl)
import Wasp.Cli.Command.AI.GenerateNewProject.Plan (Plan)
import qualified Wasp.Cli.Command.AI.GenerateNewProject.Plan as Plan
import Wasp.OpenAI.ChatGPT (ChatMessage (..), ChatRole (..))
generateAndWriteAction :: NewProjectDetails -> FilePath -> Plan -> Plan.Action -> CodeAgent Action
generateAndWriteAction newProjectDetails waspFilePath plan actPlan = do
action <- generateAction newProjectDetails (Plan.entities plan) actPlan
writeActionToJsFile action
writeActionToWaspFile waspFilePath action
writeToLog $ "Generated action: " <> T.pack (Plan.actionName actPlan)
return action
generateAction :: NewProjectDetails -> [Plan.Entity] -> Plan.Action -> CodeAgent Action
generateAction newProjectDetails entityPlans plan = do
impl <- queryChatGPTForJSON defaultChatGPTParams chatMessages
return Action {actionImpl = impl, actionPlan = plan}
where
chatMessages =
[ ChatMessage {role = System, content = Prompts.systemPrompt},
ChatMessage {role = User, content = planPrompt}
]
appName = T.pack $ _projectAppName newProjectDetails
appDesc = T.pack $ _projectDescription newProjectDetails
basicWaspLangInfoPrompt = Prompts.basicWaspLangInfo
actionName = T.pack $ Plan.actionName plan
actionFnPath = T.pack $ Plan.actionFnPath plan
actionDesc = T.pack $ Plan.actionDesc plan
entityDecls = T.intercalate "\n\n" $ entityPlanToWaspDecl <$> entityPlans
planPrompt =
[trimming|
${basicWaspLangInfoPrompt}
${actionDocPrompt}
We are implementing a Wasp app (check bottom for description).
This app has following entities:
${entityDecls}
Let's now implement the following Wasp action:
- name: ${actionName}
- fn: ${actionFnPath}
- description: ${actionDesc}
Please, respond ONLY with a valid JSON, of following format:
{ "actionWaspDecl": "action ${actionName} {\n ... }",
"actionJsImpl": "export const {$actionName} ... ",
"actionJsImports": "import foo from \"bar.js\"\n ...""
}
"waspDeclaration" and "jsImplementation" are required, "jsImports" you can skip if none are needed.
There should be no other text in the response.
${appDescriptionStartMarkerLine}
App name: ${appName}
${appDesc}
|]
actionDocPrompt =
[trimming|
Action is implemented via Wasp declaration and corresponding NodeJS implementation.
Example of Wasp declaration:
```wasp
action updateTaskIsDone {
fn: import { updateTaskIsDone } from "@server/taskActions.js",
entities: [Task] // Entities that action uses.
}
```
Example of NodeJS implementation:
```js
import HttpError from '@wasp/core/HttpError.js'
export const updateTaskIsDone = (args, context) => {
if (!context.user) { throw new HttpError(403) }
return context.entities.Task.update({ // prisma object
where: { args.id },
data: { args.isDone }
})
}
```
Action can then be easily called from the client, via Wasp's RPC mechanism.
|]
writeActionToJsFile :: Action -> CodeAgent ()
writeActionToJsFile action =
-- TODO: An issue we have here is that if other action already did the same import,
-- we don't know and we import it again.
-- One thing we can do it supply chatGPT with a list of imports that are already there.
-- Second thing we can do is to look for same lines at the start of the file, but that sounds fragile.
-- Maybe best to read and pass previous imports (we would have to do that above somewhere).
-- Or even the whole file? Hmmmmm.
writeToFile path $
((jsImports <> "\n") <>) . (<> "\n\n" <> jsImpl) . (fromMaybe "")
where
path = resolvePath $ Plan.actionFnPath $ actionPlan action
jsImpl = T.pack $ actionJsImpl $ actionImpl action
jsImports = T.pack $ actionJsImports $ actionImpl action
resolvePath p | "@server/" `isPrefixOf` p = "src/server/" <> drop (length ("@server/" :: String)) p
resolvePath _ = error "path incorrectly formatted, should start with @server."
writeActionToWaspFile :: FilePath -> Action -> CodeAgent ()
writeActionToWaspFile waspFilePath action =
writeToFile waspFilePath $
(<> "\n\n" <> waspDeclCode) . fromMaybe (error "wasp file shouldn't be empty")
where
waspDeclCode = T.pack $ actionWaspDecl $ actionImpl action
data Action = Action
{ actionImpl :: ActionImpl,
actionPlan :: Plan.Action
}
deriving (Show)
data ActionImpl = ActionImpl
{ actionWaspDecl :: String,
actionJsImpl :: String,
actionJsImports :: String
}
deriving (Generic, Show)
instance FromJSON ActionImpl

View File

@ -2,10 +2,19 @@ module Wasp.Cli.Command.AI.GenerateNewProject.Common
( NewProjectDetails (..),
File,
AuthProvider (..),
queryChatGPTForJSON,
defaultChatGPTParams,
writeToWaspFileEnd,
)
where
import Data.Aeson (FromJSON)
import qualified Data.Aeson as Aeson
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Wasp.Cli.Command.AI.CodeAgent (CodeAgent, queryChatGPT, writeToFile)
import Wasp.OpenAI.ChatGPT (ChatGPTParams (..), ChatMessage (..), Model (GPT_3_5_turbo_16k))
import Wasp.Util (naiveTrimJSON, textToLazyBS)
data NewProjectDetails = NewProjectDetails
{ _projectAppName :: !String,
@ -13,8 +22,29 @@ data NewProjectDetails = NewProjectDetails
_projectAuth :: !AuthProvider
}
-- TODO: Make these relative to WaspProjectDir?
-- TODO: Make these relative to WaspProjectDir, via StrongPath?
type File = (FilePath, Text)
-- TODO: Support more methods.
data AuthProvider = UsernameAndPassword
queryChatGPTForJSON :: FromJSON a => ChatGPTParams -> [ChatMessage] -> CodeAgent a
queryChatGPTForJSON chatGPTParams chatMessages = do
responseJSONText <- naiveTrimJSON <$> queryChatGPT chatGPTParams chatMessages
case Aeson.eitherDecode $ textToLazyBS responseJSONText of
Right plan -> return plan
Left _errMsg ->
-- TODO: Handle this better.
-- Try sending response back to chatGPT and ask it to fix it -> hey it is not valid JSON, fix it.
-- Write to log, to let user know.
error "Failed to parse plan"
-- TODO: Test more for the optimal temperature (possibly higher).
-- TODO: Should we make sure we have max_tokens set to high enough?
defaultChatGPTParams :: ChatGPTParams
defaultChatGPTParams = ChatGPTParams {_model = GPT_3_5_turbo_16k, _temperature = Just 1.0}
writeToWaspFileEnd :: FilePath -> Text -> CodeAgent ()
writeToWaspFileEnd waspFilePath text = do
writeToFile waspFilePath $
(<> "\n" <> text) . fromMaybe (error "wasp file shouldn't be empty")

View File

@ -0,0 +1,28 @@
module Wasp.Cli.Command.AI.GenerateNewProject.Entity
( writeEntitiesToWaspFile,
entityPlanToWaspDecl,
)
where
import Data.Text (Text)
import qualified Data.Text as T
import NeatInterpolation (trimming)
import Wasp.Cli.Command.AI.CodeAgent (CodeAgent)
import Wasp.Cli.Command.AI.GenerateNewProject.Common (writeToWaspFileEnd)
import qualified Wasp.Cli.Command.AI.GenerateNewProject.Plan as Plan
writeEntitiesToWaspFile :: FilePath -> [Plan.Entity] -> CodeAgent ()
writeEntitiesToWaspFile waspFilePath entityPlans = do
writeToWaspFileEnd waspFilePath $ "\n" <> entitiesCode
where
entitiesCode = T.intercalate "\n\n" $ entityPlanToWaspDecl <$> entityPlans
entityPlanToWaspDecl :: Plan.Entity -> Text
entityPlanToWaspDecl plan =
let name = T.pack $ Plan.entityName plan
pslBody = T.pack $ Plan.entityBodyPsl plan
in [trimming|
entity ${name} {=psl
${pslBody}
psl=}
|]

View File

@ -7,38 +7,32 @@ module Wasp.Cli.Command.AI.GenerateNewProject.Plan
Action (..),
Page (..),
generatePlan,
PlanRule,
)
where
import Data.Aeson (FromJSON)
import qualified Data.Aeson as Aeson
import Data.ByteString.Lazy.UTF8 (ByteString)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Lazy as TL
import qualified Data.Text.Lazy.Encoding as TLE
import GHC.Generics (Generic)
import NeatInterpolation (trimming)
import Wasp.Cli.Command.AI.CodeAgent (CodeAgent, queryChatGPT)
import Wasp.Cli.Command.AI.GenerateNewProject.Common (NewProjectDetails (_projectAppName, _projectDescription))
import Wasp.Cli.Command.AI.CodeAgent (CodeAgent)
import Wasp.Cli.Command.AI.GenerateNewProject.Common
( AuthProvider (UsernameAndPassword),
NewProjectDetails (..),
defaultChatGPTParams,
queryChatGPTForJSON,
)
import Wasp.Cli.Command.AI.GenerateNewProject.Common.Prompts (appDescriptionStartMarkerLine)
import qualified Wasp.Cli.Command.AI.GenerateNewProject.Common.Prompts as Prompts
import Wasp.OpenAI.ChatGPT (ChatGPTParams (..), ChatMessage (..), ChatRole (..), Model (..))
import Wasp.OpenAI.ChatGPT (ChatMessage (..), ChatRole (..))
generatePlan :: NewProjectDetails -> CodeAgent Plan
generatePlan newProjectDetails = do
responseJSONText <- naiveTrimJSON <$> queryChatGPT chatGPTParams chatMessages
case Aeson.eitherDecode $ textToLazyBS responseJSONText of
Right plan -> return plan
Left _errMsg ->
-- TODO: Handle this better.
-- Try sending response back to chatGPT and ask it to fix it -> hey it is not valid JSON, fix it.
-- Write to log, to let user know.
error "Failed to parse plan"
-- | Additional rule to follow while generating plan.
type PlanRule = String
generatePlan :: NewProjectDetails -> [PlanRule] -> CodeAgent Plan
generatePlan newProjectDetails planRules = do
queryChatGPTForJSON defaultChatGPTParams chatMessages
where
-- TODO: Try configuring temperature.
-- TODO: Make sure we have max_tokens set to high enough.
chatGPTParams = ChatGPTParams {_model = GPT_3_5_turbo_16k, _temperature = Just 1.0}
chatMessages =
[ ChatMessage {role = System, content = Prompts.systemPrompt},
ChatMessage {role = User, content = planPrompt}
@ -47,6 +41,7 @@ generatePlan newProjectDetails = do
appDesc = T.pack $ _projectDescription newProjectDetails
basicWaspLangInfoPrompt = Prompts.basicWaspLangInfo
waspFileExamplePrompt = Prompts.waspFileExample
rulesText = T.pack . unlines $ "Rules:" : map (" - " ++) planRules
planPrompt =
[trimming|
${basicWaspLangInfoPrompt}
@ -80,9 +75,10 @@ generatePlan newProjectDetails = do
}]
}
Make sure to generate at least one page with routePath "/".
${rulesText}
We will later use this plan to implement all of these parts of Wasp app.
We will later use this plan to implement all of these parts of Wasp app,
so make sure descriptions are detailed enough to guide implementing them.
Please, respond ONLY with a valid JSON that is a plan.
There should be no other text in the response.
@ -171,15 +167,3 @@ data Page = Page
deriving (Generic, Show)
instance FromJSON Page
-- | Given a text containing a single instance of JSON and some text around it but no { or }, trim
-- it until just JSON is left.
-- Examples
-- naiveTrimJson "some text { \"a\": 5 } yay" == "{\"a\": 5 }"
-- naiveTrimJson "some {text} { \"a\": 5 }" -> won't work correctly.
naiveTrimJSON :: Text -> Text
naiveTrimJSON textContainingJson =
T.reverse . T.dropWhile (/= '}') . T.reverse . T.dropWhile (/= '{') $ textContainingJson
textToLazyBS :: Text -> ByteString
textToLazyBS = TLE.encodeUtf8 . TL.fromStrict

View File

@ -10,15 +10,16 @@ import NeatInterpolation (trimming)
import qualified StrongPath as SP
import Wasp.Cli.Command.AI.CodeAgent (CodeAgent, writeNewFile)
import Wasp.Cli.Command.AI.GenerateNewProject.Common (AuthProvider (..), File, NewProjectDetails (..))
import Wasp.Cli.Command.AI.GenerateNewProject.Plan (PlanRule)
import Wasp.Cli.Command.CreateNewProject (readCoreWaspProjectFiles)
import qualified Wasp.Version
generateAndWriteProjectSkeleton :: NewProjectDetails -> CodeAgent FilePath
generateAndWriteProjectSkeleton :: NewProjectDetails -> CodeAgent (FilePath, [PlanRule])
generateAndWriteProjectSkeleton newProjectDetails = do
coreFiles <- liftIO $ map (first SP.fromRelFile) <$> readCoreWaspProjectFiles
mapM_ writeNewFile coreFiles
let waspFile@(waspFilePath, _) = generateBaseWaspFile newProjectDetails
let (waspFile@(waspFilePath, _), planRules) = generateBaseWaspFile newProjectDetails
writeNewFile waspFile
case _projectAuth newProjectDetails of
@ -28,19 +29,22 @@ generateAndWriteProjectSkeleton newProjectDetails = do
writeNewFile generateDotEnvServerFile
return waspFilePath
return (waspFilePath, planRules)
generateBaseWaspFile :: NewProjectDetails -> File
generateBaseWaspFile newProjectDetails = (path, content)
generateBaseWaspFile :: NewProjectDetails -> (File, [PlanRule])
generateBaseWaspFile newProjectDetails = ((path, content), planRules)
where
path = "main.wasp"
appName = T.pack $ _projectAppName newProjectDetails
appTitle = appName
waspVersion = T.pack $ show Wasp.Version.waspVersion
appAuth = case _projectAuth newProjectDetails of
-- NOTE: We assume here that there will be a page with route "/".
(appAuth, authPlanRules) = case _projectAuth newProjectDetails of
-- NOTE: We assume two things here:
-- - that there will be a page with route "/".
-- - that there will be a User entity with 'id', 'username' and 'password'.
-- We later that those will be added to Plan.
UsernameAndPassword ->
[trimming|
( [trimming|
auth: {
userEntity: User,
methods: {
@ -49,7 +53,12 @@ generateBaseWaspFile newProjectDetails = (path, content)
onAuthFailedRedirectTo: "/login",
onAuthSucceededRedirectTo: "/"
},
|]
|],
[ "Generate a User entity, with at least the following fields: 'id', 'username', 'password'.",
"There should be a page with route path \"/\"."
]
)
planRules = authPlanRules <> ["Don't generate the Login or Signup page."]
content =
[trimming|
app ${appName} {
@ -60,12 +69,6 @@ generateBaseWaspFile newProjectDetails = (path, content)
${appAuth}
}
entity User {=psl
id Int @id @default(autoincrement())
username String @unique
password String
psl=}
route LoginRoute { path: "/login", to: LoginPage }
page LoginPage {
component: import Login from "@client/Login.jsx"

View File

@ -33,6 +33,8 @@ module Wasp.Util
kebabToCamelCase,
maybeToEither,
whenM,
naiveTrimJSON,
textToLazyBS,
)
where
@ -41,6 +43,7 @@ import Control.Monad (unless, when)
import qualified Crypto.Hash.SHA256 as SHA256
import qualified Data.Aeson as Aeson
import qualified Data.ByteString as B
import qualified Data.ByteString.Lazy as BSL
import qualified Data.ByteString.UTF8 as BSU
import Data.Char (isSpace, isUpper, toLower, toUpper)
import qualified Data.HashMap.Strict as M
@ -48,8 +51,11 @@ import Data.List (intercalate)
import Data.List.Split (splitOn, wordsBy)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text as Text
import qualified Data.Text.Encoding as TextEncoding
import qualified Data.Text.Lazy as TL
import qualified Data.Text.Lazy.Encoding as TLE
import StrongPath (File, Path')
import qualified StrongPath as SP
import Text.Printf (printf)
@ -230,3 +236,15 @@ maybeToEither leftValue = maybe (Left leftValue) Right
getEnvVarDefinition :: (String, String) -> String
getEnvVarDefinition (name, value) = concat [name, "=", value]
-- | Given a text containing a single instance of JSON and some text around it but no { or }, trim
-- it until just JSON is left.
-- Examples
-- naiveTrimJson "some text { \"a\": 5 } yay" == "{\"a\": 5 }"
-- naiveTrimJson "some {text} { \"a\": 5 }" -> won't work correctly.
naiveTrimJSON :: Text -> Text
naiveTrimJSON textContainingJson =
T.reverse . T.dropWhile (/= '}') . T.reverse . T.dropWhile (/= '{') $ textContainingJson
textToLazyBS :: Text -> BSL.ByteString
textToLazyBS = TLE.encodeUtf8 . TL.fromStrict

View File

@ -372,8 +372,10 @@ library cli-lib
Wasp.Cli.FileSystem
Wasp.Cli.Command.AI.CodeAgent
Wasp.Cli.Command.AI.GenerateNewProject
Wasp.Cli.Command.AI.GenerateNewProject.Action
Wasp.Cli.Command.AI.GenerateNewProject.Common
Wasp.Cli.Command.AI.GenerateNewProject.Common.Prompts
Wasp.Cli.Command.AI.GenerateNewProject.Entity
Wasp.Cli.Command.AI.GenerateNewProject.Plan
Wasp.Cli.Command.AI.GenerateNewProject.Skeleton
Wasp.Cli.Command.AI.New