← 文章 / 编程开发
freeCodeCamp 6小时前 · 2026-10-03 10:35:16 · 4 阅读

使用 Next.js 和 Jev 构建自动将 Bug 路由至 GitHub 的 AI 支持系统

每个网站都会收到用户反馈,而这些反馈往往分散在 awkward 的地方。有人发现按钮坏了,发邮件通知你;另有人发推说某个页面在手机上打不开;还有人在联系表单里提交功能建议,结果这条信息就躺在收件箱里,夹在订阅邮件和购物的确认单中间。

等你坐下来想修 bug 时,发现反馈分散在三个地方。其中一半缺少必要信息,而最终录入 GitHub 的那部分还是手动复制粘贴的,有时甚至把用户邮箱地址直接贴进了公开 issue 里。

我给自己做的项目建了一套更好的系统,叫 IssueRelay。它给任何 React 网站添加一个轻量的反馈小部件,让访客可以提问、报告 bug 或提出功能建议。所有提交先存进你自己的数据库。之后由一个名为 Jev 的 AI 模型做分类,再由代码里的简单规则决定反馈流向,最终你在后台仪表盘做人工审核。

当你确认某个反馈确实是 bug 时,IssueRelay 会自动创建一个干净的 GitHub issue,并剥离掉访客的私人信息。日后你在 GitHub 关闭这个 issue,对应的客服工单也会同步关闭。

本教程将带你了解整套系统的工作方式,从浏览器里的小部件到负责同步 GitHub 和后台仪表盘的 webhook。同时,也会演示如何在大约 15 分钟之内部署你自己的版本。

IssueRelay 是开源项目,GitHub 仓库位于 andrewbaisden/issuerelay;小部件已发布到 npm,包名是 @issuerelay/widget。我现在的个人网站就在线上运行着它。

这篇文章不会贴全量代码。完整文件都在仓库里,部署文档也已按步骤写清楚。这里只摘录承载核心思路的几小段代码,解释它们各自做什么,并分享我在开发、测试和部署过程中踩过的坑。

The IssueRelay support widget open on a website, showing the Ask a question, Report a bug, and Suggest a feature options

Table of Contents

前置准备

如果想跟着做并部署一份自己的副本,你需要具备:

  • React、Next.js 和 TypeScript 的基础使用经验:平台基于 Next.js App Router 构建,组件本身是一个 React 组件。

  • Node.js 24 和 pnpm:如果要在本地运行项目,或使用创建 GitHub App 的命令,需要先安装。

  • 一个 GitHub 账号:以及你要安装组件的网站或应用对应的仓库,确认后的 bug 会以 issue 的形式提交到那里。

  • 一个 Vercel 账号:免费的 Hobby 套餐就够用。你需要通过 Vercel 的市场添加一个 Neon PostgreSQL 数据库,Neon 同样有免费套餐。

  • 一个 TypeSafe 账号:在 typesafe.ai 注册,用于获取 Jev——负责给报告分类的 AI 模型。在报告能转换为 GitHub issue 之前,你需要先从 TypeSafe 控制台获取 API key。

  • 一个 React 网站:能添加组件即可,Next.js 网站是最容易上手的起点。

  • 可选:一个 Resend 账户:如果你需要发送密码重置等账户相关邮件。

跟着本文不需要你是 AI 专家。Jev 通过一个小型的、类型化的 SDK 使用,大部分有趣的工作其实是普通的 Web 工程:数据库、验证、认证和 Webhooks。

AI 客服系统如何帮助任何网站

客服系统听起来像是只有大公司才需要的东西,但它解决的问题几乎出现在每个网站上:

  • 作品集网站会收到招聘者的信息、关于项目的提问,以及关于某些浏览器上页面报错的报告。

  • SaaS 产品会收到混杂了 Bug 报告、账单问题和功能请求的信息,每一条都需要不同的人或流程来处理。

  • 文档网站会收到“这个例子不起作用”的报告,而这些其实是产品本身的 Bug。

  • 开源项目会遇到不愿自己在 GitHub 上提 Issue,但很乐意点击网站上按钮的用户。

  • 你为别人开发的客户网站,会收到客户几天后转交给你、且没有细节的反馈。

