✨ 在你的文档上训练 ChatGPT 🪄 ✨ TL;DR

2025-05-28

✨ 在你的文档上训练 ChatGPT 🪄 ✨

TL;DR

TL;DR

ChatGPT 的训练将持续到 2022 年。

但是,如果您希望它提供关于您网站的具体信息,该怎么办?这很可能是不可能的,但现在不再可能了!

OpenAI 推出了他们的新功能——助手

现在,您可以轻松索引您的网站,然后向 ChatGPT 询问有关它的问题。在本教程中,我们将构建一个索引您的网站并允许您进行查询的系统。我们将:

  • 抓取文档站点地图。
  • 从网站的所有页面中提取信息。
  • 使用新信息创建一个新助手。
  • 构建一个简单的 ChatGPT 前端界面并查询助手。

助手


您的后台工作平台🔌

Trigger.dev 是一个开源库,可让您使用 NextJS、Remix、Astro 等为您的应用程序创建和监控长时间运行的作业!

 

给我们星星

请帮我们点个星🥹。
这有助于我们创作更多类似的文章💖

为 Trigger.dev 仓库加星标⭐️


让我们开始吧🔥

让我们建立一个新的 NextJS 项目。

 npx create-next-app@latest
Enter fullscreen mode Exit fullscreen mode

💡 我们使用 NextJS 的新应用路由器。安装项目前,请确保你的 Node 版本为 18 及以上。

让我们创建一个新的数据库来保存助手和抓取的页面。
在本例中,我们将使用Prisma和 SQLite。

安装非常简单,只需运行:

npm install prisma @prisma/client --save
Enter fullscreen mode Exit fullscreen mode

然后添加一个模式和一个数据库

npx prisma init --datasource-provider sqlite
Enter fullscreen mode Exit fullscreen mode

转至 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
}
Enter fullscreen mode Exit fullscreen mode

然后运行

npx prisma db push
Enter fullscreen mode Exit fullscreen mode

这将创建一个新的 SQLite 数据库(本地文件),其中包含两个主表DocsAssistant

  • 包含Docs所有抓取的页面
  • 包含Assistant文档的 URL 和内部 ChatGPT 助手 ID。

让我们添加我们的 Prisma 客户端。

创建一个名为的新文件夹 helper 并添加一个名为的新文件 prisma.ts ,并在其中添加以下代码:

import {PrismaClient} from '@prisma/client';

export const prisma = new PrismaClient();
Enter fullscreen mode Exit fullscreen mode

我们稍后可以使用该 prisma 变量来查询我们的数据库。


ScrapeAndIndex

抓取和索引

创建 Trigger.dev 帐户

抓取和索引页面是一项长期任务。我们需要:

  • 抓取站点地图的主网站元 URL。
  • 提取站点地图内的所有页面。
  • 转到每个页面并提取内容。
  • 将所有内容保存到 ChatGPT 助手。

为此,让我们使用 Trigger.dev!

注册一个 Trigger.dev 帐户

注册后,创建一个组织并为您的工作选择一个项目名称。

图1

选择 Next.js 作为您的框架,并按照将 Trigger.dev 添加到现有 Next.js 项目的过程进行操作。

图2

否则,请单击 Environments & API Keys 项目仪表板的侧边栏菜单。

图3

复制您的 DEV 服务器 API 密钥并运行下面的代码片段来安装 Trigger.dev。

请仔细遵循说明。

npx @trigger.dev/cli@latest init
Enter fullscreen mode Exit fullscreen mode

在另一个终端中运行以下代码片段,以在 Trigger.dev 和 Next.js 项目之间建立隧道。

npx @trigger.dev/cli@latest dev
Enter fullscreen mode Exit fullscreen mode

安装 ChatGPT(OpenAI)

我们将使用 OpenAI 助手,因此我们必须在我们的项目上安装它。

创建一个新的 OpenAI 帐户 并生成一个 API 密钥。

图4

单击 View API key 下拉菜单即可创建 API 密钥。

图5

接下来,通过运行下面的代码片段来安装 OpenAI 包。

npm install @trigger.dev/openai
Enter fullscreen mode Exit fullscreen mode

将您的 OpenAI API 密钥添加到 .env.local 文件中。

OPENAI_API_KEY=<your_api_key>
Enter fullscreen mode Exit fullscreen mode

创建一个新目录,helper并添加一个新文件,open.ai.tsx内容如下:

import {OpenAI} from "@trigger.dev/openai";

export const openai = new OpenAI({
    id: "openai",
    apiKey: process.env.OPENAI_API_KEY!,
});
Enter fullscreen mode Exit fullscreen mode

这是我们由 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) => {
  }
});
Enter fullscreen mode Exit fullscreen mode

我们定义了一项名为 的新作业process.documentation.event,并添加了一个名为 URL 的必需参数 - 这是我们稍后要发送的文档 URL。

如您所见,该作业是空的,因此让我们向其中添加第一个任务。

我们需要抓取网站地图并返回。
抓取网站地图会返回我们需要解析的 HTML 代码。
为此,我们需要安装 JSDOM。

npm install jsdom --save
Enter fullscreen mode Exit fullscreen mode

并将其导入到文件顶部:

