← 返回
IT技术

基于 Next.js 与 Jev 构建智能 Bug 自动路由到 GitHub 的 AI 支持系统

✍️ zhirenhun 📅 2026/10/3 👁 227 阅读 ⏱ 56 分钟
基于 Next.js 与 Jev 构建智能 Bug 自动路由到 GitHub 的 AI 支持系统

每个网站都会收到反馈,但大部分反馈最终会被放到一些尴尬的地方。访客发现按钮坏了,给你发邮件。另有人在社交媒体上留言,说某个页面在手机上打不开。还有人在你的联系表单里填写功能建议,这条信息就这样夹在新闻通讯和收据之间,躺在你的收件箱里。

当你终于坐下来想要修复这些问题时,错误报告分散在三个不同的地方。其中一半缺少细节,而那些确实进入 GitHub 的报告,都是手动复制过去的,有时甚至还把访客的电子邮件地址直接粘进了公开的 Issue。

我想为自己的项目找个更好的方案,于是自己动手做了。IssueRelay 为任何 React 网站提供一个小型支持组件,访客可以在其中提问、报告错误或提出功能建议。每条报告首先会被保存到你自己的数据库中。随后,一个叫 Jev 的 AI 模型对其进行分类,一套写在代码中的简单规则决定它的去向,而你则在私有仪表盘里进行审查。

当你确认某条报告确实是错误时,IssueRelay 会为它生成一个干净的 GitHub Issue,并移除访客的私人信息。之后你在 GitHub 上关闭该 Issue 时,对应的支持工单也会同时关闭。

在本教程中,你将了解整个系统是如何工作的——从浏览器中的小组件,到让 GitHub 和仪表盘保持同步的 Webhook。你还会看到如何在大约 15 分钟内部署自己的副本。

IssueRelay 在 GitHub 上开源,地址是 andrewbaisden/issuerelay;其组件已发布到 npm,包名为 @issuerelay/widget;目前它已经在我的个人作品网站上投入生产使用。

我不会在这篇文章中粘贴全部代码库。仓库里已经包含了所有文件,安装向导也会一步步引导你完成安装。相反,我会展示那些承载核心思想的小代码片段,说明每段代码的作用,并分享我在构建、测试和部署过程中得到的经验。

目录

前置条件

To follow along and deploy your own copy, you should have:

您不需要是 AI 专家也能跟随学习。Jev 通过一个小型类型 SDK 使用,而大部分有趣的工作是普通的 Web 工程:数据库、验证、身份验证和 Webhook。

AI 支持系统如何帮助任何网站