一个好的系统能提供一个统一的入口来接收所有报告,确保即使其他服务故障,每条报告都能安全保存,并对报告进行分类,让你把时间花在与重点事项相关的报告上。

AI 部分有助于分类,但绝不能让它主导。模型可能会自信地犯错,而公开 GitHub Issue 不是你想靠猜测就创建出来的。因此,IssueRelay 全程遵循一个简单的原则:AI 推荐,人类确认,代码执行规则。

我们将构建什么

以下是单条报告在 IssueRelay 中的完整旅程:

这是单条报告在 IssueRelay 中的旅程

访问者打开你网站上的小部件并选择一个主题:

演示站点上打开的小部件,包含三个主题:提问、报告 Bug 和建议功能

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

The Report a bug form in the widget with a message, a name, and an email address filled in

小组件将报告发送至 IssueRelay 平台,平台会将其保存,并返回一个支持参考编号,访客日后可以引用该编号进行查询:

The widget confirming Message received with the support reference SUP-9

此后,该报告在后台仪表盘中生成一张工单。Jev 负责自动分类,你负责复核,若确认为真实 Bug,只需点击一下,即可在代码仓库中创建 GitHub Issue。

完整功能列表如下:

  • 可嵌入小组件:基于 React 组件构建并运行在 Shadow DOM 中,无需额外配置 CSS,也不会与宿主网站样式冲突。兼容 Next.js App Router,并支持严格的 Content Security Policy(CSP)。

  • 持久化接收:所有报告在触发后续流程前,首先存入 PostgreSQL 数据库。支持去重、按项目限流,以及配置允许来源的站点白名单。

  • 受限 AI 分诊:仅根据消息内容,Jev 即可推荐对应的 Bug 类型与严重等级。

  • 私有仪表盘:提供筛选功能、分类历史记录,并将人工复核结论与 AI 输出分开存储。

  • 审慎的 GitHub 升级流程:由 GitHub App 负责创建 Issue,但必须经负责人确认预览后才生效;用户联系方式不会流出 IssueRelay 平台。

  • 双向同步:在 GitHub 上关闭或重新打开 Issue 时,通过签名 Webhook 自动同步更新对应工单状态。

  • 自托管部署:通过一键部署按钮、首次运行配置页和设置页,可在不直接接触数据库的前提下,自行托管一套完整实例。

什么是 Jev?

Jev 是 TypeSafe 推出的模型,专为 TypeSafe 所称的"系统 1(System One)"任务设计:擅长快速、边界明确的判断,而非长篇开放式的写作生成。

与其让模型写一段话再想办法解析,不如给 Jev 提供一些状态和一组问题,每个问题都有固定的候选项列表。Jev 为每个问题选出一个答案,并返回它赋予每个选项的概率。

这种输出结构恰好就是工单分类所需要的。一张工单要么是 bug,要么是问题咨询、功能请求、账单纠纷,要么是垃圾信息;优先级也只有低、中、高、紧急四种。模型没有机会凭空编造文本,也不存在通过 prompt injection 诱导它生成 issue 标题的风险,更不会有自由文本需要事后清洗。输出就是一个标签加一个数字,代码可以对两者进行校验。

它还便宜、速度快。写这篇文章时,TypeSafe 上 Jev 的定价是每十亿输入 token 42 美元,而一条支持消息不过几十个 token。IssueRelay 集成只把访客的消息和他们选择的主题发给 Jev,绝不会发送姓名、邮箱、工单 ID 或任何能识别个人身份的信息。

技术栈

IssueRelay 采用现代化 TypeScript 技术栈构建,这也是我自己项目一直在用的技术栈。如果你读过我的其他文章,会觉得很眼熟:

  • Next.js 16(App Router)和 React 19:用于平台和仪表盘

  • 全项目使用严格模式的 TypeScript,并用 Zod 校验所有跨信任边界的输入:公开 API 请求、环境变量、AI 输出以及 GitHub webhook 载荷

  • PostgreSQL + Drizzle ORM,SQL 迁移经过代码审查

  • Better Auth:处理仪表盘账户

  • 使用官方 TypeSafe SDK 对接 Jev,用 Octokit 对接 GitHub App

  • Vitest、React Testing Library 和 Playwright 负责测试,Biome 负责 lint 和格式化

  • pnpm workspaces:把所有内容统一放在一个 monorepo 里

  • 生产环境使用 Vercel、Neon 和 Resend

