✨ 在你的文档上训练 ChatGPT 🪄 ✨
TL;DR
TL;DR
ChatGPT 的训练将持续到 2022 年。
但是,如果您希望它提供关于您网站的具体信息,该怎么办?这很可能是不可能的,但现在不再可能了!
OpenAI 推出了他们的新功能——助手。
现在,您可以轻松索引您的网站,然后向 ChatGPT 询问有关它的问题。在本教程中,我们将构建一个索引您的网站并允许您进行查询的系统。我们将:
- 抓取文档站点地图。
- 从网站的所有页面中提取信息。
- 使用新信息创建一个新助手。
- 构建一个简单的 ChatGPT 前端界面并查询助手。
您的后台工作平台🔌
Trigger.dev 是一个开源库,可让您使用 NextJS、Remix、Astro 等为您的应用程序创建和监控长时间运行的作业!
请帮我们点个星🥹。
这有助于我们创作更多类似的文章💖
让我们开始吧🔥
让我们建立一个新的 NextJS 项目。
npx create-next-app@latest
💡 我们使用 NextJS 的新应用路由器。安装项目前,请确保你的 Node 版本为 18 及以上。
让我们创建一个新的数据库来保存助手和抓取的页面。
在本例中,我们将使用Prisma和 SQLite。
安装非常简单,只需运行:
npm install prisma @prisma/client --save
然后添加一个模式和一个数据库
npx prisma init --datasource-provider sqlite
转至 prisma/schema.prisma
并将其替换为以下模式:
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Docs {
id Int @id @default(autoincrement())
content String
url String @unique
identifier String
@@index([identifier])
}
model Assistant {
id Int @id @default(autoincrement())
aId String
url String @unique
}
然后运行
npx prisma db push
这将创建一个新的 SQLite 数据库(本地文件),其中包含两个主表Docs
:Assistant
- 包含
Docs
所有抓取的页面 - 包含
Assistant
文档的 URL 和内部 ChatGPT 助手 ID。
让我们添加我们的 Prisma 客户端。
创建一个名为的新文件夹 helper
并添加一个名为的新文件 prisma.ts
,并在其中添加以下代码:
import {PrismaClient} from '@prisma/client';
export const prisma = new PrismaClient();
我们稍后可以使用该 prisma
变量来查询我们的数据库。
抓取和索引
创建 Trigger.dev 帐户
抓取和索引页面是一项长期任务。我们需要:
- 抓取站点地图的主网站元 URL。
- 提取站点地图内的所有页面。
- 转到每个页面并提取内容。
- 将所有内容保存到 ChatGPT 助手。
为此,让我们使用 Trigger.dev!
注册一个 Trigger.dev 帐户。
注册后,创建一个组织并为您的工作选择一个项目名称。
选择 Next.js 作为您的框架,并按照将 Trigger.dev 添加到现有 Next.js 项目的过程进行操作。
否则,请单击 Environments & API Keys
项目仪表板的侧边栏菜单。
复制您的 DEV 服务器 API 密钥并运行下面的代码片段来安装 Trigger.dev。
请仔细遵循说明。
npx @trigger.dev/cli@latest init
在另一个终端中运行以下代码片段,以在 Trigger.dev 和 Next.js 项目之间建立隧道。
npx @trigger.dev/cli@latest dev
安装 ChatGPT(OpenAI)
我们将使用 OpenAI 助手,因此我们必须在我们的项目上安装它。
创建一个新的 OpenAI 帐户 并生成一个 API 密钥。
单击 View API key
下拉菜单即可创建 API 密钥。
接下来,通过运行下面的代码片段来安装 OpenAI 包。
npm install @trigger.dev/openai
将您的 OpenAI API 密钥添加到 .env.local
文件中。
OPENAI_API_KEY=<your_api_key>
创建一个新目录,helper
并添加一个新文件,open.ai.tsx
内容如下:
import {OpenAI} from "@trigger.dev/openai";
export const openai = new OpenAI({
id: "openai",
apiKey: process.env.OPENAI_API_KEY!,
});
这是我们由 Trigger.dev 集成包装的 OpenAI 客户端。
构建后台作业
让我们继续创建一个新的后台作业!
转到jobs
并创建一个名为 的新文件process.documentation.ts
。添加以下代码:
import { eventTrigger } from "@trigger.dev/sdk";
import { client } from "@openai-assistant/trigger";
import {object, string} from "zod";
import {JSDOM} from "jsdom";
import {openai} from "@openai-assistant/helper/open.ai";
client.defineJob({
// This is the unique identifier for your Job; it must be unique across all Jobs in your project.
id: "process-documentation",
name: "Process Documentation",
version: "0.0.1",
// This is triggered by an event using eventTrigger. You can also trigger Jobs with webhooks, on schedules, and more: https://trigger.dev/docs/documentation/concepts/triggers/introduction
trigger: eventTrigger({
name: "process.documentation.event",
schema: object({
url: string(),
})
}),
integrations: {
openai
},
run: async (payload, io, ctx) => {
}
});
我们定义了一项名为 的新作业process.documentation.event
,并添加了一个名为 URL 的必需参数 - 这是我们稍后要发送的文档 URL。
如您所见,该作业是空的,因此让我们向其中添加第一个任务。
我们需要抓取网站地图并返回。
抓取网站地图会返回我们需要解析的 HTML 代码。
为此,我们需要安装 JSDOM。
npm install jsdom --save
并将其导入到文件顶部:
import {JSDOM} from "jsdom";
现在,我们可以添加我们的第一个任务。
用 包装我们的代码非常重要runTask
,这样 Trigger.dev 就可以将其与其他任务区分开来。Trigger 的特殊架构将任务拆分到不同的进程中,因此 Vercel 无服务器超时不会影响它们。以下是第一个任务的代码:
const getSiteMap = await io.runTask("grab-sitemap", async () => {
const data = await (await fetch(payload.url)).text();
const dom = new JSDOM(data);
const sitemap = dom.window.document.querySelector('[rel="sitemap"]')?.getAttribute('href');
return new URL(sitemap!, payload.url).toString();
});
- 我们使用 HTTP 请求从 URL 中抓取整个 HTML。
- 我们将其转换为 JS 对象。
- 我们找到了站点地图的 URL。
- 我们解析它并返回它。
接下来,我们需要抓取站点地图,提取所有 URL 并返回它们。
让我们安装Lodash
一些用于数组结构的特殊函数。
npm install lodash @types/lodash --save
以下是该任务的代码:
export const makeId = (length: number) => {
let text = '';
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < length; i += 1) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
};
const {identifier, list} = await io.runTask("load-and-parse-sitemap", async () => {
const urls = /(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#-]*[\w@?^=%&\/~+#-])/g;
const identifier = makeId(5);
const data = await (await fetch(getSiteMap)).text();
// @ts-ignore
return {identifier, list: chunk(([...new Set(data.match(urls))] as string[]).filter(f => f.includes(payload.url)).map(p => ({identifier, url: p})), 25)};
});
- 我们创建一个名为 makeId 的新函数来为我们所有的页面生成一个随机标识符。
- 我们创建一个新任务并添加一个正则表达式来提取每个可能的 URL
- 我们发送一个 HTTP 请求来加载站点地图并提取其所有 URL。
- 我们
chunk
将 URL 分成 25 个元素的数组(如果我们有 100 个元素,我们将有 4 个 25 个元素的数组)
接下来,让我们创建一个新作业来处理每个 URL。
完整代码如下:
function getElementsBetween(startElement: Element, endElement: Element) {
let currentElement = startElement;
const elements = [];
// Traverse the DOM until the endElement is reached
while (currentElement && currentElement !== endElement) {
currentElement = currentElement.nextElementSibling!;
// If there's no next sibling, go up a level and continue
if (!currentElement) {
// @ts-ignore
currentElement = startElement.parentNode!;
startElement = currentElement;
if (currentElement === endElement) break;
continue;
}
// Add the current element to the list
if (currentElement && currentElement !== endElement) {
elements.push(currentElement);
}
}
return elements;
}
const processContent = client.defineJob({
// This is the unique identifier for your Job; it must be unique across all Jobs in your project.
id: "process-content",
name: "Process Content",
version: "0.0.1",
// This is triggered by an event using eventTrigger. You can also trigger Jobs with webhooks, on schedules, and more: https://trigger.dev/docs/documentation/concepts/triggers/introduction
trigger: eventTrigger({
name: "process.content.event",
schema: object({
url: string(),
identifier: string(),
})
}),
run: async (payload, io, ctx) => {
return io.runTask('grab-content', async () => {
// We first grab a raw html of the content from the website
const data = await (await fetch(payload.url)).text();
// We load it with JSDOM so we can manipulate it
const dom = new JSDOM(data);
// We remove all the scripts and styles from the page
dom.window.document.querySelectorAll('script, style').forEach((el) => el.remove());
// We grab all the titles from the page
const content = Array.from(dom.window.document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
// We grab the last element so we can get the content between the last element and the next element
const lastElement = content[content.length - 1]?.parentElement?.nextElementSibling!;
const elements = [];
// We loop through all the elements and grab the content between each title
for (let i = 0; i < content.length; i++) {
const element = content[i];
const nextElement = content?.[i + 1] || lastElement;
const elementsBetween = getElementsBetween(element, nextElement);
elements.push({
title: element.textContent, content: elementsBetween.map((el) => el.textContent).join('\n')
});
}
// We create a raw text format of all the content
const page = `
----------------------------------
url: ${payload.url}\n
${elements.map((el) => `${el.title}\n${el.content}`).join('\n')}
----------------------------------
`;
// We save it to our database
await prisma.docs.upsert({
where: {
url: payload.url
}, update: {
content: page, identifier: payload.identifier
}, create: {
url: payload.url, content: page, identifier: payload.identifier
}
});
});
},
});
- 我们从 URL 中抓取内容(之前从站点地图中提取)
- 我们用
JSDOM
- 我们删除页面上存在的每一个可能的内容
<script>
。<style>
- 我们抓取页面上的所有标题(
h1
,h2
,h3
,h4
, )h5
h6
- 我们迭代标题并获取它们之间的内容。我们不想获取整个页面内容,因为它可能包含不相关的内容。
- 我们创建页面原始文本的版本并将其保存到我们的数据库中。
现在,让我们对每个站点地图 URL 运行此任务。
触发器引入了一个名为 的功能batchInvokeAndWaitForCompletion
。
它允许我们批量发送 25 个项目进行处理,并且它会同时处理所有项目。以下是以下几行代码:
let i = 0;
for (const item of list) {
await processContent.batchInvokeAndWaitForCompletion(
'process-list-' + i,
item.map(
payload => ({
payload,
}),
86_400),
);
i++;
}
我们手动触发了之前创建的 25 个作业批次。
一旦完成,我们将把已保存的所有内容复制到数据库中并进行连接:
const data = await io.runTask("get-extracted-data", async () => {
return (await prisma.docs.findMany({
where: {
identifier
},
select: {
content: true
}
})).map((d) => d.content).join('\n\n');
});
我们使用之前指定的标识符。
现在,让我们使用新数据在 ChatGPT 中创建一个新文件:
const file = await io.openai.files.createAndWaitForProcessing("upload-file", {
purpose: "assistants",
file: data
});
createAndWaitForProcessing
是由 Trigger.dev 创建的任务,用于将文件上传到助手。如果您openai
在未集成的情况下手动使用,则必须流式传输文件。
现在让我们创建或更新我们的助手:
const assistant = await io.openai.runTask("create-or-update-assistant", async (openai) => {
const currentAssistant = await prisma.assistant.findFirst({
where: {
url: payload.url
}
});
if (currentAssistant) {
return openai.beta.assistants.update(currentAssistant.aId, {
file_ids: [file.id]
});
}
return openai.beta.assistants.create({
name: identifier,
description: 'Documentation',
instructions: 'You are a documentation assistant, you have been loaded with documentation from ' + payload.url + ', return everything in an MD format.',
model: 'gpt-4-1106-preview',
tools: [{ type: "code_interpreter" }, {type: 'retrieval'}],
file_ids: [file.id],
});
});
- 我们首先检查是否有针对该特定 URL 的助手。
- 如果有的话,让我们用新文件更新助手。
- 如果没有,我们就创建一个新的助手。
- 我们传递“你是一名文档助理”的指令,必须注意的是,我们希望最终输出是格式化的,
MD
以便我们以后可以更好地显示它。
作为拼图的最后一块,让我们将新助手保存到我们的数据库中。
以下是代码:
await io.runTask("save-assistant", async () => {
await prisma.assistant.upsert({
where: {
url: payload.url
},
update: {
aId: assistant.id,
},
create: {
aId: assistant.id,
url: payload.url,
}
});
});
如果该 URL 已经存在,我们可以尝试使用新的助手 ID 来更新它。
以下是该页面的完整代码:
import { eventTrigger } from "@trigger.dev/sdk";
import { client } from "@openai-assistant/trigger";
import {object, string} from "zod";
import {JSDOM} from "jsdom";
import {chunk} from "lodash";
import {prisma} from "@openai-assistant/helper/prisma.client";
import {openai} from "@openai-assistant/helper/open.ai";
const makeId = (length: number) => {
let text = '';
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < length; i += 1) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
};
client.defineJob({
// This is the unique identifier for your Job; it must be unique across all Jobs in your project.
id: "process-documentation",
name: "Process Documentation",
version: "0.0.1",
// This is triggered by an event using eventTrigger. You can also trigger Jobs with webhooks, on schedules, and more: https://trigger.dev/docs/documentation/concepts/triggers/introduction
trigger: eventTrigger({
name: "process.documentation.event",
schema: object({
url: string(),
})
}),
integrations: {
openai
},
run: async (payload, io, ctx) => {
// The first task to get the sitemap URL from the website
const getSiteMap = await io.runTask("grab-sitemap", async () => {
const data = await (await fetch(payload.url)).text();
const dom = new JSDOM(data);
const sitemap = dom.window.document.querySelector('[rel="sitemap"]')?.getAttribute('href');
return new URL(sitemap!, payload.url).toString();
});
// We parse the sitemap; instead of using some XML parser, we just use regex to get the URLs and we return it in chunks of 25
const {identifier, list} = await io.runTask("load-and-parse-sitemap", async () => {
const urls = /(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#-]*[\w@?^=%&\/~+#-])/g;
const identifier = makeId(5);
const data = await (await fetch(getSiteMap)).text();
// @ts-ignore
return {identifier, list: chunk(([...new Set(data.match(urls))] as string[]).filter(f => f.includes(payload.url)).map(p => ({identifier, url: p})), 25)};
});
// We go into each page and grab the content; we do this in batches of 25 and save it to the DB
let i = 0;
for (const item of list) {
await processContent.batchInvokeAndWaitForCompletion(
'process-list-' + i,
item.map(
payload => ({
payload,
}),
86_400),
);
i++;
}
// We get the data that we saved in batches from the DB
const data = await io.runTask("get-extracted-data", async () => {
return (await prisma.docs.findMany({
where: {
identifier
},
select: {
content: true
}
})).map((d) => d.content).join('\n\n');
});
// We upload the data to OpenAI with all the content
const file = await io.openai.files.createAndWaitForProcessing("upload-file", {
purpose: "assistants",
file: data
});
// We create a new assistant or update the old one with the new file
const assistant = await io.openai.runTask("create-or-update-assistant", async (openai) => {
const currentAssistant = await prisma.assistant.findFirst({
where: {
url: payload.url
}
});
if (currentAssistant) {
return openai.beta.assistants.update(currentAssistant.aId, {
file_ids: [file.id]
});
}
return openai.beta.assistants.create({
name: identifier,
description: 'Documentation',
instructions: 'You are a documentation assistant, you have been loaded with documentation from ' + payload.url + ', return everything in an MD format.',
model: 'gpt-4-1106-preview',
tools: [{ type: "code_interpreter" }, {type: 'retrieval'}],
file_ids: [file.id],
});
});
// We update our internal database with the assistant
await io.runTask("save-assistant", async () => {
await prisma.assistant.upsert({
where: {
url: payload.url
},
update: {
aId: assistant.id,
},
create: {
aId: assistant.id,
url: payload.url,
}
});
});
},
});
export function getElementsBetween(startElement: Element, endElement: Element) {
let currentElement = startElement;
const elements = [];
// Traverse the DOM until the endElement is reached
while (currentElement && currentElement !== endElement) {
currentElement = currentElement.nextElementSibling!;
// If there's no next sibling, go up a level and continue
if (!currentElement) {
// @ts-ignore
currentElement = startElement.parentNode!;
startElement = currentElement;
if (currentElement === endElement) break;
continue;
}
// Add the current element to the list
if (currentElement && currentElement !== endElement) {
elements.push(currentElement);
}
}
return elements;
}
// This job will grab the content from the website
const processContent = client.defineJob({
// This is the unique identifier for your Job; it must be unique across all Jobs in your project.
id: "process-content",
name: "Process Content",
version: "0.0.1",
// This is triggered by an event using eventTrigger. You can also trigger Jobs with webhooks, on schedules, and more: https://trigger.dev/docs/documentation/concepts/triggers/introduction
trigger: eventTrigger({
name: "process.content.event",
schema: object({
url: string(),
identifier: string(),
})
}),
run: async (payload, io, ctx) => {
return io.runTask('grab-content', async () => {
try {
// We first grab a raw HTML of the content from the website
const data = await (await fetch(payload.url)).text();
// We load it with JSDOM so we can manipulate it
const dom = new JSDOM(data);
// We remove all the scripts and styles from the page
dom.window.document.querySelectorAll('script, style').forEach((el) => el.remove());
// We grab all the titles from the page
const content = Array.from(dom.window.document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
// We grab the last element so we can get the content between the last element and the next element
const lastElement = content[content.length - 1]?.parentElement?.nextElementSibling!;
const elements = [];
// We loop through all the elements and grab the content between each title
for (let i = 0; i < content.length; i++) {
const element = content[i];
const nextElement = content?.[i + 1] || lastElement;
const elementsBetween = getElementsBetween(element, nextElement);
elements.push({
title: element.textContent, content: elementsBetween.map((el) => el.textContent).join('\n')
});
}
// We create a raw text format of all the content
const page = `
----------------------------------
url: ${payload.url}\n
${elements.map((el) => `${el.title}\n${el.content}`).join('\n')}
----------------------------------
`;
// We save it to our database
await prisma.docs.upsert({
where: {
url: payload.url
}, update: {
content: page, identifier: payload.identifier
}, create: {
url: payload.url, content: page, identifier: payload.identifier
}
});
}
catch (e) {
console.log(e);
}
});
},
});
我们已经完成了抓取和索引文件的后台作业创建🎉
询问助理
现在,让我们创建作业来询问我们的助手。
转到jobs
并创建一个新文件question.assistant.ts
。添加以下代码:
import {eventTrigger} from "@trigger.dev/sdk";
import {client} from "@openai-assistant/trigger";
import {object, string} from "zod";
import {openai} from "@openai-assistant/helper/open.ai";
client.defineJob({
// This is the unique identifier for your Job; it must be unique across all Jobs in your project.
id: "question-assistant",
name: "Question Assistant",
version: "0.0.1", // This is triggered by an event using eventTrigger. You can also trigger Jobs with webhooks, on schedules, and more: https://trigger.dev/docs/documentation/concepts/triggers/introduction
trigger: eventTrigger({
name: "question.assistant.event", schema: object({
content: string(),
aId: string(),
threadId: string().optional(),
})
}), integrations: {
openai
}, run: async (payload, io, ctx) => {
// Create or use an existing thread
const thread = payload.threadId ? await io.openai.beta.threads.retrieve('get-thread', payload.threadId) : await io.openai.beta.threads.create('create-thread');
// Create a message in the thread
await io.openai.beta.threads.messages.create('create-message', thread.id, {
content: payload.content,
role: 'user',
});
// Run the thread
const run = await io.openai.beta.threads.runs.createAndWaitForCompletion('run-thread', thread.id, {
model: 'gpt-4-1106-preview',
assistant_id: payload.aId,
});
// Check the status of the thread
if (run.status !== "completed") {
console.log('not completed');
throw new Error(`Run finished with status ${run.status}: ${JSON.stringify(run.last_error)}`);
}
// Get the messages from the thread
const messages = await io.openai.beta.threads.messages.list("list-messages", run.thread_id, {
query: {
limit: "1"
}
});
const content = messages[0].content[0];
if (content.type === 'text') {
return {content: content.text.value, threadId: thread.id};
}
}
});
- 该事件有三个参数
content
- 我们想要发送给助手的信息。aId
- 我们之前创建的助手的内部 ID。threadId
- 对话的线程 ID。如您所见,这是一个可选参数,因为在第一条消息中,我们还没有线程 ID。
- 然后,我们创建或获取前一个线程。
- 我们在向助手提出的问题的主题中添加了一条新消息。
- 我们运行线程并等待它完成。
- 我们获取消息列表(并将其限制为 1),因为第一条消息是该对话中的最后一条消息。
- 我们返回刚刚创建的消息内容和线程 ID。
添加路由
我们需要为我们的应用程序创建 3 个 API 路由:
- 派遣新的助手进行处理。
- 通过 URL 获取特定助手。
- 向助手添加新消息。
在 里面创建一个app/api
名为 的新文件夹,并在其中创建一个名为 的新文件route.ts
。在其中添加以下代码:
import {client} from "@openai-assistant/trigger";
import {prisma} from "@openai-assistant/helper/prisma.client";
export async function POST(request: Request) {
const body = await request.json();
if (!body.url) {
return new Response(JSON.stringify({error: 'URL is required'}), {status: 400});
}
// We send an event to the trigger to process the documentation
const {id: eventId} = await client.sendEvent({
name: "process.documentation.event",
payload: {url: body.url},
});
return new Response(JSON.stringify({eventId}), {status: 200});
}
export async function GET(request: Request) {
const url = new URL(request.url).searchParams.get('url');
if (!url) {
return new Response(JSON.stringify({error: 'URL is required'}), {status: 400});
}
const assistant = await prisma.assistant.findFirst({
where: {
url: url
}
});
return new Response(JSON.stringify(assistant), {status: 200});
}
第一种POST
方法获取一个 URL,并process.documentation.event
使用从客户端发送的 URL 触发作业。
第二种GET
方法是从我们的数据库中,通过客户端发送的 URL 获取助手。
现在,让我们创建向助手添加消息的路由。
在里面app/api
创建一个新文件夹message
并添加一个名为 的新文件route.ts
,然后添加以下代码:
import {prisma} from "@openai-assistant/helper/prisma.client";
import {client} from "@openai-assistant/trigger";
export async function POST(request: Request) {
const body = await request.json();
// Check that we have the assistant id and the message
if (!body.id || !body.message) {
return new Response(JSON.stringify({error: 'Id and Message are required'}), {status: 400});
}
// get the assistant id in OpenAI from the id in the database
const assistant = await prisma.assistant.findUnique({
where: {
id: +body.id
}
});
// We send an event to the trigger to process the documentation
const {id: eventId} = await client.sendEvent({
name: "question.assistant.event",
payload: {
content: body.message,
aId: assistant?.aId,
threadId: body.threadId
},
});
return new Response(JSON.stringify({eventId}), {status: 200});
}
这是非常基本的代码。我们从客户端获取消息、助手 ID 和线程 ID,并将其发送到我们之前创建的question.assistant.event
。
最后要做的事情是创建一个函数来获取我们所有的助手。
在里面helpers
创建一个名为的新函数get.list.ts
并添加以下代码:
import {prisma} from "@openai-assistant/helper/prisma.client";
// Get the list of all the available assistants
export const getList = () => {
return prisma.assistant.findMany({
});
}
非常简单的代码即可获得所有助手。
我们已经完成了后端🥳
我们到前面去吧。
创建前端
我们将创建一个基本界面来添加 URL 并显示已添加的 URL 列表:
主页
将 的内容替换app/page.tsx
为以下代码:
import {getList} from "@openai-assistant/helper/get.list";
import Main from "@openai-assistant/components/main";
export default async function Home() {
const list = await getList();
return (
<Main list={list} />
)
}
这是一个简单的代码,它从数据库中获取列表并将其传递给我们的主组件。
接下来,让我们创建Main
组件。
在里面app
创建一个新文件夹components
并添加一个名为 的新文件main.tsx
。添加以下代码:
"use client";
import {Assistant} from '@prisma/client';
import {useCallback, useState} from "react";
import {FieldValues, SubmitHandler, useForm} from "react-hook-form";
import {ChatgptComponent} from "@openai-assistant/components/chatgpt.component";
import {AssistantList} from "@openai-assistant/components/assistant.list";
import {TriggerProvider} from "@trigger.dev/react";
export interface ExtendedAssistant extends Assistant {
pending?: boolean;
eventId?: string;
}
export default function Main({list}: {list: ExtendedAssistant[]}) {
const [assistantState, setAssistantState] = useState(list);
const {register, handleSubmit} = useForm();
const submit: SubmitHandler<FieldValues> = useCallback(async (data) => {
const assistantResponse = await (await fetch('/api/assistant', {
body: JSON.stringify({url: data.url}),
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})).json();
setAssistantState([...assistantState, {...assistantResponse, url: data.url, pending: true}]);
}, [assistantState])
const changeStatus = useCallback((val: ExtendedAssistant) => async () => {
const assistantResponse = await (await fetch(`/api/assistant?url=${val.url}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})).json();
setAssistantState([...assistantState.filter((v) => v.id), assistantResponse]);
}, [assistantState])
return (
<TriggerProvider publicApiKey={process.env.NEXT_PUBLIC_TRIGGER_PUBLIC_API_KEY!}>
<div className="w-full max-w-2xl mx-auto p-6 flex flex-col gap-4">
<form className="flex items-center space-x-4" onSubmit={handleSubmit(submit)}>
<input className="flex-grow p-3 border border-black/20 rounded-xl" placeholder="Add documentation link" type="text" {...register('url', {required: 'true'})} />
<button className="flex-shrink p-3 border border-black/20 rounded-xl" type="submit">
Add
</button>
</form>
<div className="divide-y-2 divide-gray-300 flex gap-2 flex-wrap">
{assistantState.map(val => (
<AssistantList key={val.url} val={val} onFinish={changeStatus(val)} />
))}
</div>
{assistantState.filter(f => !f.pending).length > 0 && <ChatgptComponent list={assistantState} />}
</div>
</TriggerProvider>
)
}
让我们看看这里发生了什么:
- 我们创建了一个新的接口,它
ExtendedAssistant
使用两个参数pending
和进行调用eventId
。当我们创建一个新的助手时,我们没有最终的值,我们只会存储eventId
并监听作业处理,直到完成。 - 我们从服务器组件获取列表并将其设置为我们的新状态(以便我们以后可以修改它)
- 我们添加了一个
TriggerProvider
来帮助我们监听事件完成并用数据更新它。 - 我们用来
react-hook-form
创建一个新表单来添加新助手。 - 我们添加了一个带有一个输入的表单
URL
来提交新的助手进行处理。 - 我们进行迭代并展示所有现有的助手。
- 在提交表单时,我们将信息发送到之前创建的
route
以添加新的助手。 - 一旦事件完成,我们就会触发
changeStatus
从数据库加载助手。 - 最后,我们有了 ChatGPT 组件,只有当我们没有等待处理的助手时才会显示(
!f.pending
)
让我们创建我们的AssistantList
组件。
在里面components
创建一个新文件assistant.list.tsx
并添加以下内容:
"use client";
import {FC, useEffect} from "react";
import {ExtendedAssistant} from "@openai-assistant/components/main";
import {useEventRunDetails} from "@trigger.dev/react";
export const Loading: FC<{eventId: string, onFinish: () => void}> = (props) => {
const {eventId} = props;
const { data, error } = useEventRunDetails(eventId);
useEffect(() => {
if (!data || error) {
return ;
}
if (data.status === 'SUCCESS') {
props.onFinish();
}
}, [data]);
return <div className="pointer bg-yellow-300 border-yellow-500 p-1 px-3 text-yellow-950 border rounded-2xl">Loading</div>
};
export const AssistantList: FC<{val: ExtendedAssistant, onFinish: () => void}> = (props) => {
const {val, onFinish} = props;
if (val.pending) {
return <Loading eventId={val.eventId!} onFinish={onFinish} />
}
return (
<div key={val.url} className="pointer relative bg-green-300 border-green-500 p-1 px-3 text-green-950 border rounded-2xl hover:bg-red-300 hover:border-red-500 hover:text-red-950 before:content-[attr(data-content)]" data-content={val.url} />
)
}
我们迭代所有创建的助手。如果助手已经创建,则显示其名称。如果没有创建,则渲染该<Loading />
组件。
加载组件Loading
在屏幕上显示并对服务器进行长轮询,直到事件完成。
我们使用useEventRunDetails
Trigger.dev 创建的函数来了解事件何时完成。
一旦事件结束,它就会触发onFinish
函数,使用新创建的助手来更新我们的客户端。
聊天界面
现在,让我们添加 ChatGPT 组件并向我们的助手提问!
- 选择我们想要使用的助手
- 显示消息列表
- 添加我们要发送的消息的输入和提交按钮。
在里面components
添加一个名为chatgpt.component.tsx
让我们绘制我们的 ChatGPT 聊天框:
"use client";
import {FC, useCallback, useEffect, useRef, useState} from "react";
import {ExtendedAssistant} from "@openai-assistant/components/main";
import Markdown from 'react-markdown'
import {useEventRunDetails} from "@trigger.dev/react";
interface Messages {
message?: string
eventId?: string
}
export const ChatgptComponent = ({list}: {list: ExtendedAssistant[]}) => {
const url = useRef<HTMLSelectElement>(null);
const [message, setMessage] = useState('');
const [messagesList, setMessagesList] = useState([] as Messages[]);
const [threadId, setThreadId] = useState<string>('' as string);
const submitForm = useCallback(async (e: any) => {
e.preventDefault();
setMessagesList((messages) => [...messages, {message: `**[ME]** ${message}`}]);
setMessage('');
const messageResponse = await (await fetch('/api/message', {
method: 'POST',
body: JSON.stringify({message, id: url.current?.value, threadId}),
})).json();
if (!threadId) {
setThreadId(messageResponse.threadId);
}
setMessagesList((messages) => [...messages, {eventId: messageResponse.eventId}]);
}, [message, messagesList, url, threadId]);
return (
<div className="border border-black/50 rounded-2xl flex flex-col">
<div className="border-b border-b-black/50 h-[60px] gap-3 px-3 flex items-center">
<div>Assistant:</div>
<div>
<select ref={url} className="border border-black/20 rounded-xl p-2">
{list.filter(f => !f.pending).map(val => (
<option key={val.id} value={val.id}>{val.url}</option>
))}
</select>
</div>
</div>
<div className="flex-1 flex flex-col gap-3 py-3 w-full min-h-[500px] max-h-[1000px] overflow-y-auto overflow-x-hidden messages-list">
{messagesList.map((val, index) => (
<div key={index} className={`flex border-b border-b-black/20 pb-3 px-3`}>
<div className="w-full">
{val.message ? <Markdown>{val.message}</Markdown> : <MessageComponent eventId={val.eventId!} onFinish={setThreadId} />}
</div>
</div>
))}
</div>
<form onSubmit={submitForm}>
<div className="border-t border-t-black/50 h-[60px] gap-3 px-3 flex items-center">
<div className="flex-1">
<input value={message} onChange={(e) => setMessage(e.target.value)} className="read-only:opacity-20 outline-none border border-black/20 rounded-xl p-2 w-full" placeholder="Type your message here" />
</div>
<div>
<button className="border border-black/20 rounded-xl p-2 disabled:opacity-20" disabled={message.length < 3}>Send</button>
</div>
</div>
</form>
</div>
)
}
export const MessageComponent: FC<{eventId: string, onFinish: (threadId: string) => void}> = (props) => {
const {eventId} = props;
const { data, error } = useEventRunDetails(eventId);
useEffect(() => {
if (!data || error) {
return ;
}
if (data.status === 'SUCCESS') {
props.onFinish(data.output.threadId);
}
}, [data]);
if (!data || error || data.status !== 'SUCCESS') {
return (
<div className="flex justify-end items-center pb-3 px-3">
<div className="animate-spin rounded-full h-3 w-3 border-t-2 border-b-2 border-blue-500" />
</div>
}
return <Markdown>{data.output.content}</Markdown>;
};
这里发生了一些令人兴奋的事情:
- 当我们创建新消息时,我们会自动将其渲染为“我们的”消息,但当我们将其发送到服务器时,我们需要推送事件 ID,因为我们还没有收到该消息。这就是为什么我们使用
{val.message ? <Markdown>{val.message}</Markdown> : <MessageComponent eventId={val.eventId!} onFinish={setThreadId} />}
- 我们用一个组件包装消息
Markdown
。如果您还记得的话,我们在前面的步骤中告诉 ChatGPT 将所有内容以 MD 格式输出,以便我们能够正确渲染。 - 一旦事件处理完毕,我们就会更新线程 ID,以便从以下消息中获得相同对话的上下文。
大功告成🎉
让我们联系吧!🔌
作为开源开发者,您可以加入我们的 社区 ,贡献代码并与维护人员互动。欢迎访问我们的 GitHub 代码库 ,贡献代码并创建与 Trigger.dev 相关的问题。
本教程的源代码可以在这里找到:
https://github.com/triggerdotdev/blog/tree/main/openai-assistant
感谢您的阅读!
文章来源:https://dev.to/triggerdotdev/train-chatgpt-on-your-documentation-1a9g