使用 LangChain 构建您专属的个人 Twitter 代理 🧠🐦⛓
太长不看
LangChain、ChatGPT 和其他新兴技术使得构建一些真正具有创造性的工具成为可能。
在本教程中,我们将构建一个全栈 Web 应用(LangChainJS、React、NodeJS),它将充当我们自己的私人 Twitter 代理,或者我更喜欢称之为“实习生”。它会记录你的笔记和想法,并利用它们——以及热门 Twitter 用户的推文——为你集思广益,撰写推文草稿!💥
顺便说一下,如果你在教程过程中遇到任何问题,或者想查看我们正在构建的应用程序的完整最终代码库,请访问这里:https://github.com/vincanger/twitter-intern
开始之前
Wasp = }是唯一开源的、完全基于服务器的全栈 React/Node 框架,它内置编译器,可让您在一天内构建应用程序并使用单个 CLI 命令进行部署。
我们正在努力帮助您尽可能轻松地构建高性能的 Web 应用程序——包括制作这些教程,这些教程每周都会发布!
如果您能帮忙在 GitHub 上给我们的代码仓库点个星标,我们将万分感激:https://www.github.com/wasp-lang/wasp 🙏

就连 Ron 都会在 GitHub 上给 Wasp点赞🤩
背景
Twitter 是一个很棒的营销工具,也是探索想法和完善自身想法的绝佳途径。但要坚持发推文可能很耗时也很困难。
基于以上假设,我决定使用LangChain构建我自己的个人 Twitter 代理:
🧠 LLM(像 ChatGPT 这样的)虽然不是最好的作家,但他们非常擅长集思广益,提出新想法。
📊 某些推特用户在某些细分领域内主导着大部分讨论,即潮流引领者影响着当前的讨论内容。
💡 智能体需要上下文才能生成与你和你的观点相关的想法,因此它应该能够访问你的笔记、想法、推文等。
因此,与其尝试构建一个完全自主的代理来替你发推文,我认为最好是构建一个代理,根据你最喜欢的引领潮流的推特用户以及你自己的想法,为你进行头脑风暴。
想象一下,你有一个实习生负责做繁琐的工作,而你则负责策划!
为了实现这一目标,我们需要利用一些热门的人工智能工具:
- 嵌入和向量数据库
- 大型语言模型(LLM),例如 ChatGPT
- LangChain 和 LLM 调用的连续“链”
嵌入和向量数据库为我们提供了一种强大的方法,可以对我们的笔记和想法进行相似性搜索。
如果您不熟悉相似性搜索,最简单的解释方法是将其与普通的谷歌搜索进行比较。在普通搜索中,搜索短语“老鼠吃奶酪”只会返回包含这两个词组合的结果。但 基于 向量的相似性搜索则不然,它不仅会返回“老鼠吃奶酪”这三个词,还会返回包含“狗”、“猫”、“骨头”和“鱼”等相关词的结果。
你可以看出这为什么如此强大,因为即使我们有不完全匹配但相关的笔记,我们的相似性搜索仍然会返回它们!
例如,如果我们最喜欢的引领潮流的推特用户发布了一条关于 TypeScript 优势的帖子,而我们只有一篇关于“我们最喜欢的 React Hooks”的笔记,我们的相似性搜索仍然很可能会返回这样的结果。这太棒了!
收到这些笔记后,我们可以将它们连同提示一起传递给 ChatGPT 自动补全 API,以生成更多想法。然后,该提示的结果将发送到另一个提示,其中包含生成推文草稿的指令。我们将这些结果保存到 Postgres 关系数据库中。
这种提示“链”本质上就是 LangChain 包名称的由来 🙂
这种方法能让我们获得大量与我们喜爱的潮流引领者推特用户的推文相关的新想法和推文草稿。我们可以浏览这些内容,编辑并保存我们喜欢的想法到“笔记”素材库中,或者直接发布一些推文。
我个人使用这款应用已经有一段时间了,它不仅激发了一些很棒的想法,还能帮助我产生新的想法(即使它产生的一些想法“一般般”),这就是为什么我在导航栏最显眼的位置添加了“添加笔记”功能。
好了,背景介绍就到这里。让我们开始打造你专属的推特实习生吧!🤖
顺便说一下,如果你在学习教程的过程中遇到任何问题,都可以参考本教程的 GitHub 代码库,里面有最终的应用:Twitter Intern GitHub Repo
配置
设置您的 Wasp 项目
我们将把它做成一个全栈的 React/NodeJS Web 应用,所以首先需要进行一些设置。不过别担心,这不会花很长时间,因为我们将使用 Wasp 作为框架。
黄蜂帮我们完成了所有繁重的工作。你马上就会明白我的意思了。
# First, install Wasp by running this in your terminal:
curl -sSL https://get.wasp-lang.dev/installer.sh | sh
# next, create a new project:
wasp new twitter-agent
# cd into the new directory and start the project:
cd twitter-agent && wasp start
太棒了!运行后wasp start,Wasp 将安装所有必要的 npm 包,在 3001 端口启动我们的服务器,并在 3000 端口启动我们的 React 客户端。在浏览器中访问localhost:3000即可查看。
提示💡
您可以安装Wasp vscode 扩展程序,以获得最佳的开发体验。
你会注意到 Wasp 会使用如下所示的文件结构来设置你的全栈应用程序:
.
├── main.wasp # The wasp config file.
└── src
├── client # Your React client code (JS/CSS/HTML) goes here.
├── server # Your server code (Node JS) goes here.
└── shared # Your shared (runtime independent) code goes here.
让我们开始添加一些服务器端代码。
服务器端和数据库实体
首先.env.server在项目根目录下添加一个文件:
# https://platform.openai.com/account/api-keys
OPENAI_API_KEY=
# sign up for a free tier account at https://www.pinecone.io/
PINECONE_API_KEY=
# will be a location, e.g 'us-west4-gcp-free'
PINECONE_ENV=
# We will fill these in later during the Twitter Scraping section
# Twitter details -- only needed once for Rettiwt.account.login() to get the tokens
TWITTER_EMAIL=
TWITTER_HANDLE=
TWITTER_PASSWORD=
# TOKENS -- fill these in after running the getTwitterTokens script in the Twitter Scraping section
KDT=
TWID=
CT0=
AUTH_TOKEN=
我们需要一种方法来存储我们所有的好点子。所以,我们先去Pinecone.io注册一个免费试用账号吧。
在 Pinecone 控制面板中,转到 API 密钥并创建一个新的密钥。复制并粘贴您的Environment密钥API Key到.env.server
对 OpenAI 也进行同样的操作,在https://platform.openai.com/account/api-keys创建账户和密钥。
main.wasp现在,让我们用以下代码替换配置文件(它就像应用程序的“骨架”)的内容。这将为你配置大部分全栈应用程序🤯
app twitterAgent {
wasp: {
version: "^0.10.6"
},
title: "twitter-agent",
head: [
"<script async src='https://platform.twitter.com/widgets.js' charset='utf-8'></script>"
],
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")
],
}
// ### Database Models
entity Tweet {=psl
id Int @id @default(autoincrement())
tweetId String
authorUsername String
content String
tweetedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
userId Int
drafts TweetDraft[]
ideas GeneratedIdea[]
psl=}
entity TweetDraft {=psl
id Int @id @default(autoincrement())
content String
notes String
originalTweet Tweet @relation(fields: [originalTweetId], references: [id])
originalTweetId Int
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
userId Int
psl=}
entity GeneratedIdea {=psl
id Int @id @default(autoincrement())
content String
createdAt DateTime @default(now())
updatedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
userId Int
originalTweet Tweet? @relation(fields: [originalTweetId], references: [id])
originalTweetId Int?
isEmbedded Boolean @default(false)
psl=}
entity User {=psl
id Int @id @default(autoincrement())
username String @unique
password String
createdAt DateTime @default(now())
favUsers String[]
originalTweets Tweet[]
tweetDrafts TweetDraft[]
generatedIdeas GeneratedIdea[]
psl=}
// <<< Client Pages & Routes
route RootRoute { path: "/", to: MainPage }
page MainPage {
authRequired: true,
component: import Main from "@client/MainPage"
}
//...
注意📝
您可能已经注意到{=psl psl=}上面实体中的这种语法。这表示psl括号内的内容实际上是一种不同的语言,在本例中是Prisma Schema Language(Prisma模式语言)。Wasp 底层使用了 Prisma,因此如果您之前使用过 Prisma,应该很容易上手。
如您所见,我们的main.wasp配置文件包含以下内容:
- 依赖关系,
- 认证方法,
- 数据库类型,以及
- 数据库模型(“实体”)
这样一来,我们的应用程序结构基本就确定了,Wasp 将为我们处理大量的配置工作。
数据库设置
但我们仍然需要运行一个 PostgreSQL 数据库。通常这会很麻烦,但使用 Wasp,只需安装并运行Docker Desktop,然后打开另一个单独的终端标签页/窗口,再运行以下命令:
wasp start db
这将启动您的应用程序并将其连接到 Postgres 数据库。无需进行任何其他操作!🤯 只需保持此终端标签页以及 Docker Desktop 打开并在后台运行即可。
在另一个终端标签页中运行:
wasp db migrate-dev
请务必给你的数据库迁移命名。
如果您为了运行此命令而停止了 wasp dev 服务器,请使用以下命令重新启动它wasp start。
此时,我们的应用会将我们引导至localhost:3000/login,但由于我们尚未实现登录界面/流程,因此会看到一个空白屏幕。别担心,我们稍后会解决这个问题。
嵌入想法和笔记
服务器操作
首先,main.wasp我们需要在配置文件中定义一个用于保存笔记和想法的服务器操作。请将以下代码添加到文件末尾:
// main.wasp
//...
// <<< Client Pages & Routes
route RootRoute { path: "/", to: MainPage }
page MainPage {
authRequired: true,
component: import Main from "@client/MainPage"
}
// !!! Actions
action embedIdea {
fn: import { embedIdea } from "@server/ideas.js",
entities: [GeneratedIdea]
}
既然已经确定了操作,我们就来创建它。新建一个文件,.src/server/ideas.ts并添加以下代码:
import type { EmbedIdea } from '@wasp/actions/types';
import type { GeneratedIdea } from '@wasp/entities';
import HttpError from '@wasp/core/HttpError.js';
import { PineconeStore } from 'langchain/vectorstores/pinecone';
import { Document } from 'langchain/document';
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;
};
export const embeddings = new OpenAIEmbeddings({
openAIApiKey: process.env.OPENAI_API_KEY,
});
/**
* Embeds a single idea into the vector store
*/
export const embedIdea: EmbedIdea<{ idea: string }, GeneratedIdea> = async ({ idea }, context) => {
if (!context.user) {
throw new HttpError(401, 'User is not authorized');
}
console.log('idea: ', idea);
try {
let newIdea = await context.entities.GeneratedIdea.create({
data: {
content: idea,
userId: context.user.id,
},
});
if (!newIdea) {
throw new HttpError(404, 'Idea not found');
}
const pinecone = await initPinecone();
// we need to create an index to save the vector embeddings to
// an index is similar to a table in relational database world
const availableIndexes = await pinecone.listIndexes();
if (!availableIndexes.includes('embeds-test')) {
console.log('creating index');
await pinecone.createIndex({
createRequest: {
name: 'embeds-test',
// open ai uses 1536 dimensions for their embeddings
dimension: 1536,
},
});
}
const pineconeIndex = pinecone.Index('embeds-test');
// the LangChain vectorStore wrapper
const vectorStore = new PineconeStore(embeddings, {
pineconeIndex: pineconeIndex,
namespace: context.user.username,
});
// create a document with the idea's content to be embedded
const ideaDoc = new Document({
metadata: { type: 'note' },
pageContent: newIdea.content,
});
// add the document to the vectore store along with its id
await vectorStore.addDocuments([ideaDoc], [newIdea.id.toString()]);
newIdea = await context.entities.GeneratedIdea.update({
where: {
id: newIdea.id,
},
data: {
isEmbedded: true,
},
});
console.log('idea embedded successfully!', newIdea);
return newIdea;
} catch (error: any) {
throw new Error(error);
}
};
提示ℹ️
我们在文件中将操作函数定义main.wasp为来自' @server /ideas.js',但我们却创建了一个新ideas.ts文件。这是怎么回事?!Wasp 内部使用
esnext模块解析,这始终需要指定文件扩展名.js(即生成的 JS 文件中使用的扩展名)。这适用于所有@server导入(以及服务器上的所有文件),但不适用于客户端文件。
太棒了!现在我们有了向矢量数据库添加注释和想法的服务器操作。而且我们甚至不需要自己配置服务器(谢谢 Wasp 🙂)。
让我们退后一步,仔细看看我们刚才编写的代码:
- 我们创建一个新的 Pinecone 客户端,并使用我们的 API 密钥和环境对其进行初始化。
- 我们创建一个新的 OpenAIEmbeddings 客户端,并使用我们的 OpenAI API 密钥对其进行初始化。
- 我们在 Pinecone 数据库中创建了一个新索引来存储我们的向量嵌入。
- 我们创建了一个新的 PineconeStore,它是围绕我们的 Pinecone 客户端和 OpenAIEmbeddings 客户端的 LangChain 包装器。
- 我们创建一个新文档,并将该想法的内容嵌入其中。
- 我们将文档及其 ID 添加到矢量图库中。
- 我们还会更新 Postgres 数据库中的该想法,将其标记为嵌入式。
现在我们要创建客户端添加想法的功能,但您应该记得我们auth在 wasp 配置文件中定义了一个对象。因此,在前端进行任何操作之前,我们需要添加登录功能。
验证
main.wasp让我们通过在文件中添加新的路由和页面定义来快速添加它。
//...
route LoginPageRoute { path: "/login", to: LoginPage }
page LoginPage {
component: import Login from "@client/LoginPage"
}
src/client/LoginPage.tsx…并创建包含以下内容的文件:
import { LoginForm } from '@wasp/auth/forms/Login';
import { SignupForm } from '@wasp/auth/forms/Signup';
import { useState } from 'react';
export default () => {
const [showSignupForm, setShowSignupForm] = useState(false);
const handleShowSignupForm = () => {
setShowSignupForm((x) => !x);
};
return (
<>
{showSignupForm ? <SignupForm /> : <LoginForm />}
<div onClick={handleShowSignupForm} className='underline cursor-pointer hover:opacity-80'>
{showSignupForm ? 'Already Registered? Login!' : 'No Account? Sign up!'}
</div>
</>
);
};
信息ℹ️
在文件auth中的对象中main.wasp,我们使用了usernameAndPasswordWasp提供的最简单的身份验证方法。如果您感兴趣,Wasp也提供了对Google、GitHub和电子邮件验证身份验证的抽象层,但本教程将使用最简单的身份验证方式。
身份验证设置完成后,如果我们尝试访问localhost:3000,我们将自动跳转到登录/注册表单。
你会看到,由于auth我们在main.wasp文件中定义的对象,Wasp 会自动为我们创建登录和注册表单。太棒了!🎉
虽然我们添加了一些样式类,但我们还没有设置任何 CSS 样式,所以现在它可能看起来相当难看。
🤢 恶心。
添加 Tailwind CSS
幸运的是,Wasp 自带 Tailwind CSS 支持,所以我们只需在项目根目录中添加以下文件即可使其正常工作:
.
├── main.wasp
├── src
│ ├── client
│ ├── server
│ └── shared
├── postcss.config.cjs # add this file here
├── tailwind.config.cjs # and this here too
└── .wasproot
postcss.config.cjs
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
tailwind.config.cjs
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{js,jsx,ts,tsx}'],
theme: {
extend: {},
},
plugins: [],
};
最后,将文件中的内容替换src/client/Main.css为以下几行:
@tailwind base;
@tailwind components;
@tailwind utilities;
现在我们有了Tailwind CSS的强大功能!🎨 不过样式方面我们稍后再说。耐心点,小家伙。
客户端添加备注
接下来,我们来创建用于向矢量图库添加注释的配套客户端组件。创建一个新.src/client/AddNote.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 (
<div className='flex flex-row gap-2 justify-center items-end w-full'>
<textarea
autoFocus
onChange={(e) => setIdea(e.target.value)}
value={idea}
placeholder='LLMs are great for brainstorming!'
className='w-full p-4 h-22 bg-neutral-100 border rounded-lg w-full'
/>
<button
onClick={handleEmbedIdea}
className='flex flex-row justify-center items-center bg-neutral-100 hover:bg-neutral-200 border border-neutral-300 font-bold px-3 py-1 text-sm text-blue-500 whitespace-nowrap rounded-lg'
>
{isIdeaEmbedding ? 'Loading...' : 'Save Note'}
</button>
</div>
);
}
这里我们使用embedIdea之前定义的操作将我们的想法添加到向量存储中。我们还使用useState钩子来跟踪我们添加的想法以及按钮的加载状态。
现在我们可以把自己的想法和笔记添加到矢量图库里了。真棒!
生成新想法和推文草稿
使用 LangChain 的序列链
现在我们需要建立 LangChain 非常擅长的 LLM 调用顺序链。
我们将采取以下步骤:
- 定义一个函数,该函数使用 LangChain 发起对 OpenAI 的 ChatGPT 补全端点的 API 调用“链”。
- 该函数接受一条我们从我们最喜欢的推特用户那里收集的推文作为参数,在我们的向量库中搜索类似的笔记和想法,并根据示例推文和我们的笔记返回一个新的“头脑风暴”想法列表。
- 定义一个新的操作,该操作遍历我们收藏的用户数组,提取他们最新的推文,并将它们发送到上面提到的 LangChain 函数。
那么,让我们重新开始,创建 LangChain 函数。新建一个src/server/chain.ts文件:
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);
}
};
太好了!我们快速浏览一下上面的代码:
- 初始化 Pinecone 客户端
- 找到我们之前创建的松果索引(即表),并使用 LangChain 初始化一个新的 PineconeStore。
- 在我们的矢量图库中搜索与示例推文类似的笔记,过滤掉相似度低于 70% 的结果。
- 创建一个新的 ChatGPT 补全链,以我们的笔记作为输入,并生成新的推文创意。
- 创建一个新的 ChatGPT 补全链,该补全链以新的推文想法作为输入,并生成新的推文草稿。
- 创建一个新的 SequentialChain,并将上述两个链合并在一起,以便我们可以将笔记作为输入,它会返回新的推文想法和新的推文草稿作为输出。
向量余弦相似度得分🧐
文本字符串余弦相似度搜索的合适阈值取决于具体应用和所需的匹配严格程度。余弦相似度得分介于 0 和 1 之间,0 表示完全不相似,1 表示文本字符串完全相同。
- 0.8-0.9 = 严格
- 0.6-0.8 = 中等
- 0.5 = 放松状态。
在本例中,我们采用了 0.7 的适中相似度阈值,这意味着我们只会返回与示例推文相似度至少为 70% 的笔记。
通过此功能,我们将获得我们的数据newTweetIdeas和interestingTweet草稿作为结果返回,我们可以在服务器端操作中使用这些结果。
抓取推特数据
在将一个值exampleTweet作为参数传递给我们新创建的顺序链之前,我们需要先获取它!
为此,我们将使用Rettiwt-Api(其实就是 Twitter 的反写)。由于这是一个非官方 API,因此存在一些注意事项:
- 我们需要使用 rettiwt 客户端登录一次我们的 Twitter 账号。我们会通过脚本输出它返回的令牌,并将其保存到
.env.server文件中以备后用。 - 最好使用备用账户进行此操作。如果您没有备用账户,请立即注册一个新账户。
⚠️警告:使用非官方Twitter客户端Rettiwt仅用于演示目的。在实施这些方法之前,务必熟悉Twitter关于数据抓取的政策和规则。任何滥用或误用这些脚本和技术的行为都可能导致您的Twitter帐户受到处罚。对于您因个人使用本教程和/或相关脚本而导致的任何后果,我们概不负责。本教程仅用于学习和教育目的。
接下来,我们在src/server名为 `<path>` 的新文件scripts夹中创建一个名为 `<filename>` 的文件tokens.ts。这将是我们的脚本,我们只会运行一次,目的是为了获取传递给 Rettiwt 客户端所需的令牌。
我们希望避免多次运行此脚本,否则我们的账户可能会被限速。不过这应该不会造成问题,因为一旦我们返回令牌,它们有效期最长可达一年。
所以,请在代码中src/server/scripts/tokens.ts添加以下代码:
import { Rettiwt } from 'rettiwt-api';
/**
* This is a script we can now run from the cli with `wasp db seed`
* IMPORTANT! We only want to run this script once, after which we save the tokens
* in the .env.server file. They should be good for up to a year.
*/
export const getTwitterTokens = async () => {
const tokens = await Rettiwt().account.login(
process.env.TWITTER_EMAIL!,
process.env.TWITTER_HANDLE!,
process.env.TWITTER_PASSWORD!
);
console.log('tokens: ', tokens)
};
.env.server如果您还没有添加,请务必将您的推特登录信息添加到我们的文件中!
太好了。要通过简单的 Wasp CLI 命令运行此脚本,请将其添加到文件顶部对象seeds内的数组中:dbmain.wasp
app twitterAgent {
wasp: {
version: "^0.10.6"
},
//...
db: {
system: PostgreSQL,
seeds: [ // <---------- add this
import { getTwitterTokens } from "@server/scripts/tokens.js",
]
},
//...
太棒了!现在到了有趣的部分 :)
在终端中,进入项目根目录,运行命令wasp db seed,你应该会在终端看到类似这样的令牌输出:
[Db] Running seed: getTwitterTokens
[Db] tokens: { // your tokens... }
将这些标记复制并粘贴到您的.env.server文件中:
# TOKENS -- fill these in after running the getTwitterTokens script in the Twitter Scraping section
KDT='...'
TWID='...'
CT0='...'
AUTH_TOKEN='...'
现在有了这项功能,我们应该能够访问我们最喜欢的引领潮流用户的最新推文,并利用它们来帮助我们集思广益,想出新点子!
服务器操作
好的,我们已经有了获取引领潮流的示例推文所需的令牌,并且我们有一个函数可以运行相似性搜索和 LLM 调用的顺序链。
现在让我们在main.wasp文件中定义一个操作,将所有内容整合在一起:
// actions...
action generateNewIdeas {
fn: import { generateNewIdeas } from "@server/ideas.js",
entities: [GeneratedIdea, Tweet, TweetDraft, User]
}
然后在……内创建该行动src/server/ideas.ts
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 -----
kdt: process.env.KDT!,
twid: process.env.TWID!,
ct0: process.env.CT0!,
auth_token: process.env.AUTH_TOKEN!,
});
//... other stuff ...
export const generateNewIdeas: GenerateNewIdeas<unknown, void> = 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 oneDayFromNow = new Date(Date.now() + 24 * 60 * 60 * 1000);
// convert oneDayFromNow to format YYYY-MM-DD
const endDate = oneDayFromNow.toISOString().split('T')[0];
// find the most recent tweet from the favUser
const mostRecentTweet = await context.entities.Tweet.findFirst({
where: {
authorUsername: favUser,
},
orderBy: {
tweetedAt: 'desc',
},
});
console.log('mostRecentTweet: ', mostRecentTweet)
const favUserTweets = await twitter.tweets.getTweets({
fromUsers: [favUser],
sinceId: mostRecentTweet?.tweetId || undefined, // get tweets since the most recent tweet if it exists
endDate: endDate, // endDate in format YYYY-MM-DD
});
const favUserTweetTexts = favUserTweets.list
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: favUser,
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);
}
}
好的!干得漂亮。上面内容很多,我们来总结一下:
- 我们遍历用户实体中定义的收藏用户数组
main.wasp, - 提取该用户最近的推文
- 将那条推文发送给我们的
generateIdeas函数,该函数- 在我们的矢量图库中搜索类似的音符
- 要求 GPT 生成类似的新想法
- 将这些想法发送到另一个提示 GPT,以创建一条新的、有趣的推文
- 返回新想法和有趣的推文
- 创建新的
GeneratedIdeas数据库TweetDraft并将它们保存到我们的 Postgres 数据库中。
呼!我们成功了💪
获取和展示创意
定义服务器端查询
既然我们现在已经通过 LangChain 定义了 GPT 提示链和服务器端操作,那么接下来就让我们开始实现一些前端逻辑来获取数据并将其显示给我们的用户……而目前基本上只有我们自己 🫂。
就像我们之前添加了一个服务器端操作一样,generateNewIdeas现在我们将定义一个查询来获取这些想法。
将以下查询添加到您的main.wasp文件中:
query getTweetDraftsWithIdeas {
fn: import { getTweetDraftsWithIdeas } from "@server/ideas.js",
entities: [TweetDraft]
}
在你的src/server/ideas.ts文件中,在你的generateNewIdeas操作下方,添加我们刚刚在 wasp 文件中定义的查询:
//... 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<unknown, TweetDraftsWithIdeas> = 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;
};
通过此功能,我们将返回我们生成的推文草稿,以及我们的注释、启发该草稿的原始推文和新产生的想法。
甜的!
好吧,但是如果我们没有地方显示数据,那么获取数据的函数又有什么用呢?!
客户端显示想法
现在打开我们的src/client/MainPage.tsx文件(确保文件.tsx扩展名为 .txt 而不是 .txt .jsx),并将内容替换为以下内容:
import waspLogo from './waspLogo.png'
import './Main.css'
const MainPage = () => {
return (
<div className='min-h-screen bg-neutral-300/70 text-center'>
<div className='flex flex-col justify-center items-center mx-auto pt-12'>
<img src={waspLogo} className='w-5' />
</div>
</div>
)
}
export default MainPage
此时,您可能需要重启终端中运行的 wasp dev 服务器,以使 tailwind 配置生效(按 ctrl + c,然后wasp start再按一次)。
现在您将看到登录/注册页面。点击后register,您将自动登录并跳转到主页,此时主页仅包含以下内容:
让我们回到MainPage.tsx文件,添加一些魔法吧!
首先,我们创建一个按钮组件,这样就不用每次都手动设置新按钮的样式了。创建一个新src/client/Button.tsx文件:
import { ButtonHTMLAttributes } from 'react';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
isLoading?: boolean;
}
export default function Button({ isLoading, children, ...otherProps }: ButtonProps) {
return (
<button
{...otherProps}
className={`flex flex-row justify-center items-center bg-neutral-100 hover:bg-neutral-200 border border-neutral-300 text-blue-500 font-bold px-3 py-1 text-sm rounded-lg ${isLoading ? ' pointer-events-none opacity-70' : 'cursor-pointer'}`}
>
{isLoading? 'Loading...' : children}
</button>
);
}
现在让我们把它添加到你的AddNote.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 (
<div className='flex flex-row gap-2 justify-center items-end w-full'>
<textarea
autoFocus
onChange={(e) => setIdea(e.target.value)}
value={idea}
placeholder='LLMs are great for brainstorming!'
className='w-full p-4 h-22 bg-neutral-100 border rounded-lg w-full'
/>
<Button isLoading={isIdeaEmbedding} onClick={handleEmbedIdea}>
Save Note
</Button>
</div>
);
}
不错。
接下来,我们希望页面执行以下操作:
- 创建一个按钮,
generateNewIdeas点击该按钮即可执行我们的操作。 - 定义用于获取和缓存推文草稿和想法的查询。
- 遍历结果并将其显示在页面上
下面的代码正是实现这个功能。请将代码中的 `<input type="text/json">` 替换为 `<input type="text/json">` MainPage,然后花点时间了解一下它的工作原理:
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 (
<div className='min-h-screen bg-neutral-300/70 text-center'>
<div className='flex flex-col gap-6 justify-center items-center mx-auto pt-12'>
<img src={waspLogo} className='w-5' />
<div className='flex flex-col gap-4 justify-center items-center w-2/4'>
<AddNote />
<hr className='border border-t-1 border-neutral-100/70 w-full' />
<div className='flex flex-row justify-center w-1/4'>
<Button onClick={handleNewIdeas} isLoading={isGenerating}>
Generate New Ideas
</Button>
</div>
<div className='flex flex-col gap-4 justify-center items-center w-full'>
{tweetDrafts.map((tweetDraft) => (
<>
<h2 className='text-2xl font-bold'>Generated Ideas</h2>
<div key={tweetDraft.id} className='flex flex-col gap-2 justify-center items-center w-full'>
<h2>Tweet Draft</h2>
<div className='flex flex-row gap-2 justify-center items-center w-full'>
<div className='w-full p-4 h-22 bg-blue-100 border rounded-lg w-full'>{tweetDraft.content}</div>
</div>
{!!tweetDraft.notes && tweetDraft.notes !== tweetDraft.originalTweet.content && (
<>
<h2>Your Similar Notes</h2>
{tweetDraft.notes}
</>
)}
<div className='flex flex-col gap-2 justify-center items-center w-full'>
<h2>Ideas</h2>
{tweetDraft.originalTweet.ideas.map((idea) => (
<div key={idea.id} className='flex flex-row gap-2 justify-center items-center w-full'>
<div className='flex flex-row gap-2 justify-center items-center w-full'>
<div className='w-full p-4 h-22 bg-neutral-100 border rounded-lg w-full'>{idea.content}</div>
</div>
</div>
))}
</div>
</div>
</>
))}
</div>
</div>
</div>
</div>
);
};
export default MainPage;
现在首页应该显示这个!🎉
但是,如果您点击“生成新想法”后没有任何反应,那是因为我们还没有定义任何我们喜欢的、引领潮流的推特用户来抓取他们的推文。目前,用户界面上还没有办法做到这一点,所以我们打开数据库管理器,手动添加一些用户。
在新打开的终端标签页中,进入项目根目录,运行以下命令:
wasp db studio
然后,在新打开的浏览器标签页中,访问localhost:5555,您应该可以看到您的数据库。
前往[user此处插入链接],你应该是唯一的用户。添加几个你最喜欢的、引领潮流的推特用户的用户名。
请确保这些账号最近发布过推文,否则您的函数将无法抓取或生成任何内容!
嘿✋
如果你喜欢这个教程,不妨关注一下我的账号@hot_town,这样以后就能看到更多类似的内容了。
添加推特用户名后,请务必点击save 1 change。
返回客户端并Generate New Ideas再次点击按钮。这可能需要一些时间,具体取决于它正在生成多少推文创意,所以请耐心等待——如果您好奇的话,可以查看终端中的控制台输出 ;)
太棒了!现在我们应该会收到来自我们推特“实习生”的一些想法,这将有助于我们集思广益,进一步整理思路,并创作出我们自己的精彩推文。
但如果能把这些想法最初引用的推文也展示出来就更好了。这样我们就能更清楚地了解这些想法的来源。
那就开始吧!在你的MainPage文件最顶部,添加以下导入语句:
import { TwitterTweetEmbed } from 'react-twitter-embed';
这样我们就可以将推文以漂亮的推特样式嵌入到文本中。
我们main.wasp在教程开始时就已经将此依赖项添加到我们的文件中,因此我们可以直接导入并开始嵌入推文。
MainPage现在让我们在元素上方添加以下代码片段来尝试一下<h2>Tweet Draft</h2>:
//...
<h2>Original Tweet</h2>
<div className='flex flex-row gap-2 justify-center items-center w-full'>
<TwitterTweetEmbed tweetId={tweetDraft.originalTweet.tweetId} />
</div>
<h2>Tweet Draft</h2>
//...
太好了。现在我们应该稳操胜券了😻
你可能还记得,在教程开始时,我们定义了 LLM 调用,如果你的向量存储笔记没有返回至少 0.7 的余弦相似度,你的智能体将完全生成自己的想法,而不会使用你的笔记作为指导。
由于我们目前的矢量图库里没有任何笔记,所以它就直接开始生成笔记了。这没关系,因为我们可以让它帮我们集思广益,然后我们再选择自己喜欢的笔记,根据需要进行编辑和添加。
所以你可以随时开始添加笔记📝。
但是,我们是手动将自己喜欢的推特用户添加到数据库的。通过账户设置页面来添加岂不是更好?那我们就创建一个吧。
创建帐户设置页面
首先,将路由和页面添加到main.wasp配置文件中,放在其他路由下方:
//...
route AccountRoute { path: "/account", to: AccountPage }
page AccountPage {
authRequired: true,
component: import Account from "@client/AccountPage"
}
接下来,我们创建一个新页面src/client/AccountPage.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 (
<div className='flex flex-col justify-center items-center mt-12 w-full'>
<div className='flex flex-col items-center justify-center gap-4 border border-neutral-700 bg-neutral-100/40 rounded-xl p-1 sm:p-4 w-full'>
<div className='flex flex-row justify-end w-full px-4 pt-2'>
<Button onClick={logout}>Logout</Button>
</div>
{JSON.stringify(user, null, 2)}
</div>
</div>
);
};
export default AccountPage;
当你访问localhost:3000/account 时,你会注意到两件事,其中之一是注销按钮。SettingsPage正如你上面看到的,我们导入了一个 Wasp 提供的函数。由于我们在文件中logout定义了策略,所以这个函数是“免费”获得的——这大大节省了时间!authmain.wasp
由于我们还定义了AccountPage带有该authRequired: true属性的路由,Wasp 会自动将已登录用户作为 prop 参数传递给我们的页面。我们可以使用该用户对象来显示和更新我们的页面favUsers,就像我们在上图中看到的那样。
为此,让我们updateAccount在文件中定义一个新的操作main.wasp:
action updateAccount {
fn: import { updateAccount } from "@server/account.js",
entities: [User]
}
接下来,让我们updateAccount在一个新文件中创建该操作src/server/account.ts:
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);
}
}
好了。现在是时候把所有内容整合到我们的Account页面中了。我们将创建一个表单,用于添加新的 Twitter 用户以抓取他们的推文,因此在您的页面底部src/client/AccountPage.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<HTMLInputElement>, index: number) => {
const newFields = [...fields];
newFields[index] = e.target.value;
setFields(newFields);
};
const handleSubmit = async () => {
//...
};
return (
<div className='w-full p-4'>
<div className='flex flex-row justify-start items-start'>
<h2 className='ml-1 font-bold'>Trend-Setting Twitter Accounts</h2>
</div>
{fields.map((field, index) => (
<div key={index} className='my-2'>
<input
type='text'
placeholder='Twitter Username'
className='w-full bg-white border border-gray-300 rounded-lg py-2 px-4 text-gray-700 focus:border-blue-400 focus:outline-none'
value={field}
onChange={(e) => handleChange(e, index)}
/>
</div>
))}
<div className='my-2 flex flex-row justify-end gap-1'>
{fields.length > 1 && <Button onClick={handleRemove}>-</Button>}
{fields.length < 10 && (
<Button onClick={handleAdd} className='bg-blue-500 text-white px-4 py-2 rounded'>
+
</Button>
)}
</div>
<Button onClick={handleSubmit} isLoading={isLoading}>
<span>Save</span>
</Button>
</div>
);
}
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
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 <InputFields user={user} />.
Here is what the entire AccountPage.tsx file should now look like in case you get stuck:
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 (
<div className='flex flex-col justify-center items-center mt-12 w-full'>
<div className='flex flex-col items-center justify-center gap-4 border border-neutral-700 bg-neutral-100/40 rounded-xl p-1 sm:p-4 w-full'>
<div className='flex flex-row justify-end w-full px-4 pt-2'>
<Button onClick={logout}>Logout</Button>
</div>
<InputFields user={user} />
</div>
</div>
);
};
export default AccountPage;
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<HTMLInputElement>, index: number) => {
const newFields = [...fields];
newFields[index] = e.target.value;
setFields(newFields);
};
const handleSubmit = async () => {
try {
setIsLoading(true);
await updateAccount({ favUsers: fields });
} catch (err: any) {
alert(err.message);
} finally {
setIsLoading(false);
}
};
return (
<div className='w-full p-4'>
<div className='flex flex-row justify-start items-start'>
<h2 className='ml-1 font-bold'>Trend-Setting Twitter Accounts</h2>
</div>
{fields.map((field, index) => (
<div key={index} className='my-2'>
<input
type='text'
placeholder='Twitter Username'
className='w-full bg-white border border-gray-300 rounded-lg py-2 px-4 text-gray-700 focus:border-blue-400 focus:outline-none'
value={field}
onChange={(e) => handleChange(e, index)}
/>
</div>
))}
<div className='my-2 flex flex-row justify-end gap-1'>
{fields.length > 1 && <Button onClick={handleRemove}>-</Button>}
{fields.length < 10 && (
<Button onClick={handleAdd} className='bg-blue-500 text-white px-4 py-2 rounded'>
+
</Button>
)}
</div>
<Button onClick={handleSubmit} isLoading={isLoading}>
<span>Save</span>
</Button>
</div>
);
}
And here’s what your AccountPage should look like when navigating to localhost:3000/account (note: the styling may be a bit ugly, but we’ll take care of that later):
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:
//...
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, a postgres extension, to queue and run tasks under the hood.
- with
performwe’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
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.
[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:
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):
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 <AddNote /> 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:
app twitterAgent {
wasp: {
version: "^0.10.6"
},
title: "twitter-agent",
client: { // <---- add this
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:
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 (
<div className='min-h-screen bg-neutral-300/70 text-center'>
<div className='flex flex-col gap-6 justify-center items-center mx-auto pt-12'>
<div className='flex flex-row justify-between items-center w-1/2 mb-6 text-neutral-600 px-2'>
<div className='flex justify-start w-1/3'>
<a href='/' className='hover:underline cursor-pointer'>
🤖 Generated Ideas
</a>
</div>
<div className='flex justify-center w-1/3'>
<a href='/notes' className='hover:underline cursor-pointer'>
📝 My Notes
</a>
</div>
<div className='flex justify-end w-1/3'>
<a href='/account' className='hover:underline cursor-pointer'>
👤 Account
</a>
</div>
</div>
<div className='flex flex-col gap-4 justify-center items-center w-2/4'>
{!!user && <AddNote />}
<hr className='border border-t-1 border-neutral-100/70 w-full' />
{children}
</div>
</div>
</div>
);
};
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:
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 (
<>
<div className='flex flex-row justify-center w-full'>
<Button onClick={handleNewIdeas} isLoading={isGenerating}>
Generate New Ideas
</Button>
</div>
<div className='flex flex-col gap-4 justify-center items-center w-full'>
{tweetDrafts.map((tweetDraft) => (
<>
<h2 className='text-2xl font-bold'>Generated Ideas</h2>
<div key={tweetDraft.id} className='flex flex-col gap-2 justify-center items-center w-full'>
<h2>Original Tweet</h2>
<div className='flex flex-row gap-2 justify-center items-center w-full'>
<TwitterTweetEmbed tweetId={tweetDraft.originalTweet.tweetId} />
</div>
<h2>Tweet Draft</h2>
<div className='flex flex-row gap-2 justify-center items-center w-full'>
<div className='w-full p-4 h-22 bg-blue-100 border rounded-lg w-full'>{tweetDraft.content}</div>
</div>
{!!tweetDraft.notes && tweetDraft.notes !== tweetDraft.originalTweet.content && (
<>
<h2>Your Similar Notes</h2>
{tweetDraft.notes}
</>
)}
<div className='flex flex-col gap-2 justify-center items-center w-full'>
<h2>Ideas</h2>
{tweetDraft.originalTweet.ideas.map((idea) => (
<div key={idea.id} className='flex flex-row gap-2 justify-center items-center w-full'>
<div className='flex flex-row gap-2 justify-center items-center w-full'>
<div className='w-full p-4 h-22 bg-neutral-100 border rounded-lg w-full'>{idea.content}</div>
</div>
</div>
))}
</div>
</div>
</>
))}
</div>
</>
);
};
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:
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:
// first import the type at the top of the file
import type { GetEmbeddedNotes, GetTweetDraftsWithIdeas } from '@wasp/queries/types';
//...
export const getEmbeddedNotes: GetEmbeddedNotes<never, GeneratedIdea[]> = 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:
import { useQuery } from '@wasp/queries';
import getEmbeddedNotes from '@wasp/queries/getEmbeddedNotes';
const NotesPage = () => {
const { data: notes, isLoading, error } = useQuery(getEmbeddedNotes);
if (isLoading) <div>Loading...</div>;
if (error) <div>Error: {error.message}</div>;
return (
<>
<h2 className='text-2xl font-bold'>My Notes</h2>
{notes && notes.length > 0 ? (
notes.map((note) => (
<div key={note.id} className='flex flex-col gap-2 justify-center items-center w-full'>
<div className='flex flex-row gap-2 justify-center items-center w-full'>
<div className='w-full p-4 h-22 bg-blue-100 border rounded-lg w-full'>{note.content}</div>
</div>
</div>
))
) : notes && notes.length === 0 && (
<div className='flex flex-col gap-2 justify-center items-center w-full'>
<div className='w-full p-4 h-22 bg-blue-100 border rounded-lg w-full'>No notes yet</div>
</div>
)}
</>
);
};
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:
You Did it! Your own Twitter Intern 🤖
If you haven’t yet, please star us on 🌟 GitHub, 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.
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.
记住,如果你想看到这款应用的更高级版本,它利用官方 Twitter API 发送推文,让你能够即时编辑和添加生成的笔记,对所有笔记进行手动相似性搜索等等,那么你可以看看💥 Banger Tweet Bot 🤖。
最后,这里再次附上我们在本教程中构建的最终应用程序的代码仓库:Personal Twitter Intern
文章来源:https://dev.to/wasp/build-your-own-personal-twitter-agent-with-langchain-32n3

