monorepo 拆分成多个小 package,每个 package 职责单一:

Package 职责
apps/web 平台本体:公开的工单 API、仪表盘、设置功能以及 GitHub webhook
packages/widget 发布到 npm 的浏览器插件,永不导入服务端代码
packages/support-contracts 插件和 API 之间共享的请求和响应结构
packages/db Drizzle 的数据库结构、迁移和所有的查询语句
packages/ai Jev 的适配器、问题分级服务和路由策略
packages/github GitHub App 客户端、issue 草稿、隐私检查以及 webhook 处理
packages/auth Better Auth 配置、会话管理和工作区成员权限检查

边界划分的重要性远比表面看起来大。React 组件直接不碰 GitHub、Jev 或者数据库,浏览器端代码也不包含任何敏感信息,AI 包无法导入数据库代码。

严格遵守这些边界让系统更容易测试和理解决逻辑,这也是插件可以独立发布到 npm 而无需捆绑服务端代码的原因。

一条问题是如何通过系统的

我们来追踪一条问题,从访客的浏览器一路走到 GitHub 上关闭的 issue。

第一步:插件

这个插件就是普通的 React 组件,你可以从 npm 安装:

npm install @issuerelay/widget

然后渲染它一次,比如在你的根布局中的 client component 里:

"use client";

import {
  HttpSupportSubmissionClient,
  SupportWidget,
} from "@issuerelay/widget";

const submissionClient = new HttpSupportSubmissionClient({
  apiBaseUrl: "https://your-issuerelay.vercel.app",
});

export function Support() {
  return (
    <SupportWidget
      projectKey="pk_your_project_key"
      submissionClient={submissionClient}
      theme="system"
      position="bottom-right"
    />
  );
}

HttpSupportSubmissionClient 是负责与你平台通信的部分。它向 IssueRelay API 提交问题报告,不带 cookies 和认证信息,并给每条报告一个唯一的提交 ID。这样在发生网络错误重试时,不会创建重复的 ticket。

SupportWidget 是访客看到的按钮和面板。projectKey 用于告知平台该报告属于哪个项目。它只是公开标识,并非密码,因此可以放心地写在网站代码里。真正的安全保护在服务端,平台只会接受来自你为该项目配置的站点地址的报告。

"use client" 指令之所以存在,是因为提交客户端在浏览器中创建。在 Next.js App Router 中,你需要将该小部件封装在一个小型客户端组件里,并从此布局中渲染该组件,示例如下。

在底层实现上,小部件渲染在 Shadow DOM 中并自带打包后的样式,因此你的网站无需引入 Tailwind 或任何 CSS 文件,现有的样式也不会意外覆盖小部件的外观。

你也不必手写这段代码。IssueRelay 的项目设置页面直接展示了这一代码片段,且已预先填好你的平台地址和项目密钥。

第 2 步:先存储,后思考

当报告到达 API 时,IssueRelay 首先执行的操作是将其保存。不进行分类,不发送给任何地方,仅仅是在事务中将数据存入 PostgreSQL。

这是整个系统中最重要的设计决策。AI 服务商可能会故障,GitHub 也可能不可用。如果平台在保存报告前就调用 Jev,而 Jev 超时了,访客的消息就会丢失,且他们对此毫不知情。

因此规则很简单:只有报告安全存储后才会被接受,后续任何步骤的失败都不能导致数据丢失。如果 Jev 宕机,工单就会在仪表板中等待,直到你再次运行分诊流程。

在保存之前,API 会检查以下几项内容:

  • 请求体符合共享的 Zod 契约,无效输入会被拒绝并返回明确的错误信息。

  • 项目密钥存在,且请求的来源地址是该项目允许的站点地址之一。

  • 该项目未达到速率限制。

  • 提交 ID 未被使用过。重复提交不会创建新工单,而是返回原工单引用。