支持系统听起来像是只有大公司才需要的东西,但它解决的问题几乎在每个网站都会出现。

  • 客户站点您为他人构建的客户站点会收到反馈,客户几天之后才转发给您,且没有任何细节。

  • 一个好的系统提供一个集中地点,所有报告都会送达此处;即使其他服务出现故障,也能保证每份报告安全,并对报告排序,让您把时间花在真正重要的事情上。

    AI 部分有助于排序,但它永不应负责。模型可能会有把握地出错,公开的 GitHub 问题也不是您想凭猜测创建的。因此 IssueRelay 始终遵循一条简单原则:AI 推荐,人工确认,代码强制执行规则。

    我们将构建什么

    以下是单份报告在 IssueRelay 中的流程:

    访客在您的站点打开小部件并选择一个主题:

    他们描述问题,并可选择留下姓名和电子邮件,以便您后续跟进:

    小部件将报告发送到您的 IssueRelay 平台,平台保存该报告并回复一个支持引用,访客稍后可引用该引用:

    此后,报告变为您仪表盘中的一个工单。Jev 对其进行分类,您审查它,如果是真正的 bug,点击一下即可在您的仓库中创建 GitHub issue。

    以下是完整功能列表:

    什么是 Jev?

    Jev 是 TypeSafe (TypeSafe) 提供的模型,专为 TypeSafe 所称的“System One”任务而构建:快速且有界的判断,而不是长篇开放式的写作。

    与其让模型写一段文字再去解析,不如给 Jev 提供一些状态和一组问题,每个问题都有固定候选答案。Jev 为每个问题选择一个答案,并返回它对每个选项所分配的概率。

    这种形式正是支持分流所需要的。工单可能是 bug、问题、功能需求、计费问题或垃圾信息。其优先级可以是低、中、高或致命。模型不需要编造文本,也不存在可能让它生成 issue 标题的提示词注入,事后也不需要清理自由文本。输出是一个标签和一个数字,你的代码可以同时检查这两项。

    它也既便宜又快速。根据当前文档,TypeSafe 将 Jev 定价为每十亿输入标记 42 美元,而一条支持消息只有几十个标记。IssueRelay 集成仅将访客的消息以及他们选择的主题发送给 Jev。它永不会发送姓名、电子邮件、工单 ID 或任何可用来识别个人的信息。

    技术栈

    IssueRelay 使用现代 TypeScript 构建,且与我用于自身项目的技术栈相同。如果你读过 我的其他文章,会发现很多内容很熟悉:

    单体仓库拆分为多个小包,每个包都有明确的职责:

    Package Responsibility
    apps/web 平台:公开 ticket API、仪表盘、安装以及 GitHub webhook
    packages/widget 发布到 npm 的浏览器小部件。它永不导入服务器代码。
    packages/support-contracts 由 widget 和 API 共享的请求和响应结构
    packages/db Drizzle 模式、迁移以及所有数据库查询
    packages/ai Jev 适配器、分流服务以及路由策略
    packages/github GitHub App 客户端、议题草稿、隐私门控以及 webhook 处理
    packages/auth Better Auth 设置、会话以及工作区成员检查

    这些边界比看起来更重要。React 组件不会直接与 GitHub、Jev 或数据库通信。浏览器代码永不包含密钥。AI 包无法导入数据库。

    保持这些边界严格,使系统测试和理解变得容易得多,也是 widget 能够发布到 npm 而不带入任何服务器代码的原因。

    报告在系统中的流转

    让我们跟踪一份报告,从访客浏览器一直追踪到已关闭 GitHub issue。

    步骤 1:Widget

    Widget 是一个普通 React 组件,你可以从 npm 安装:

    npm install @issuerelay/widget
    

    然后渲染一次即可,比如在根布局的客户端组件中:

    "use client";
    
    import {
      HttpSupportSubmissionClient,
      SupportWidget,
    } from "@issuerelay/widget";
    
    const submissionClient = new HttpSupportSubmissionClient({
      apiBaseUrl: "https://your-issuerelay.vercel.app",
    });
    
    export function Support() {
      return (
        
      );
    }
    

    HttpSupportSubmissionClient 是与您平台通信的部分。它在不使用 Cookie 或凭据的情况下,将每份报告发送到您的 IssueRelay API,并为每份报告分配一个提交 ID,以便在网络错误后重试不会创建第二张工单。

    SupportWidget 是访客可见的按钮和面板。projectKey 告诉平台该报告属于哪个项目。它是公开的身份标识,而非密码,因此可以安全地放入站点代码。真正的防护在服务器端,它仅接受来自您为该项目列出的站点地址的报告。

    由于提交客户端是在浏览器中创建的,所以需要 "use client" 这一行。在 Next.js App Router 中,您可以像下面这样将小部件包裹在您自己的客户端组件中,然后从布局渲染该组件。

    实际上,该小部件在带有自身捆绑样式的 Shadow DOM 中渲染,因此您的站点不需要 Tailwind 或 CSS 导入,且您的样式不会意外地覆盖它。

    您也不必手动编写这段代码。IssueRelay 的项目设置页面会直接展示此代码片段,并已填入您的平台地址和项目密钥。

    步骤 2:先保存,后思考

    当报告到达 API 时,IssueRelay 首要做的就是将其保存。不进行分类,也不发送到其他地方,只是在事务中将其存入 PostgreSQL。

    这是整个系统中最重要的设计决策。AI 提供商可能会宕机。GitHub 也可能宕机。如果平台在保存报告之前先调用 Jev,而 Jev 超时,访客的消息就会丢失,且他们永远不会知道。

    所以规则很简单:只有在报告安全存储后才会被接受,之后任何步骤的失败都不会删除它。 如果 Jev 不可用,工单将在仪表盘中等待,直至您再次运行 triage。

    在保存之前,API 会进行以下几项检查:

    步骤 3:使用 Jev 进行 triage

    票据存储后,triage 服务会请求 Jev 对其进行分类。以下是 Jev 适配器的核心代码,摘自 packages/ai/src/jev-classifier.ts(为节省空间略作删减):

    const response = await this.client.systemOne({
      state: {
        message: input.message,
        category_hint: input.categoryHint ?? null,
      },
      questions: {
        ticket_type: choice(
          "What kind of support ticket is this? The visitor-selected category hint is a weak signal, not ground truth: judge from the message content.",
          {
            question: "The visitor asks how something works or what something is.",
            bug: "Something is broken, errors, or behaves incorrectly.",
            feature_request: "The visitor requests new functionality or an improvement.",
            spam: "Unsolicited advertising, scams, or irrelevant bulk content.",
            // ...account, billing, feedback, and other
          },
        ),
        severity: choice("How urgent is this ticket?", {
          low: "Minor inconvenience, cosmetic issue, or general question.",
          medium: "Broken functionality with a workaround, or a routine request.",
          high: "Major functionality unavailable, no workaround, time-sensitive.",
          critical: "Security breach, data loss, privacy exposure, or billing harm.",
        }),
      },
    });
    

    systemOne 是 TypeSafe SDK 用于有界问题的调用。state 对象是 Jev 允许看到的全部内容:访客选择的消息和主题。

    注意缺失的内容。这里没有姓名、邮箱和票据 ID,因为它们对分类没有帮助,且都会把私人数据带离您的平台。

    每个 choice 定义一个问题及其可能的答案。每个标签旁的说明告诉 Jev 该标签的含义。票据类型问题还会告诉 Jev 将访客选择的主题视为弱提示,因为人们常在提问时选择“报告错误”,而在明显故障的情况下选择“提问”。

    返回的结果不会被自动信任。适配器会使用 Zod schema 验证响应,检查两个答案都是允许列表中的标签,并使用 Jev 为所选类型标签给出的概率作为置信度分数。

    如果该概率缺失或不在 0 到 1 范围内,则结果会被拒绝,票据将保持在审核状态,而不是得到一个虚构的数字。

    步骤 4:代码制定决策

    Jev 会推荐一种类型和一个严重程度。它不会决定票据的去向或是否成为 GitHub 问题。此工作由 packages/ai/src/policy.ts 中的普通函数完成:

    export function routeForType(type: TicketType): TicketRoute {
      switch (type) {
        case "bug":
          return "engineering";
        case "feature_request":
          return "product";
        case "spam":
          return "ignore";
        default:
          return "support";
      }
    }
    
    export function evaluateGitHubEscalation(input: {
      type: TicketType;
      route: TicketRoute;
      confidence: number;
    }): EscalationEvaluation {
      const reasons: string[] = [];
      if (input.type !== "bug") reasons.push(`type is ${input.type}, not bug`);
      if (input.route !== "engineering") reasons.push(`route is ${input.route}, not engineering`);
      if (!(input.confidence >= GITHUB_ESCALATION_CONFIDENCE_THRESHOLD)) {
        reasons.push(`confidence ${input.confidence} is below ${GITHUB_ESCALATION_CONFIDENCE_THRESHOLD}`);
      }
      return { eligible: reasons.length === 0, reasons };
    }
    

    routeForType 将每种票据类型映射到一个队列。错误单进入工程,功能请求进入产品,垃圾信息被隔离,其余的进入支持。由于这是一个普通的 switch 语句,你可以阅读、测试和修改它,而无需触及 AI。

    evaluateGitHubEscalation 决定票据是否甚至允许成为 GitHub issue。它必须是错误单,必须在工程队列中,且其置信度至少为 0.9。与其返回裸露的 true 或 false,它会收集票据失败的原因,仪表盘会显示这些原因,因此你始终知道为什么 创建 GitHub issue 按钮不可见。

    0.9 阈值位于一个配置文件中,其中有一条注释说明这是未校准的起点,而非测得的准确率。我想在代码中保持诚实:模型得分 0.99 不意味着模型在 99% 的时间里是正确的。

    步骤 5:仪表盘中的人工审查

    每张票据都会进入私人仪表盘。项目页面显示每种状态下的票据数量:

    每个项目都有一个票据列表,可按状态、路由、类型、严重程度和引用进行过滤:

    打开票据时,会显示访客的报告、当前的 AI 分类、完整的分类历史以及一切发生事件的时间线。你可以重新运行 triage,解决票据,或记录一条会更改路由、状态或 GitHub 建议的审查决定。

    我关注的一个细节是:人工决策存储在自己的表格中,包含作者和必需的原因。AI 的历从不被重写。如果你覆盖了 Jev,你仍然可以看到 Jev 确切说过什么以及何时说的,这对于想了解模型实际表现如何非常重要。

    仪表盘受 Better Auth 保护,且每次读写都限定在工作区内。变更操作需要同源请求,只有工作区所有者才能发布到 GitHub。

    步骤 6:从错误报告到 GitHub issue

    当票据通过策略时,仪表盘会显示将要创建的精确 issue 的预览:

    仔细查看该截图。访客留下了他们的姓名和电子邮件,虽然这两项信息在上面的仪表盘中可见,但在 issue 预览中却完全看不到。

    这不是巧合。在创建任何议题之前,报告会经过 packages/github/src/privacy.ts 中的隐私门禁,该门禁会检查电子邮件地址、电话号码、信用卡号码、私钥、API 令牌、JSON Web 令牌以及密码分配。它还会将报告与访客提交的联系信息进行核对,这样 "Hi, Sam Visitor here" 就不会把姓名泄露到公开议题中。如果发现任何内容,则会阻止预览,且不会发布任何内容。

    当你点击 创建 GitHub issue 时,会运行几项额外的防护措施:

    这是 IssueRelay 在我的作品集公开仓库中,根据访客报告创建的一个真实议题。它由 App 的机器人创建,标记为 bug。它包含报告和 AI 的分类,但不含任何联系信息。

    步骤 7:保持 GitHub 与仪表板同步

    最后这一环闭合了循环。当你在 GitHub 上关闭议题时,GitHub 会向 IssueRelay 发送 webhook,票据随之转为已解决状态。重新打开议题后,票据会返回队列。

    Webhook 端点默认是公开的,因此它首先要做的就是验证请求确实来自 GitHub。以下是来自 packages/github/src/webhook-auth.ts 的验证函数:

    export function verifyWebhookSignature(input: {
      secret: string;
      rawBody: Uint8Array;
      signatureHeader: string | null;
    }): boolean {
      const { secret, rawBody, signatureHeader } = input;
      if (!secret || !signatureHeader?.startsWith("sha256=")) {
        return false;
      }
      const hex = signatureHeader.slice("sha256=".length);
      if (!/^[0-9a-f]{64}$/.test(hex)) return false;
      const expected = createHmac("sha256", secret).update(rawBody).digest();
      const actual = Buffer.from(hex, "hex");
      if (expected.length !== actual.length) return false;
      return timingSafeEqual(expected, actual);
    }
    

    GitHub 使用只有 GitHub 和你的平台知道的密钥对每次投递进行签名,并在 X-Hub-Signature-256 头中发送该签名。此函数会在 原始请求字节 上计算自己的 HMAC SHA256 签名,然后进行比较。

    有两个细节容易出错。首先,必须在 GitHub 发送的原始字节上计算签名,即在任何 JSON 解析之前;因为解析和重新格式化会改变字节。其次,比较使用 timingSafeEqual,无论第一个字节还是最后一个字节不同,所需时间相同,因此攻击者无法通过测量响应时间逐字猜测签名。该函数对任何失败都返回 false,且不说明具体原因,因而不会泄露任何信息。

    签名检查通过后,IssueRelay 会记录每个投递的 ID,从而忽略重复投递。它仅更新属于匹配的 App 安装和仓库的议题,并按照事件在 GitHub 上发生的顺序处理,而不是它们到达的顺序。

    如何部署你自己的 IssueRelay

    你可以在 Vercel 和 Neon 上大约 15 分钟内运行自己的 IssueRelay。完整的操作指南(包括故障排除)位于 docs/SELF_HOSTING.md。以下是简要版本。

    步骤 1:部署

    推荐的方式是 README 中的 使用 Vercel 部署 按钮。它会将仓库复制到你的 GitHub 账户,添加一个 Neon 数据库,并要求提供三个随机密钥。

    第一次构建会故意失败,因为 Vercel 的克隆界面没有 Root Directory 设置;你需要在项目设置中将 Root Directory 设为 apps/web 并重新部署。生产构建随后会为你创建所有数据库表。

    指南还介绍了 Fork and Import 路径,使未来的更新只需一次点击,但该路径尚未完成端到端测试。

    步骤 2:运行设置页面

    在你的新站点上打开 /setup。仅在数据库中没有任何账户且你输入了在部署过程中创建的 SETUP_TOKEN 时才能使用,这样先找到你 URL 的人就不能声称拥有你的平台。它会创建你的所有者账户和第一个项目,然后显示你的 widget 密钥以及可直接粘贴的 widget 代码。

    站点地址字段默认以 http://localhost:3000 开头,这是你本地运行的 Next.js 应用的地址。请同时填写你的线上地址,例如 https://my-site.vercel.app 或你自己的域名。如果忘记填写,widget 会礼貌地告诉访客“无法发送你的消息”,因此当消息未送达时,这应该是第一个检查的地方。

    步骤 3: 创建 GitHub App

    手动设置 GitHub App 时会有一些容易出错的地方。最严重的错误是忘记订阅 Issues 事件,我在测试时自己就踩到了这个坑。因此 IssueRelay 提供了一个命令,可根据 manifest 为您创建该 App:

    pnpm github:create-app --platform https://your-issuerelay.vercel.app
    

    这会在你的浏览器中打开 GitHub,所有内容已填好:一个私有 App,拥有写入议题和读取元数据的权限,已订阅 Issues 事件,其 webhook 指向你的平台。

    你点击 Create GitHub App,GitHub 会重定向回由该命令启动的临时本地服务器,命令会把 App ID、私钥和 webhook 密钥写入 git 忽略的文件。它不会在终端打印这些信息。终端随后会列出下一步设置阶段。

    步骤 4: 添加你的密钥并重新部署

    将这三个 GitHub App 值以及你的 TYPESAFE_API_KEY 添加到 Vercel 项目环境变量,然后重新部署。Jev 对于 GitHub 议题是必需的:没有它,报告仍会出现在你的仪表盘,但没有一个能成为议题。

    步骤 5: 连接你的仓库

    在 widget 将要运行的网站仓库上安装该 App,然后在控制面板中打开你的项目 Settings 页面并进行连接。该页面会询问 GitHub 哪个安装和哪个仓库 ID 对应该名称,这样拼写错误就不会将错误的仓库关联起来。

    步骤 6: 安装 Widget

    使用设置页面上的代码在你的站点上安装 widget,并发送你的第一份报告。

    以后要保持你的副本更新,只需从主仓库拉取更改。该指南涵盖了使用 Deploy 按钮创建的副本所需的一次性步骤,因为这些副本不是 GitHub 分支。

    在真实网站上运行

    演示只是一方面,但我想真正使用 IssueRelay,因此 widget 现在运行在我的作品集网站上,地址是 andrewbaisden.com:

    IssueRelay 小部件在作者作品集网站角落打开,背景是一幅插画的伦敦街景

    网站设计可能会变化,因此如果你在未来阅读这篇文章,之前的构建可以在我的 GitHub 上找到。

    安装它让我学到了一些东西。作品集在测试时仍然使用 React 18,而 App Router 已经在使用 React 19 进行渲染。于是我先将其升级到 React 19,并在添加 widget 之前确保所有现有测试都通过。widget 与网站亮色和暗色主题相匹配,位于右下角,并在作品集仓库中拥有自己的单元测试和浏览器测试。

    然后我像访客一样进行了测试。我从现场站点发送了三份真实报告:一个问题、一个 bug 和一个功能请求。Jev 按我预期的方式将这三者全部分类,得分在 0.95 到 1.00 之间,策略将它们分别路由到支持、工程和产品。该 bug 成为了我公开作品库中的 issue #3,即之前截图中显示的那个 issue。我在提交该报告时附上了姓名和邮箱,但公开的 issue 中并未出现这两项信息。

    端到端测试(以及我所学到的)

    我不想要一个只能在自己机器上运行的项目,因此测试贯穿了每个阶段,而不是留到最后才进行。

    测试套件包含以下几层:

    我对该实时旅程进行了三次测试:首先通过隧道针对本地机器,然后针对生产环境,最后针对仅按照设置指南部署的全新副本。三次测试均以 20 分满分通过。

    然而,比通过更有趣的是每个阶段发现的问题:

    给我印象最深的教训: 按照自己的文档逐字部署,能够发现自动化测试无法发现的 bug。 上面提到的每个部署问题在自动化测试中都是不可见的,但在真实用户按照指南操作时就会显而易见。

    它是如何构建的:阶段与 AI 辅助开发

    IssueRelay 采用分阶段构建,每个阶段结束时都会输出书面交接文档,随后才能进入下一阶段:

    阶段 结果
    0 产品定义、架构、决策、安全以及测试计划
    1 和 2 Monorepo 基础、领域模型、PostgreSQL 模式以及种子数据
    3 和 4 小部件、演示站点以及公开的票据 API
    5 和 6 使用 Jev 进行 AI 分类以及运营仪表盘
    7 和 8 确认的 GitHub 升级以及签名的 Webhook 同步
    9 在临时仓库中对完整流程进行实时验证
    10 生产环境加固
    11 和 12 验证并将小部件发布到 npm
    Deploy 在生产环境中使用 Vercel、Neon 和 Resend
    13 在我的作品集中安装小部件
    14 和 15 自用(持续进行)
    16 自托管:Deploy 按钮、设置页面、项目设置以及 App manifest 命令

    我的开发环境

    我在终端中完成了大部分工作。我的环境如下:

    我在 Claude Code 中使用 Claude Opus 5.5 作为构建 IssueRelay 的主要模型。它来自于为模型提供更好的指令和更适合其工作的结构。在进行代码审查以及在签 off 前检查阶段时,我还使用了其他模型,包括 GPT-6 Sol 和 Grok,以及各种其他前沿和免费模型。

    另一个模型以全新视角阅读相同代码时,发现了实际问题。例如,Grok 对第 7 和第 8 阶段的审查提出了 15 项问题。其中 7 项有效,包括在声明 issue 创建时的竞争条件以及可能被猜测的 issue 标记,全部在我继续之前已修复。还有 3 项部分有效,5 项因书面理由被推迟。

    Anthropic 新发布的 Sonnet 5.5 以及 OpenAI 的 GPT-6.1 Sol 未在本项目中使用。

    更好的提示词如何改进了代码库

    质量的最大提升并非来自更智能的模型。

    它来自于为模型提供更好的指令和更适合其工作的结构。

    以下是有效的做法:

    把 Widget 发布到 npm

    IssueRelay 中只有 widget 这一部分对外发布,也就是 @issuerelay/widget,其余内容都以私有形式留在 monorepo 中。

    我不想发布一个只能在自己工作区里跑的东西,所以发布检查会构建出 npm 最终收到的那个 tarball,并对它进行审查。

    这个包必须且只能包含五个文件,不得引用私有包、Node 内置模块、环境变量,也不能包含任何看起来像密钥的内容。随后它必须在仓库之外的两个全新应用中完成安装并正常运行,其中一个应用还启用了严格的 Content Security Policy,且要求零违规。

    版本发布通过 GitHub Actions 完成,使用 npm 的 trusted publishing 和 provenance 机制,因此不存在可能泄露的长期 npm token。

    最终得到的包压缩后约 10 KB,无需任何 CSS 配置,只依赖 React 和 React Hook Form。widget README 详细记录了每一个 prop。

    接下来做什么

    IssueRelay 的自托管部分已经完成,我每天都在自己的作品集网站上使用它。接下来我想探索的方向包括:

    结论

    在本教程中,您看到了如何构建一个 AI 支持系统,它将零散的网站反馈转化为已审查的工单,并将确认的错误路由到 GitHub。在此过程中,您学到了如何:

    了解 IssueRelay 的最佳方式是亲自尝试。您可以探索 GitHub 上的代码,使用自托管指南部署您自己的副本,并且使用 npm install @issuerelay/widget 从 npm。如果它对您有帮助,为仓库点星 siempre 受到赞赏。

    ——

    广告 广告
    🧑‍💻

    zhirenhun

    一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。