>
);
};
```
:::info
In the `auth` object on the `main.wasp` file, we used the `usernameAndPassword` method which is the simplest form of auth Wasp offers. If youāre interested, [Wasp](https://wasp-lang.dev/docs) does provide abstractions for Google, Github, and Email Verified Authentication, but we will stick with the simplest auth for this tutorial.
:::
With authentication all set up, if we try to go to [localhost:3000](http://localhost:3000) we will be automatically directed to the login/register form.
Youāll see that Wasp creates Login and Signup forms for us because of the `auth` object we defined in the `main.wasp` file. Sweet! š
But even though weāve added some style classes, we havenāt set up any css styling so it will probably be pretty ugly right about now.
š¤¢Ā Barf.
![Untitled](../static/img/build-your-own-twitter-agent/Untitled%203.png)
### Adding Tailwind CSS
Luckily, Wasp comes with tailwind css support, so all we have to do to get that working is add the following files in the root directory of the project:
```bash
.
āāā main.wasp
āāā src
ā āāā client
ā āāā server
ā āāā shared
āāā postcss.config.cjs # add this file here
āāā tailwind.config.cjs # and this here too
āāā .wasproot
```
`postcss.config.cjs`
```jsx
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
```
`tailwind.config.cjs`
```jsx
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{js,jsx,ts,tsx}'],
theme: {
extend: {},
},
plugins: [],
};
```
Finally, replace the contents of your `src/client/Main.css` file with these lines:
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
```
Now weāve got the magic of [tailwind css](https://tailwindcss.com/) on our sides! šØĀ Weāll get to styling later though. Patience, young grasshopper.
### Adding Notes Client-side
From here, letās create the complimentary client-side components for adding notes to the vector store. Create a new `.src/client/AddNote.tsx` file with the following contents:
```tsx
import { useState } from 'react';
import embedIdea from '@wasp/actions/embedIdea';
export default function AddNote() {
const [idea, setIdea] = useState('');
const [isIdeaEmbedding, setIsIdeaEmbedding] = useState(false);
const handleEmbedIdea = async (e: any) => {
try {
setIsIdeaEmbedding(true);
if (!idea) {
throw new Error('Idea cannot be empty');
}
const embedIdeaResponse = await embedIdea({
idea,
});
console.log('embedIdeaResponse: ', embedIdeaResponse);
} catch (error: any) {
alert(error.message);
} finally {
setIdea('');
setIsIdeaEmbedding(false);
}
};
return (
);
}
```
Here weāre using the `embedIdea` action we defined earlier to add our idea to the vector store. Weāre also using the `useState` hook to keep track of the idea weāre adding, as well as the loading state of the button.
So now we have a way to add our own ideas and notes to our vector store. Pretty sweet!
## Generating New Ideas & Tweet Drafts
### Using LangChain's Sequential Chains
To pull the tweets from our favorite trend-setting twitter users, weāll be using a package, `Rettiwt`, that pulls the information from the unauthenticated public twitter page. This is perfect for our purposes since we only want to fetch tweets from our selected twitter "trend-setters" and don't need to interact with the official twitter API.
To achieve this, we will perform the following steps:
1. define a function that uses LangChain to initiate a āchainā of API calls to OpenAIās ChatGPT completions endpoint.
1. this function takes a tweet that we pulled from one of our favorite twitter users as an argument, searches our vector store for similar notes & ideas, and returns a list of new ābrainstormedā based on the example tweet and our notes.
2. define a new action that loops through our favorite users array, pulls their most recent tweets, and sends them to our LangChain function mentioned above
So letās start again by creating our LangChain function. Make a new `src/server/chain.ts` file:
```tsx
import { ChatOpenAI } from 'langchain/chat_models/openai';
import { LLMChain, SequentialChain } from 'langchain/chains';
import { PromptTemplate } from 'langchain/prompts';
import { PineconeStore } from 'langchain/vectorstores/pinecone';
import { OpenAIEmbeddings } from 'langchain/embeddings/openai';
import { PineconeClient } from '@pinecone-database/pinecone';
const pinecone = new PineconeClient();
export const initPinecone = async () => {
await pinecone.init({
environment: process.env.PINECONE_ENV!,
apiKey: process.env.PINECONE_API_KEY!,
});
return pinecone;
};
const embeddings = new OpenAIEmbeddings({
openAIApiKey: process.env.OPENAI_API_KEY,
});
export const generateIdeas = async (exampleTweet: string, username: string) => {
try {
// remove quotes and curly braces as not to confuse langchain template parser
exampleTweet = exampleTweet.replace(/"/g, '');
exampleTweet = exampleTweet.replace(/{/g, '');
exampleTweet = exampleTweet.replace(/}/g, '');
const pinecone = await initPinecone();
console.log('list indexes', await pinecone.listIndexes());
// find the index we created earlier
const pineconeIndex = pinecone.Index('embeds-test');
const vectorStore = new PineconeStore(embeddings, {
pineconeIndex: pineconeIndex,
namespace: username,
});
//
// sequential tweet chain begin --- >
//
/**
* vector store results for notes similar to the original tweet
*/
const searchRes = await vectorStore.similaritySearchWithScore(exampleTweet, 2);
console.log('searchRes: ', searchRes);
let notes = searchRes
.filter((res) => res[1] > 0.7) // filter out strings that have less than %70 similarity
.map((res) => res[0].pageContent)
.join(' ');
console.log('\n\n similarity search results of our notes-> ', notes);
if (!notes || notes.length <= 2) {
notes = exampleTweet;
}
const tweetLlm = new ChatOpenAI({
openAIApiKey: process.env.OPENAI_API_KEY,
temperature: 0.8, // 0 - 2 with 0 being more deterministic and 2 being most "loose". Past 1.3 the results tend to be more incoherent.
modelName: 'gpt-3.5-turbo',
});
const tweetTemplate = `You are an expert idea generator. You will be given a user's notes and your goal is to use this information to brainstorm other novel ideas.
Notes: {notes}
Ideas Brainstorm:
-`;
const tweetPromptTemplate = new PromptTemplate({
template: tweetTemplate,
inputVariables: ['notes'],
});
const tweetChain = new LLMChain({
llm: tweetLlm,
prompt: tweetPromptTemplate,
outputKey: 'newTweetIdeas',
});
const interestingTweetTemplate = `You are an expert interesting tweet generator. You will be given some tweet ideas and your goal is to choose one, and write a tweet based on it. Structure the tweet in an informal yet serious tone and do NOT include hashtags in the tweet!
Tweet Ideas: {newTweetIdeas}
Interesting Tweet:`;
const interestingTweetLlm = new ChatOpenAI({
openAIApiKey: process.env.OPENAI_API_KEY,
temperature: 1.1,
modelName: 'gpt-3.5-turbo',
});
const interestingTweetPrompt = new PromptTemplate({
template: interestingTweetTemplate,
inputVariables: ['newTweetIdeas'],
});
const interestingTweetChain = new LLMChain({
llm: interestingTweetLlm,
prompt: interestingTweetPrompt,
outputKey: 'interestingTweet',
});
const overallChain = new SequentialChain({
chains: [tweetChain, interestingTweetChain],
inputVariables: ['notes'],
outputVariables: ['newTweetIdeas', 'interestingTweet'],
verbose: false,
});
type ChainDraftResponse = {
newTweetIdeas: string;
interestingTweet: string;
notes: string;
};
const res1 = (await overallChain.call({
notes,
})) as ChainDraftResponse;
return {
...res1,
notes,
};
} catch (error: any) {
throw new Error(error);
}
};
```
Great! Let's run through the above code real quick:
1. Initialize the Pinecone client
2. Find our pinecone index (i.e. table) that we created earlier and initialize a new PineconeStore with LangChain
3. Search our vector store for notes similar to the example tweet, filtering out any results that have less than %70 similarity
4. Create a new ChatGPT completion chain that takes our notes as input and generates new tweet ideas
5. Create a new ChatGPT completion chain that takes the new tweet ideas as input and generates a new tweet draft
6. Create a new SequentialChain and combine the above two chains together so that we can pass it our notes as input and it returns the new tweet ideas and the new tweet draft as output
:::tip VECTOR COSINE SIMILARITY SCORES
A good similarity threshold for cosine similarity search on text strings depends on the specific application and the desired level of strictness in matching. Cosine similarity scores range between 0 and 1, with 0 meaning no similarity and 1 meaning completely identical text strings.
- 0.8-0.9 = strict
- 0.6-0.8 = moderate
- 0.5 = relaxed.
In our case, we went for a moderate similarity threshold of 0.7, which means that we will only return notes that are at least 70% similar to the example tweet.
:::
With this function, we will get our `newTweetIdeas` and our `interestingTweet` draft back as results that we can use within our server-side action.
### Server Action
Now letās define that action in our `main.wasp` file
```tsx
// actions...
action generateNewIdeas {
fn: import { generateNewIdeas } from "@server/ideas.js",
entities: [GeneratedIdea, Tweet, TweetDraft, User]
}
```
ā¦and then create that action within `src/server/ideas.ts`
```tsx
import type {
EmbedIdea,
GenerateNewIdeas // < ---- add this type here -----
} from '@wasp/actions/types';
// ... other imports ...
import { generateIdeas } from './chain.js'; // < ---- this too -----
import { Rettiwt } from 'rettiwt-api'; // < ---- and this here -----
const twitter = Rettiwt(); // < ---- and this -----
//... other stuff ...
export const generateNewIdeas: GenerateNewIdeas = async (_args, context) => {
try {
// get the logged in user that Wasp passes to the action via the context
const user = context.user
if (!user) {
throw new HttpError(401, 'User is not authorized');
}
for (let h = 0; h < user.favUsers.length; h++) {
const favUser = user.favUsers[h];
const userDetails = await twitter.users.getUserDetails(favUser);
const favUserTweets = await twitter.users.getUserTweets(userDetails.id);
// filter out retweets
let favUserTweetTexts = favUserTweets.list.filter((tweet) => !tweet.fullText.startsWith('RT'));
favUserTweetTexts = favUserTweetTexts.filter((tweet) => {
// filter out tweets that were created more than 24 hours ago
const createdAt = new Date(tweet.createdAt); // createdAt: 'Wed May 24 03:41:53 +0000 2023'
const now = new Date();
const twelveHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
return createdAt > twelveHoursAgo;
});
const authorUsername = userDetails.userName
for (let i = 0; i < favUserTweetTexts.length; i++) {
const tweet = favUserTweetTexts[i];
const existingTweet = await context.entities.User.findFirst({
where: {
id: user.id,
},
select: {
originalTweets: {
where: {
tweetId: tweet.id,
},
},
},
});
/**
* If the tweet already exists in the database, skip generating drafts and ideas for it.
*/
if (existingTweet) {
console.log('tweet already exists in db, skipping generating drafts...');
continue;
}
/**
* this is where the magic happens
*/
const draft = await generateIdeas(tweet.fullText, user.username);
console.log('draft: ', draft);
const originalTweet = await context.entities.Tweet.create({
data: {
tweetId: tweet.id,
content: tweet.fullText,
authorUsername: userDetails.userName,
tweetedAt: new Date(tweet.createdAt),
userId: user.id
},
});
let newTweetIdeas = draft.newTweetIdeas.split('\n');
newTweetIdeas = newTweetIdeas
.filter((idea) => idea.trim().length > 0)
.map((idea) => {
// remove all dashes that are not directly followed by a letter
idea = idea.replace(/-(?![a-zA-Z])/g, '');
idea = idea.replace(/"/g, '');
idea = idea.replace(/{/g, '');
idea = idea.replace(/}/g, '');
// remove hashtags and the words that follow them
idea = idea.replace(/#[a-zA-Z0-9]+/g, '');
idea = idea.replace(/^\s*[\r\n]/gm, ''); // remove new line breaks
idea = idea.trim();
// check if last character contains punctuation and if not add a period
if (idea.length > 1 && !idea[idea.length - 1].match(/[.,\/#!$%\^&\*;:{}=\-_`~()]/g)) {
idea += '.';
}
return idea;
});
for (let j = 0; j < newTweetIdeas.length; j++) {
const newTweetIdea = newTweetIdeas[j];
const newIdea = await context.entities.GeneratedIdea.create({
data: {
content: newTweetIdea,
originalTweetId: originalTweet.id,
userId: user.id
},
});
console.log('newIdea saved to DB: ', newIdea);
}
const interestingTweetDraft = await context.entities.TweetDraft.create({
data: {
content: draft.interestingTweet,
originalTweetId: originalTweet.id,
notes: draft.notes,
userId: user.id
},
});
console.log('interestingTweetDraft saved to DB: ', interestingTweetDraft);
// create a delay to avoid rate limiting
await new Promise((resolve) => setTimeout(resolve, 1000));
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
} catch (error: any) {
console.log('error', error);
throw new HttpError(500, error);
}
}
```
Ok! Nice work. Thereās a lot going on above, so letās just recap:
- We loop through the array of our favorite users, as defined on our user entity in `main.wasp`,
- Pull that userās most recent tweets
- Send that tweet to our `generateIdeas` function, which
- searches our vector store for similar notes
- asks GPT to generate similar, new ideas
- sends those ideas in another prompt GPT to create a new, interesting tweet
- returns the new ideas and interesting tweet
- Create new `GeneratedIdeas` and a `TweetDraft` and saves them to our Postgres DB
Phew! Weāre doing it šŖĀ
## Fetching & Displaying Ideas
### Defining a Server-side Query
Since we now have our chain of GPT prompts defined via LangChain and our server-side action, letās go ahead and start implementing some front-end logic to fetch that data and display it to our usersā¦ which is basically only us at this point š«.
Just as we added a server-side action to `generateNewIdeas` we will now define a query to fetch those ideas.
Add the following query to your `main.wasp` file:
```tsx
query getTweetDraftsWithIdeas {
fn: import { getTweetDraftsWithIdeas } from "@server/ideas.js",
entities: [TweetDraft]
}
```
In your `src/server/ideas.ts` file, below your `generateNewIdeas` action, add the query we just defined in our wasp file:
```tsx
//... other imports ...
import type { GetTweetDraftsWithIdeas } from '@wasp/queries/types'; // <--- add this ---
// ... other functions ...
type TweetDraftsWithIdeas = {
id: number;
content: string;
notes: string;
createdAt: Date;
originalTweet: {
id: number;
content: string;
tweetId: string;
tweetedAt: Date;
ideas: GeneratedIdea[];
authorUsername: string;
};
}[];
export const getTweetDraftsWithIdeas: GetTweetDraftsWithIdeas = async (_args, context) => {
if (!context.user) {
throw new HttpError(401, 'User is not authorized');
}
const drafts = await context.entities.TweetDraft.findMany({
orderBy: {
originalTweet: {
tweetedAt: 'desc',
}
},
where: {
userId: context.user.id,
createdAt: {
gte: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), // Get drafts created within the last 2 days
},
},
select: {
id: true,
content: true,
notes: true,
createdAt: true,
originalTweet: {
select: {
id: true,
tweetId: true,
content: true,
ideas: true,
tweetedAt: true,
authorUsername: true,
},
},
},
});
return drafts;
};
```
With this function we will be returning the tweet drafts we generate, along with our notes, the original tweet that inspired it, and the newly generated ideas.
Sweet!
Ok, but what good is a function that fetches the data if weāve got nowhere to display it!?
### Displaying Ideas Client-side
Letās go now to our `src/client/MainPage.tsx` file (make sure itās got the `.tsx` extension and not `.jsx`) and replace the contents with these below:
```tsx
import waspLogo from './waspLogo.png'
import './Main.css'
const MainPage = () => {
return (
)
}
export default MainPage
```
At this point, you. might need to restart the wasp dev server running in your terminal to get the tailwind configuration to take effect (ctrl + c, then `wasp start` again).
Youāll now be prompted with the login / register screen. Go ahead and click on `register` and you will be automatically logged in and redirected to the main page, which at this point only has this:
![Untitled](../static/img/build-your-own-twitter-agent/Untitled%204.png)
Letās go back to our `MainPage.tsx` file and add the magic!
![https://media3.giphy.com/media/ekv45izCuyXkXoHRaL/giphy.gif?cid=7941fdc6c3dszwj4xaoxg2kyj6xxdubjxn69m4qruhomhkut&ep=v1_gifs_search&rid=giphy.gif&ct=g](https://media3.giphy.com/media/ekv45izCuyXkXoHRaL/giphy.gif?cid=7941fdc6c3dszwj4xaoxg2kyj6xxdubjxn69m4qruhomhkut&ep=v1_gifs_search&rid=giphy.gif&ct=g)
First, letās create a buttons component so we donāt have to constantly style a new button. Create a new `src/client/Button.tsx` file:
```tsx
import { ButtonHTMLAttributes } from 'react';
interface ButtonProps extends ButtonHTMLAttributes {
isLoading?: boolean;
}
export default function Button({ isLoading, children, ...otherProps }: ButtonProps) {
return (
);
}
```
Now letās add it to your `AddNote.tsx` component, replacing the original button with this one. The whole file should look like this:
```tsx
import { useState } from 'react';
import embedIdea from '@wasp/actions/embedIdea';
import Button from './Button';
export default function AddNote() {
const [idea, setIdea] = useState('');
const [isIdeaEmbedding, setIsIdeaEmbedding] = useState(false);
const handleEmbedIdea = async (e: any) => {
try {
setIsIdeaEmbedding(true);
if (!idea) {
throw new Error('Idea cannot be empty');
}
const embedIdeaResponse = await embedIdea({
idea,
});
console.log('embedIdeaResponse: ', embedIdeaResponse);
} catch (error: any) {
alert(error.message);
} finally {
setIdea('');
setIsIdeaEmbedding(false);
}
};
return (
);
}
```
Noice.
Next, we want our page to perform the following actions:
1. create a button that runs our `generateNewIdeas` action when clicked
2. define the query that fetches and caches the tweet drafts and ideas
3. loop through the results and display them on the page
Thatās exactly what the below code will do. Go ahead and replace the `MainPage` with it and take a minute to review whatās going on:
```tsx
import waspLogo from './waspLogo.png';
import './Main.css';
import { useState } from 'react';
import generateNewIdeas from '@wasp/actions/generateNewIdeas';
import { useQuery } from '@wasp/queries';
import getTweetDraftsWithIdeas from '@wasp/queries/getTweetDraftsWithIdeas';
import AddNote from './AddNote';
import Button from './Button';
const MainPage = () => {
const [isGenerating, setIsGenerating] = useState(false);
const {
data: tweetDrafts,
isLoading: isTweetDraftsLoading,
error: tweetDraftsError,
} = useQuery(getTweetDraftsWithIdeas);
const handleNewIdeas = async (e: any) => {
try {
setIsGenerating(true);
await generateNewIdeas();
} catch (error: any) {
alert(error.message);
} finally {
setIsGenerating(false);
}
};
if (isTweetDraftsLoading) {
return 'Loading...';
}
if (tweetDraftsError) {
return 'Error: ' + tweetDraftsError.message;
}
return (
);
};
export default MainPage;
```
This is what you should see on the homepage now! š
![Untitled](../static/img/build-your-own-twitter-agent/Untitled%205.png)
But, if you clicked āgenerate new ideasā and nothing happened, well thatās because we havenāt defined any favorite trend-setting twitter users to scrape tweets from. And thereās no way to do that from the UI at the moment, so letās open up the database manager and add some manually.
In a new terminal tab, in the root of your project, run:
```bash
wasp db studio
```
Then, in a new browswer tab, at [localhost:5555](http://localhost:5555) you should see your database.
Go to `user`, and you should be the only user in there. Add the usernames of a couple of your favorite trend-setting twitter users.
![Untitled](../static/img/build-your-own-twitter-agent/Untitled%206.png)
Make sure the accounts have tweeted recently or your function wonāt be able to scrape or generate anything!
:::tip Hey ā
While youāre at it, if youāre liking this tutorial, give me a [follow @hot_town](https://twitter.com/hot_town) for more future content like this
:::
After adding the twitter usernames, make sure you click `save 1 change`.
Go back to your client and click the `Generate New Ideas` button again. This might take a while depending on how many tweets itās generating ideas for, so be patient ā and watch the console output in your terminal if youāre curious ;)
![Untitled](../static/img/build-your-own-twitter-agent/Untitled%207.png)
Awesome! Now we should be getting back some generated ideas from our twitter āinternā which will help us brainstorm further notes and generate our own BANGER TWEETS.
But it would be cool to also display the tweet these ideas are referencing from the beginning. That way weād have a bit more context on where the ideas came from.
Letās do that then! In your `MainPage` file, at the very top, add the following import:
```tsx
import { TwitterTweetEmbed } from 'react-twitter-embed';
```
This allows us to embed tweets with that nice twitter styling.
We already added this dependency to our `main.wasp` file at the beginning of the tutorial, so we can just import and start embedding tweets.
Letās try it out now in our `MainPage` by adding the following snippet above our `
Tweet Draft
` element:
```tsx
//...
Original Tweet
Tweet Draft
//...
```
Great. Now we should be sitting pretty š»
![Untitled](../static/img/build-your-own-twitter-agent/Untitled%208.png)
You might remember from the beginning of the tutorial when we defined the LLM calls, that if your vector store notes donāt turn back a cosine similarity of at least 0.7, your agent will generate its own ideas entirely without using your notes as a guide.
And since we have NO notes in our vector store at the moment, thatās exactly what it is doing. Which is fine, because we can let it brainstorm for us, and we can select our favorite notes and edit and add them as we see fit.
So you can go ahead and start adding notes whenever you feel like it š.
But, weāve added our favorite twitter users to the database manually. It would be preferable to do it via an account settings page, right? Letās make one then.
### Creating an Account Settings Page
First, add the route and page to your `main.wasp` config file, under the other routes:
```tsx
//...
route AccountRoute { path: "/account", to: AccountPage }
page AccountPage {
authRequired: true,
component: import Account from "@client/AccountPage"
}
```
Next, letās create a new page, `src/client/AccountPage.tsx`:
```tsx
import Button from './Button';
import { ChangeEvent, useEffect, useState } from 'react';
import logout from '@wasp/auth/logout';
import type { User } from '@wasp/entities';
const AccountPage = ({ user }: { user: User }) => {
return (
{JSON.stringify(user, null, 2)}
);
};
export default AccountPage;
```
When you navigate to [localhost:3000/account](localhost:3000/account), youāll notice two things, one of them being a logout button. You can see in our `SettingsPage` above that we imported a Wasp-provided `logout` function. We get this āfor freeā since we defined our `auth` strategy in the `main.wasp` file ā a big time-saver!
![Untitled](../static/img/build-your-own-twitter-agent/Untitled%209.png)
Because we also defined the `AccountPage` route with the `authRequired: true` property, Wasp will automatically pass the logged in user as a prop argument to our page. We can use the user object to display and update our `favUsers`, just as we can see in the image above.
To do that, letās define a new `updateAccount` action in our `main.wasp` file:
```tsx
action updateAccount {
fn: import { updateAccount } from "@server/account.js",
entities: [User]
}
```
Next, letās create the `updateAccount` action in a new file, `src/server/account.ts`:
```tsx
import type { UpdateAccount } from "@wasp/actions/types";
import HttpError from "@wasp/core/HttpError.js";
export const updateAccount: UpdateAccount<{ favUsers: string[] }, void> = async ({ favUsers }, context) => {
if (!context.user) {
throw new HttpError(401, "User is not authorized");
}
try {
await context.entities.User.update({
where: { id: context.user.id },
data: { favUsers },
});
} catch (error: any) {
throw new HttpError(500, error.message);
}
}
```
Right. Now itās time to put it all together in our `Account` page. Weāre going to create a form for adding new twitter users to scrape tweets from, so at the bottom of your `src/client/AccountPage.tsx`, below your other code, add the following component:
```tsx
function InputFields({ user }: { user: User }) {
const [isLoading, setIsLoading] = useState(false);
const [fields, setFields] = useState(['']);
useEffect(() => {
if (user?.favUsers.length > 0) {
setFields(user.favUsers);
}
}, [user?.favUsers]);
const handleAdd = () => {
setFields([...fields, '']);
};
const handleRemove = () => {
const newFields = [...fields];
newFields.splice(fields.length - 1, 1);
setFields(newFields);
};
const handleChange = (e: ChangeEvent, index: number) => {
const newFields = [...fields];
newFields[index] = e.target.value;
setFields(newFields);
};
const handleSubmit = async () => {
//...
};
return (
);
}
```
This component takes care of adding the logged in userās `favUsers` array to state, and displaying that in information in a set of input components.
The only thing missing from it is to add our `updateAccount` action we just defined earlier. So at the top of the file, letās import it and add the logic to our `InputFields` submit handler
```tsx
import updateAccount from '@wasp/actions/updateAccount'; // <--- add this import
//...
const handleSubmit = async () => { // < --- add this function
try {
setIsLoading(true);
await updateAccount({ favUsers: fields });
} catch (err: any) {
alert(err.message);
} finally {
setIsLoading(false);
}
};
```
Also, in your `AccountPage` make sure to replace the line `{JSON.stringify(user, null, 2)}` with the newly created component ``.
Here is what the entire `AccountPage.tsx` file should now look like in case you get stuck:
```tsx
import Button from './Button';
import { ChangeEvent, useEffect, useState } from 'react';
import logout from '@wasp/auth/logout';
import type { User } from '@wasp/entities';
import updateAccount from '@wasp/actions/updateAccount'
const AccountPage = ({ user }: { user: User }) => {
return (
);
}
```
And hereās what your `AccountPage` should look like when navigating to [localhost:3000/account](http://localhost:3000/account) (note: the styling may be a bit ugly, but weāll take care of that later):
![Untitled](../static/img/build-your-own-twitter-agent/Untitled%2010.png)
Fantastic. So weāve got the majority of the app logic finished ā our own personal twitter āinternā to help us all become thought leaders and thread bois š¤£.
### Adding a Cron Job
But wouldnāt it be cool if we could automate the `Generate New Ideas` process? Each time you click the button, it takes quite a while for tweets to be scraped, and ideas to be generated, especially if we are generating ideas for a lot of new tweets.
So it would be nicer if we had a cron job (recurring task), that ran automatically in the background at a set interval.
With Wasp, thatās also super easy to set up. To do so, letās go to our `main.wasp` file and add our job at the very bottom:
```tsx
//...
job newIdeasJob {
executor: PgBoss,
perform: {
fn: import generateNewIdeasWorker from "@server/worker/generateNewIdeasWorker.js"
},
entities: [User, GeneratedIdea, Tweet, TweetDraft],
schedule: {
// run cron job every 30 minutes
cron: "*/30 * * * *",
executorOptions: {
pgBoss: {=json { "retryLimit": 2 } json=},
}
}
}
```
Letās run through the code above:
- Jobs use [pg-boss](https://github.com/timgit/pg-boss), a postgres extension, to queue and run tasks under the hood.
- with `perform` weāre telling the job what function we want it to call: `generateNewIdeasWorker`
- just like actions and queries, we have to tell the job which entities we want to give it access to. In this case, we will need access to all of our entities.
- the schedule allows us to pass some options to pg-boss so that we can make it a recurring task. In this case, I set it to run every 30 minutes, but you can set it to any interval youād like (tip: change the comment and let github co-pilot write the cron for you). We also tell pg-boss to retry a failed job two times.
Perfect. So now, our app will automatically scrape our favorite usersā tweets and generate new ideas for us every 30 minutes. This way, if we revisit the app after a few days, all the content will already be there and we wonāt have to wait a long time for it to generate it for us. We also make sure we never miss out on generating ideas for older tweets.
But for that to happen, we have to define the function our job will call. To do this, create a new directory `worker` within the `server` folder, and within it a new file: `src/server/worker/generateNewIdeasWorker`
```tsx
import { generateNewIdeas } from '../ideas.js';
export default async function generateNewIdeasWorker(_args: unknown, context: any) {
try {
console.log('Running recurring task: generateNewIdeasWorker')
const allUsers = await context.entities.User.findMany({});
for (const user of allUsers) {
context.user = user;
console.log('Generating new ideas for user: ', user.username);
await generateNewIdeas(undefined as never, context);
console.log('Done generating new ideas for user: ', user.username)
}
} catch (error: any) {
console.log('Recurring task error: ', error);
}
}
```
In this file, all weāre doing is looping through all the users in our database, and passing them via the context object to our `generateNewIdeas` action. The nice thing about jobs is that Wasp automatically passes the `context` object to these functions, which we can then pass along to our action.
So now, at the interval that you set (e.g. 30 minutes), you should notice the logs being printed to the console whenever your job starts automatically running.
```bash
[Server] Generating new ideas for user: vinny
```
Alright, things are looking pretty good now, but letās not forget to add a page to view all the notes we added and embedded to our vector store!
### Adding a Notes Page
Go ahead and add the following route to your `main.wasp` file:
```tsx
route NotesPage { path: "/notes", to: NotesPage }
page NotesPage {
authRequired: true,
component: import Notes from "@client/NotesPage"
}
```
Create the complementary page, `src/client/NotesPage.tsx` and add the following boilerplate just to get started (weāll add the rest later):
```tsx
const NotesPage = () => {
return (
<>Notes>
);
};
export default NotesPage;
```
It would be nice if we had a simple Nav Bar to navigate back and forth between our two pages. It would also be cool if we had our `` input component on all pages, that way itās easy for us to add an idea whenever inspiration strikes.
Rather than copying the NavBar and AddNote code to both pages, letās create a wrapper, or ārootā, component for our entire app so that all of our pages have the same Nav Bar and layout.
To do that, in our `main.wasp` file, letās define our root component by adding a `client` property to our `app` configuration at the very top of the file. This is how the entire `app` object should look like now:
```tsx lines={6-8}
app twitterAgent {
wasp: {
version: "^0.10.6"
},
title: "twitter-agent",
client: {
rootComponent: import App from "@client/App",
},
db: {
system: PostgreSQL,
},
auth: {
userEntity: User,
onAuthFailedRedirectTo: "/login",
methods: {
usernameAndPassword: {},
}
},
dependencies: [
("openai", "3.2.1"),
("rettiwt-api", "1.1.8"),
("langchain", "0.0.91"),
("@pinecone-database/pinecone", "0.1.6"),
("@headlessui/react", "1.7.15"),
("react-icons", "4.8.0"),
("react-twitter-embed", "4.0.4")
],
}
// entities, operations, routes, and other stuff...
```
Next, create a new file `src/client/App.tsx` with the following content:
```tsx
import './Main.css';
import AddNote from './AddNote';
import { ReactNode } from 'react';
import useAuth from '@wasp/auth/useAuth';
const App = ({ children }: { children: ReactNode }) => {
const { data: user } = useAuth();
return (
);
};
export default App;
```
With this defined, Wasp will know to pass all other routes as `children` through our `App` component. That way, we will always show the Nav Bar and `AddNote` component on the top of every page.
We also take advantage of Waspās handy `useAuth` hook to check if a user is logged in, and if so we show the `AddNote` component.
Now, we can delete the duplicate code on our `MainPage`. This is what it should look like now:
```tsx
import { useState } from 'react';
import generateNewIdeas from '@wasp/actions/generateNewIdeas';
import { useQuery } from '@wasp/queries';
import getTweetDraftsWithIdeas from '@wasp/queries/getTweetDraftsWithIdeas';
import Button from './Button';
import { TwitterTweetEmbed } from 'react-twitter-embed';
const MainPage = () => {
const [isGenerating, setIsGenerating] = useState(false);
const {
data: tweetDrafts,
isLoading: isTweetDraftsLoading,
error: tweetDraftsError,
} = useQuery(getTweetDraftsWithIdeas);
const handleNewIdeas = async (e: any) => {
try {
setIsGenerating(true);
await generateNewIdeas();
} catch (error: any) {
alert(error.message);
} finally {
setIsGenerating(false);
}
};
if (isTweetDraftsLoading) {
return 'Loading...';
}
if (tweetDraftsError) {
return 'Error: ' + tweetDraftsError.message;
}
return (
<>
>
);
};
export default MainPage;
```
Next, we need to create a query that allows us to fetch all of our added notes and ideas that have been embedded in our vector store.
For that, we need to define a new `query` in our `main.wasp` file:
```tsx
query getEmbeddedNotes {
fn: import { getEmbeddedNotes } from "@server/ideas.js",
entities: [GeneratedIdea]
}
```
We then need to create that `query` at the bottom of our `src/actions/ideas.ts` file:
```tsx
// first import the type at the top of the file
import type { GetEmbeddedNotes, GetTweetDraftsWithIdeas } from '@wasp/queries/types';
//...
export const getEmbeddedNotes: GetEmbeddedNotes = async (_args, context) => {
if (!context.user) {
throw new HttpError(401, 'User is not authorized');
}
const notes = await context.entities.GeneratedIdea.findMany({
where: {
userId: context.user.id,
isEmbedded: true,
},
orderBy: {
createdAt: 'desc',
},
});
return notes;
}
```
Now letās go back to our `src/client/NotesPage.tsx` and add our query. Our new file will look like this:
```tsx
import { useQuery } from '@wasp/queries';
import getEmbeddedNotes from '@wasp/queries/getEmbeddedNotes';
const NotesPage = () => {
const { data: notes, isLoading, error } = useQuery(getEmbeddedNotes);
if (isLoading)
)}
>
);
};
export default NotesPage;
```
Cool! Now we should be fetching all our embedded notes and ideas, signified by the `isEmbedded` tag in our postgres database. Your Notes page should now look something like this:
![Untitled](../static/img/build-your-own-twitter-agent/Untitled%2011.png)
## You Did it! Your own Twitter Intern š¤
:::info Help me help you
š **If you havenāt yet, please** [star us on GitHub](https://www.github.com/wasp-lang/wasp), especially if you found this useful! If you do, it helps support us in creating more content like this. And if you donātā¦ well, we will deal with it, I guess.
![https://media.giphy.com/media/3oEjHEmvj6yScz914s/giphy.gif](https://media.giphy.com/media/3oEjHEmvj6yScz914s/giphy.gif)
:::
And thatās it! Youāve now got yourself a semi-autonomous twitter brainstorming agent to help inspire new ideas and keep you actively contributing š
Thereās way more you can do with these tools, but this is a great start.
Remember, if you want to see a more advanced version of this app which utilizes the official Twitter API to send tweets, gives you the ability to edit and add generated notes on the fly, has manual similarity search for all your notes, and more, then you can check out the [š„ Banger Tweet Bot š¤](https://github.com/vincanger/banger-tweet-bot).
And, once again, here's the repo for the finished app we built in this tutorial: [Personal Twitter Intern](https://github.com/vincanger/twitter-intern)