第 3 步:使用 Jev 进行分诊

工单存储完成后,分诊服务会请求 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(
      "这是什么类型的支持工单?访客选择的分类提示只是弱信号,并非最终依据:请根据消息内容判断。",
      {
        question: "访客询问某项功能如何使用或某样东西是什么。",
        bug: "东西坏了、报错,或行为不正常。",
        feature_request: "访客请求新功能或改进。",
        spam: "未经请求的广告、诈骗或无关的批量内容。",
        // ...账户、账单、反馈等选项
      },
    ),
    severity: choice("这个工单有多紧急?", {
      low: "小麻烦、外观问题或一般性咨询。",
      medium: "功能有问题但有变通办法,或常规请求。",
      high: "重要功能不可用、没有变通办法、有时间敏感性。",
      critical: "安全漏洞、数据丢失、隐私泄露或账单损失。",
    }),
  },
});

systemOne 是 TypeSafe SDK 中用于限定式提问的调用。state 对象包含 Jev 能看到的全部信息:消息内容和访客选择的分类。

注意这里没有的东西。没有姓名、没有邮箱、也没有工单 ID,因为它们对分类毫无帮助,而且一旦传出去就是离开你平台的隐私数据。

每个 choice 定义一个问题及其可选项。每个标签旁边的描述告诉 Jev 这个标签的含义。工单类型的问题还特别告诉 Jev,访客选择的话题只能当作弱提示——因为人们经常把提问选成"报告 bug",或者把明显是故障的问题选成"咨询"。

返回的结果不会直接采信。适配器会用 Zod schema 校验响应,确认两个答案都是允许列表里的标签,并把 Jev 给所选类型标签的概率作为置信度分数。

如果这个概率缺失,或者不在 0 到 1 的范围内,结果就会被拒绝,工单留在待审核状态,而不是拿到一个编造出来的数字。

A ticket in the dashboard after triage: Jev classified it as a medium severity bug with a confidence of 1.00 and recommended it for GitHub

第四步:由代码做出决策

Jev 负责推荐工单类型和严重程度,但不决定工单去向或是否转为 GitHub issue。这项职责由 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 将每种工单类型映射到相应队列:Bug 转工程团队,功能请求转产品团队,垃圾信息直接忽略,其余归支持团队。由于这是一个常规的 switch 语句,你可以轻松阅读、测试和修改它,而无需触碰 AI 部分。

evaluateGitHubEscalation 判断工单是否有资格成为 GitHub issue。它必须是一个 Bug,位于工程队列,且置信度至少为 0.9。该函数不直接返回简单的 true 或 false,而是收集工单不符合条件的所有原因,这些原因会在仪表盘中展示,让你始终清楚 Create GitHub issue 按钮为何缺失。

0.9 的阈值存储在一个配置文件中,并附注释说明这只是未经校准的起点,而非实测准确率。我希望在代码中保持这种诚实:模型给出的 0.99 分并不意味着它 99% 的情况下都是正确的。

第五步:仪表盘中的人工审核

所有工单都会汇入一个私有仪表盘。项目页面显示各工单状态下的数量:

仪表盘项目页面,展示两个项目及其各工作流状态下的工单数量

每个项目都有一份工单列表,支持按状态、路由、类型、严重级别和引用进行筛选:

某项目的工单列表,带有筛选器及展示状态、路由、AI 类型、严重度和置信度的工单表格

点击打开工单,可查看访客的原始报告、当前的 AI 分类、完整的分类历史,以及所有操作的时间线。你可以重新运行分诊、标记工单为已解决,或记录人工审查决定,该决定可以更改路由、状态或 GitHub 推荐建议。

我特别注重的一点是:人工决策存储在独立的表中,必须填写操作人和必填理由。AI 的历史记录永远不会被篡改。即使你手动覆盖了 Jev 的判断,仍能准确看到 Jev 当时说了什么、在何时说的,这在评估模型实际表现时至关重要。