import {JSDOM} from "jsdom";
Enter fullscreen mode Exit fullscreen mode

现在,我们可以添加我们的第一个任务。

用 包装我们的代码非常重要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();
});
Enter fullscreen mode Exit fullscreen mode
  • 我们使用 HTTP 请求从 URL 中抓取整个 HTML。
  • 我们将其转换为 JS 对象。
  • 我们找到了站点地图的 URL。
  • 我们解析它并返回它。

接下来,我们需要抓取站点地图,提取所有 URL 并返回它们。
让我们安装Lodash一些用于数组结构的特殊函数。

npm install lodash @types/lodash --save
Enter fullscreen mode Exit fullscreen mode

以下是该任务的代码:

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)};
});
Enter fullscreen mode Exit fullscreen mode
  • 我们创建一个名为 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
            }
        });
    });
  },
});
Enter fullscreen mode Exit fullscreen mode
  • 我们从 URL 中抓取内容(之前从站点地图中提取)
  • 我们用JSDOM
  • 我们删除页面上存在的每一个可能的内容<script><style>
  • 我们抓取页面上的所有标题( h1, h2, h3, h4, )h5h6
  • 我们迭代标题并获取它们之间的内容。我们不想获取整个页面内容,因为它可能包含不相关的内容。
  • 我们创建页面原始文本的版本并将其保存到我们的数据库中。

现在,让我们对每个站点地图 URL 运行此任务。
触发器引入了一个名为 的功能batchInvokeAndWaitForCompletion
它允许我们批量发送 25 个项目进行处理,并且它会同时处理所有项目。以下是以下几行代码:

let i = 0;
for (const item of list) {
    await processContent.batchInvokeAndWaitForCompletion(
        'process-list-' + i,
        item.map(
            payload => ({
            payload,
        }),
        86_400),
    );
    i++;
} 
Enter fullscreen mode Exit fullscreen mode

我们手动触发了之前创建的 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');
});
Enter fullscreen mode Exit fullscreen mode

我们使用之前指定的标识符。

现在,让我们使用新数据在 ChatGPT 中创建一个新文件:

const file = await io.openai.files.createAndWaitForProcessing("upload-file", {
  purpose: "assistants",
  file: data
});
Enter fullscreen mode Exit fullscreen mode

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],
    });
});
Enter fullscreen mode Exit fullscreen mode
  • 我们首先检查是否有针对该特定 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,
        }
    });
});
Enter fullscreen mode Exit fullscreen mode

如果该 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);
        }
    });
  },
});
Enter fullscreen mode Exit fullscreen mode

我们已经完成了抓取和索引文件的后台作业创建🎉

询问助理

现在,让我们创建作业来询问我们的助手。

转到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};
        }
    }
});
Enter fullscreen mode Exit fullscreen mode
  • 该事件有三个参数
    • content- 我们想要发送给助手的信息。
    • aId- 我们之前创建的助手的内部 ID。
    • threadId- 对话的线程 ID。如您所见,这是一个可选参数,因为在第一条消息中,我们还没有线程 ID。
  • 然后,我们创建或获取前一个线程。
  • 我们在向助手提出的问题的主题中添加了一条新消息。
  • 我们运行线程并等待它完成。
  • 我们获取消息列表(并将其限制为 1),因为第一条消息是该对话中的最后一条消息。
  • 我们返回刚刚创建的消息内容和线程 ID。

添加路由

我们需要为我们的应用程序创建 3 个 API 路由:

  1. 派遣新的助手进行处理。
  2. 通过 URL 获取特定助手。
  3. 向助手添加新消息。

在 里面创建一个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});
}
Enter fullscreen mode Exit fullscreen mode

第一种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});
}
Enter fullscreen mode Exit fullscreen mode

这是非常基本的代码。我们从客户端获取消息、助手 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({
    });
}
Enter fullscreen mode Exit fullscreen mode

非常简单的代码即可获得所有助手。

我们已经完成了后端🥳

我们到前面去吧。


前端

创建前端

我们将创建一个基本界面来添加 URL 并显示已添加的 URL 列表:

SS1

主页

将 的内容替换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} />
  )
}
Enter fullscreen mode Exit fullscreen mode

这是一个简单的代码,它从数据库中获取列表并将其传递给我们的主组件。

接下来,让我们创建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>
    )
}
Enter fullscreen mode Exit fullscreen mode

让我们看看这里发生了什么:

  • 我们创建了一个新的接口,它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} />
    )
}
Enter fullscreen mode Exit fullscreen mode

我们迭代所有创建的助手。如果助手已经创建,则显示其名称。如果没有创建,则渲染该<Loading />组件。

加载组件Loading在屏幕上显示并对服务器进行长轮询,直到事件完成。

我们使用useEventRunDetailsTrigger.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>;
};
Enter fullscreen mode Exit fullscreen mode

这里发生了一些令人兴奋的事情:

  • 当我们创建新消息时,我们会自动将其渲染为“我们的”消息,但当我们将其发送到服务器时,我们需要推送事件 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
PREV
Laravue - 漂亮的 Laravel 仪表板
NEXT
🎉 17 个 Javascript 存储库助您成为世界上最好的开发人员🌍 成为更好的开发人员