仪表盘由 Better Auth 保护,所有读写操作均限定在工作区范围内。数据变更请求必须来自同源,且只有工作区所有者才能将内容发布到 GitHub。

步骤 6:从 Bug 报告到 GitHub Issue

当工单通过策略校验后,仪表盘会展示即将创建的 Issue 预览:

GitHub 升级部分,展示 Issue 标题和正文预览,访客的姓名和邮箱未出现在 Issue 中

仔细看那张截图。访客留下了姓名和邮箱,虽然这些在上方的仪表盘中可见,但在 Issue 预览中完全没有出现。

这不是巧合。在创建任何 issue 之前,报告都会先经过 packages/github/src/privacy.ts 中的隐私检查,扫描邮箱、电话号码、银行卡号、私钥、API token、JSON web token 以及密码赋值等内容。它还会把报告与访客提交的联系信息做比对,因此"Hi, Sam Visitor here"这样的自我介绍不会把姓名泄露到公开 issue 里。一旦发现敏感信息,预览就会被拦截,什么都不会发布。

当你点击 Create GitHub issue 时,还有几重保障措施:

  • 使用 GitHub App 而非个人 token:App 只安装在你指定的仓库上,仅有写 issue 和读取元数据的权限,此外什么都不能做。

  • 先占位再创建:在调用 GitHub 之前,工单会先在数据库中标记为 creating,这样即使连点两次也绝不会创建出两个 issue。

  • 隐藏标记:每个 issue 正文末尾都带有一个与工单绑定的不透明 HTML 注释。如果请求超时、结果未知,IssueRelay 会先在自己 App 创建的内容中搜索这个确切的标记,确认无误后才重试。它绝不会盲目重试一个可能已经创建过的 issue。

升级后的工单,时间线中显示了关联的 GitHub issue 和升级事件

下面是 IssueRelay 根据访客报告在我的作品集公开仓库中创建的一个真实 issue。它由 App 的机器人创建,打上了 bug 标签,包含报告内容和 AI 的分类结果,但没有任何联系信息:

IssueRelay 机器人在公开仓库中创建的真实 GitHub issue,包含摘要、报告、上下文,以及联系信息绝不公开的说明

第 7 步:让 GitHub 和控制台保持同步

最后一步形成闭环。当你在 GitHub 上关闭 issue 时,GitHub 会向 IssueRelay 发送一个 webhook,对应工单随即变为已解决。重新打开 issue,工单则回到队列中。

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 会用一个只有自己和你的平台知晓的密钥对每次推送进行签名,并将该签名放在 X-Hub-Signature-256 请求头中。上述函数会自行计算针对原始请求字节的 HMAC SHA256 签名,然后与请求头中的签名进行比对。

这里有俩细节很容易踩坑。第一,签名必须基于 GitHub 发送的原始字节计算,不能先做 JSON 解析,因为解析和重新格式化会改变字节内容。第二,比对时使用的是 timingSafeEqual,无论差异出现在第一个字节还是最后一个字节,耗时都相同。这样一来,攻击者就无法通过测量响应时间,逐个字符猜出签名。此外,该函数在任何失败情况下都直接返回 false,而不透露具体失败原因,从而避免泄露任何信息。

验签通过后,IssueRelay 会记录每个投递 ID,忽略重复投递。它只更新属于对应 App 安装和仓库的 Issue,并且按事件在 GitHub 上实际发生的顺序(而非到达顺序)来处理事件。

如何部署你自己的 IssueRelay

你可以在大约 15 分钟内,将 IssueRelay 部署到 Vercel 和 Neon 上。包含故障排查在内的完整教程见 docs/SELF_HOSTING.md。这里是精简版。

第一步:部署

推荐路径是 README 中的 Deploy with Vercel 按钮。它会将仓库复制到你自己的 GitHub 账户,自动添加 Neon 数据库,并要求填入三个随机密钥。

首次构建会刻意失败,因为 Vercel 的克隆界面缺少 Root Directory 设置项。因此需要在项目设置中将 Root Directory 指定为 apps/web,然后重新部署。生产构建随后会自动创建所有数据库表。

指南还介绍了一条 Fork and Import 路径,能让后续更新只需一键完成,但该路径尚未经过端到端的完整测试。

第二步:运行设置页面

在新站点中打开 /setup。此功能仅在数据库中不存在账户时有效,且需要你填入部署期间生成的 SETUP_TOKEN,以防止抢先访问该网址的人占用你的平台。该流程会创建你的所有者账户和首个项目,随后展示你的 widget key 以及可直接粘贴的 widget 代码。

首次运行设置页面,包含设置令牌、所有者账户、工作区、站点名称和站点地址字段

站点地址字段默认以 http://localhost:3000 开头,这是 Next.js 应用在本地计算机上运行的地址。记得同时添加你的线上地址,例如 https://my-site.vercel.app 或你自己的域名。如果遗漏了这一步,widget 会礼貌地向访客显示“无法发送你的消息”,因此当报告未送达时,这是需要首先检查的配置项。

第三步:创建 GitHub App

手动配置 GitHub App 时容易犯几个小错误。最严重的是忘记订阅 Issues 事件,我在测试期间就亲身踩过这个坑。因此,IssueRelay 内置了一条命令,可以根据清单文件自动为你创建该 App:

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

该命令会在浏览器中打开 GitHub 并预填所有配置:一个私有的 App,具备写入 issues 和读取 metadata 的权限,订阅了 Issues 事件,并将 webhook 指向你的平台。

点击 Create GitHub App 后,GitHub 会重定向回命令启动的临时本地服务器,命令会把 App ID、私钥和 webhook secret 写入一个被 git 忽略的文件,绝不会在终端里打印出来。随后终端会列出下一个配置阶段。

Step 4: 填入密钥并重新部署

把三个 GitHub App 的值和你的 TYPESAFE_API_KEY 添加到 Vercel 项目的环境变量中,然后重新部署。GitHub issues 功能必须有 Jev:没有它,报告仍会出现在你的仪表板里,但无法转成 issue。

Step 5: 连接你的仓库

在挂载组件的网站所在仓库上安装该 App,然后在仪表板中打开项目的 Settings 页面并连接。该页面会向 GitHub 核实对应名称的 installation 和 repository ID,所以即使拼错了也不会连错仓库。

项目设置页面,包含组件密钥、可直接粘贴的组件代码、允许的站点地址以及已连接的 GitHub 仓库

Step 6: 安装组件

使用设置页面提供的代码把组件装到你的网站上,然后发送第一份报告。

之后想保持你的副本最新,可以从主仓库拉取更新。指南里说明了通过 Deploy 按钮创建的副本所需的一次性步骤,因为它们并不是 GitHub fork。

在真实网站上运行

Demo 演示是一回事,我是想真正用上 IssueRelay,所以这个组件现在跑在我的作品集网站 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 中。

端到端测试(以及我从中获得的经验)

我不希望做一个只有在我本地才能跑起来的项目,所以测试是贯穿每个阶段的工作,而不是留到最后才做。

测试套件包含多个层级:

  • 单元测试 覆盖 widget、API 契约、AI 策略、隐私门、设置页面等模块。总共有 180 个测试用例,且无需依赖数据库。

  • 数据库集成测试 针对一个独立的 PostgreSQL 测试数据库运行,其中包括并发测试,用于验证两次点击不会创建两个 GitHub issue。

  • 基于 Playwright 的浏览器测试 会在独立的端口上启动自己的服务器,并使用一个每次运行都会重建的独立数据库,确保测试绝不会碰到真实数据。其中一台服务器专门针对空数据库运行,以测试首次运行的设置页面。

  • 打包检查 会构建精确的 npm tarball 包,并将其安装到两个位于 monorepo 外部的应用中:一个是采用严格 Content Security Policy 的 Vite 应用,另一个是 Next.js 应用,然后在每个应用中分别提交一次报告。

  • 全流程实测 对着一个真实的 GitHub App 和一个废弃仓库进行 20 项检查:提交报告、分流、预览、创建 issue、确认未泄露私人数据、在 GitHub 上关闭 issue 并等待 webhook、重新打开 issue,以及检查时间线。

我完整走通了这套流程三次:先通过隧道连本地开发环境,再打生产环境,最后严格按部署文档从头部署一个全新副本。三轮测试全部通过,20 项检查无一失败。

相比测试全绿,各阶段暴露出来的问题更有意思:

  • Issues 事件容易被忽略:第一次手动创建 GitHub App 时,我忘了订阅事件,导致 GitHub 从不通知 IssueRelay 有新 Issue 关闭。正是这个坑催生了 create-app 命令。

  • 访客会报自己姓名:如果一位叫 Sarah 的访客提交"Sarah 这边,页面挂了"这样的反馈,她的名字会被写进公开 Issue。现在的隐私校验环节会将每条反馈与其附带的联系方式比对,避免姓名直接暴露。

  • Zod 与浏览器端严格 CSP 冲突:Zod 4 会短暂探测是否可用 new Function,而启用了严格 Content Security Policy 的站点会将此判为违规。于是我把 Zod 从 widget 中移除,改写了轻量级校验逻辑,并写了一个测试证明它和服务器端 Zod schema 在 270 种表单组合下的判定完全一致。

  • Vercel 克隆流程没有 Root Directory 选项,且框架类型只让你选一次:全新部署挂了两次:一次是 Vercel 把仓库根目录当构建根,另一次是框架设置还停在 "Other"。仓库现在通过 vercel.json 锁死 Next.js,文档也加了第一次坑的预警。

  • Deploy 按钮生成的不是 fork:对主仓库执行 git pull 会拒绝合并。文档新增了一条一次性命令,把 Deploy 按钮生成的副本接回主仓库。

  • GitHub Issue 离不开 Jev:我一开始把 Jev 标成可选。重新审阅文档后发现,没有它,任何真实反馈都无法过置信度阈值。文档和设置页现在都写清楚了。

  • 日志噪音有杀伤力:每次数据库连接都打一条 error 级别的 SSL 警告,让健康部署看起来像挂了。修复方式是显式声明驱动已在用的 SSL 模式,警告随之消失,证书校验逻辑却原封不动。

最让我印象深刻的一点是:照着自己写的文档一字不落地部署,能发现任何测试都抓不到的 bug。上面那些部署问题,自动化测试全都发现不了,可真人一照着指南操作就立刻暴露了。

构建过程:分阶段与 AI 辅助开发

IssueRelay 是分小阶段构建的,每个阶段结束时都要写一份交接文档,才能进入下一阶段:

阶段 成果
0 产品定义、架构、决策、安全和测试计划
1 和 2 Monorepo 基础、领域模型、PostgreSQL schema 和种子数据
3 和 4 widget、演示站点和公开的工单 API
5 和 6 用 Jev 做 AI 分诊,以及操作员仪表盘
7 和 8 确认后的 GitHub 上报和带签名的 webhook 同步
9 在一个临时仓库里对完整流程做线上验证
10 生产环境加固
11 和 12 验证 widget 并发布到 npm
Deploy 生产环境使用 Vercel、Neon 和 Resend
13 把 widget 装到我的个人作品集网站上
14 和 15 自用验证(持续进行中)
16 自托管:Deploy 按钮、设置页、项目设置和 App manifest 命令

我的开发环境

大部分工作我都在终端里完成。我的配置是:

  • 终端用 Ghostty,运行 Claude Code、Codex 和 OpenCode

  • 编辑器用 Cursor

  • ChatGPT、Claude 和 OpenCode 的原生桌面客户端

构建 IssueRelay 的主力模型是 Claude Code 里的 Claude Opus 5.5。代码审查和阶段验收时,我会用其他模型交叉检查,包括 GPT-6 Sol 和 Grok,以及各种其他前沿模型和免费模型。

另一个模型以全新的视角重读代码,发现了不少实际问题。例如,Grok 对第 7 和第 8 阶段的评审提出了 15 条意见,其中 7 条属实,包括问题创建中的竞态条件以及可被猜解的问题标记。我在继续之前修复了所有 7 项。另外 3 条部分属实,其余 5 条则附上了书面理由暂时搁置。

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

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

质量提升最大的原因并非模型更聪明,而在于提供了更清晰的指令和更合理的工作结构。以下是行之有效的做法:

  • 一次只做一个阶段: 每次提示词只针对一个阶段,目标明确,且在我批准前 AI 不得进入下一阶段。小步、可审阅的变更比一次性开发巨大功能更容易检查。

  • 先出计划再写代码: 对于较大的阶段,我先要求输出计划(“先制定计划,待我批准后再执行”)。阅读计划只需两分钟,而拆解错误的实现却要一个下午。

  • 将规则写入仓库: AGENTS.md 文件承载项目规则,如“在接受工单并持久化之前,禁止调用外部 AI 或 GitHub”、“严禁向 GitHub 公开联系信息”、“不要盲目重试模糊的 GitHub 问题创建”。每次 AI 会话都会读取该文件,因此规则不再依赖我反复提醒。

  • 诚实汇报: 指令规定不得将未执行的检查标记为通过,每次交接都需记录实际运行的命令及其真实结果,包括失败情况。

  • 明确的提交条件: 使用类似“当测试通过且无其他问题时进行提交并推送”的提示词,确保全量测试套件通过后才允许代码进入主分支。

  • 要证据,不要承诺: 我不问“自托管是否可行”,而是要求 AI 按照指南在全新部署环境中验证。仅此一项要求便暴露了 7 个文档和配置问题。

  • 真实使用反馈:当我自己部署测试站点并记录下所有令人困惑的地方时,这些笔记直接写回了指南、设置页和配置页。

  • 将组件发布到 npm

    组件是 IssueRelay 中唯一对外发布的部分,以 @issuerelay/widget 形式存在。其余所有模块均保持在 monorepo 内部私有。

    我不想发布一个只在我的工作区内能用的包,因此发布检查流程会构建 npm 将要收到的准确 tarball 并对其进行检查。

    该包必须只包含五个文件。不得引用私有包、Node 内置模块、环境变量或任何看起来像密钥的内容。此外,它必须能在仓库外的两个全新应用中安装并正常工作,其中之一的应用运行在严格的 Content Security Policy 下,且没有任何策略违规。

    发布通过 GitHub Actions 配合 npm 可信发布与 provenance 机制完成,因此不存在长期有效的 npm token 泄漏风险。

    最终得到一个压缩后约 10 KB 的包,无需任何 CSS 配置,仅依赖 React 和 React Hook Form。组件 README 文档化了每一个 prop。

    未来计划

    IssueRelay 已支持自托管,我每天都在自己的个人站点上使用它。以下是我想继续探索的几个方向:

    • 托管版 IssueRelay:用户无需自行部署任何内容,注册后即可添加组件。

    • Fork 与导入路径的端到端测试,使其成为推荐的部署方式。

    • 路线图上的想法,如检测重复报告、将多个报告关联到同一 issue、通知以及同步 GitHub 评论。

    结语

    本教程展示了如何构建一个 AI 支持系统,将分散的站点反馈转化为经过审查的工单,并将确认的 bug 路由到 GitHub。过程中你学到了如何:

    • 构建可嵌入的 React 组件,在任何站点上无需 CSS 配置且不会样式冲突即可运行

    • 在调用任何外部服务前先保存所有报告,确保供应商故障不会导致数据丢失

    • 用 Jev 做有边界、经过校验的分类,返回标签和概率,而不是自由文本

    • 把路由和发布决策放在普通、可测试的代码里,并保留人工审核环节

    • 通过 GitHub App、隐私门槛和防重复标记,安全地创建 GitHub issue

    • 用带签名验证的 webhook 保持 GitHub 与你的仪表盘同步

    • 在 Vercel 和 Neon 上部署你自己的副本,做端到端测试,包括用你自己的文档来验证

    了解 IssueRelay 最好的方式就是亲自试一试。你可以在 GitHub 上查看源码,按照自托管指南部署自己的副本,也可以通过 npm 执行 npm install @issuerelay/widget 把组件加到你的网站上。如果它对你有帮助,欢迎给仓库点个 star。

    原始来源: freeCodeCamp

    评论 (0)