每个网站都会收到反馈,但大部分反馈最终会被放到一些尴尬的地方。访客发现按钮坏了,给你发邮件。另有人在社交媒体上留言,说某个页面在手机上打不开。还有人在你的联系表单里填写功能建议,这条信息就这样夹在新闻通讯和收据之间,躺在你的收件箱里。
当你终于坐下来想要修复这些问题时,错误报告分散在三个不同的地方。其中一半缺少细节,而那些确实进入 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:
对 React、Next.js 和 TypeScript 有工作经验:平台使用 Next.js App Router,且该 widget 是一个 React 组件。
Node.js 24 和 pnpm:如果您想在本地运行项目或使用创建 GitHub App 的命令,请先安装 Node.js 24 和 pnpm。
GitHub 账户:此外,还需要您想要安装 widget 的网站或应用的仓库。确认的 bug 会在那里成为 issue。
Vercel 账户:免费的 Hobby 计划已经足够。您可以通过 Vercel 市场添加一个 Neon PostgreSQL 数据库,Neon 也提供免费计划。
TypeSafe 账户:在 typesafe.ai 处获取 Jev,这是用于分类报告的 AI 模型。在任何报告能够成为 GitHub issue 之前,您需要从 TypeSafe 控制台 获取 API 密钥。
React 网站:您可以在此添加组件。Next.js 网站是开始的最简单地方。
可选:Resend 账户:如果您需要诸如密码重置之类的账户邮件。
您不需要是 AI 专家也能跟随学习。Jev 通过一个小型类型 SDK 使用,而大部分有趣的工作是普通的 Web 工程:数据库、验证、身份验证和 Webhook。
AI 支持系统如何帮助任何网站
支持系统听起来像是只有大公司才需要的东西,但它解决的问题几乎在每个网站都会出现。
作品集网站:作品集网站会收到来自招聘人员的消息和关于项目的问题。此外,它们还会收到关于在特定浏览器上出现故障页面的报告。
SaaS 产品:SaaS 产品会收到混合了计费问题和功能请求的错误报告,每个报告都需要不同的人员或流程来处理。
文档网站:文档网站会收到“这示例无法运行”的报告,但实际上这是产品中的 bug。
开源项目:开源项目会吸引那些不会自行打开 GitHub issue 的用户,但这些用户会很乐意在网站上点击按钮。
客户站点您为他人构建的客户站点会收到反馈,客户几天之后才转发给您,且没有任何细节。
一个好的系统提供一个集中地点,所有报告都会送达此处;即使其他服务出现故障,也能保证每份报告安全,并对报告排序,让您把时间花在真正重要的事情上。
AI 部分有助于排序,但它永不应负责。模型可能会有把握地出错,公开的 GitHub 问题也不是您想凭猜测创建的。因此 IssueRelay 始终遵循一条简单原则:AI 推荐,人工确认,代码强制执行规则。
我们将构建什么
以下是单份报告在 IssueRelay 中的流程:
访客在您的站点打开小部件并选择一个主题:
他们描述问题,并可选择留下姓名和电子邮件,以便您后续跟进:
小部件将报告发送到您的 IssueRelay 平台,平台保存该报告并回复一个支持引用,访客稍后可引用该引用:
此后,报告变为您仪表盘中的一个工单。Jev 对其进行分类,您审查它,如果是真正的 bug,点击一下即可在您的仓库中创建 GitHub issue。
以下是完整功能列表:
可嵌入的小部件: 作为 Shadow DOM 内的 React 组件构建,因此无需 CSS 设置,且永不与您站点样式冲突。它可在 Next.js App Router 中运行,并在严格内容安全策略下工作。
持久摄入: 每份报告在其他任何操作之前都会存储到 PostgreSQL 中,并提供防重复、按项目限速以及允许站点地址列表的保护。
有界 AI 分类: Jev 仅根据消息推荐类型和严重程度。
私人仪表盘: 包含过滤器、分类历史以及与 AI 输出分开存储的人工审核决定。
谨慎的 GitHub 升级: 只有在所有者确认预览后,GitHub App 才会创建 issue,且联系信息永不离开 IssueRelay。
双向同步: 在 GitHub 上关闭或重新打开 issue 会通过签名 webhook 更新票据。
自托管: 通过 Deploy 按钮、首次运行设置页面和设置页面,即可在不触碰数据库的情况下运行自己的副本。
什么是 Jev?
Jev 是 TypeSafe (TypeSafe) 提供的模型,专为 TypeSafe 所称的“System One”任务而构建:快速且有界的判断,而不是长篇开放式的写作。
与其让模型写一段文字再去解析,不如给 Jev 提供一些状态和一组问题,每个问题都有固定候选答案。Jev 为每个问题选择一个答案,并返回它对每个选项所分配的概率。
这种形式正是支持分流所需要的。工单可能是 bug、问题、功能需求、计费问题或垃圾信息。其优先级可以是低、中、高或致命。模型不需要编造文本,也不存在可能让它生成 issue 标题的提示词注入,事后也不需要清理自由文本。输出是一个标签和一个数字,你的代码可以同时检查这两项。
它也既便宜又快速。根据当前文档,TypeSafe 将 Jev 定价为每十亿输入标记 42 美元,而一条支持消息只有几十个标记。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 用于 linting 和格式化
pnpm workspaces 用于将一切放在一个 monorepo 中
Vercel、Neon 和 Resend 用于生产环境
单体仓库拆分为多个小包,每个包都有明确的职责:
| 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 会进行以下几项检查:
请求体必须符合共享的 Zod 契约,否则会返回明确的错误拒绝不良输入。
项目密钥必须存在,且请求来源必须是该项目允许的站点地址之一。
项目必须处于其速率限制之内。
提交 ID 未曾被使用过。重复提交会返回原始票据引用,而不是创建重复。
步骤 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 时,会运行几项额外的防护措施:
GitHub App,而非个人令牌: 该 App 仅安装在你选择的仓库中,具备创建议题和读取元数据的权限,除此之外没有其他权限。
先认领,后创建: 在调用 GitHub 之前,数据库中的票据会被标记为
creating,这样两次点击永远不会创建两个议题。隐藏标记: 每个议题正文末尾都有一个关联票据的不透明 HTML 注释。如果请求超时且结果未知,IssueRelay 在再次尝试之前会先从自身 App 中搜索仓库的精确标记。它不会盲目重试可能已经创建的议题。
这是 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:
网站设计可能会变化,因此如果你在未来阅读这篇文章,之前的构建可以在我的 GitHub 上找到。
安装它让我学到了一些东西。作品集在测试时仍然使用 React 18,而 App Router 已经在使用 React 19 进行渲染。于是我先将其升级到 React 19,并在添加 widget 之前确保所有现有测试都通过。widget 与网站亮色和暗色主题相匹配,位于右下角,并在作品集仓库中拥有自己的单元测试和浏览器测试。
然后我像访客一样进行了测试。我从现场站点发送了三份真实报告:一个问题、一个 bug 和一个功能请求。Jev 按我预期的方式将这三者全部分类,得分在 0.95 到 1.00 之间,策略将它们分别路由到支持、工程和产品。该 bug 成为了我公开作品库中的 issue #3,即之前截图中显示的那个 issue。我在提交该报告时附上了姓名和邮箱,但公开的 issue 中并未出现这两项信息。
端到端测试(以及我所学到的)
我不想要一个只能在自己机器上运行的项目,因此测试贯穿了每个阶段,而不是留到最后才进行。
测试套件包含以下几层:
单元测试 用于小部件、API 契约、AI 策略、隐私门户、设置页面等。共有 180 个,且均不需要数据库。
数据库集成测试 针对独立的 PostgreSQL 测试数据库运行,包含并发测试,证明两次点击不会创建两个 GitHub issue。
使用 Playwright 的浏览器测试 会在独立端口启动各自的服务器,并使用每次运行时重新创建的独立数据库,以确保测试永不会接触真实数据。其中一个服务器针对空数据库运行,用于测试首次运行的设置页面。
包检查 会构建精确的 npm tarball,并将其安装到一个带有严格内容安全策略的 Vite 应用以及一个 Next.js 应用中(两者均位于 monorepo 之外),然后在各自环境中提交报告。
实时旅程测试 包含针对真实 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,而采用严格内容安全策略的站点会将此报告为违规。我从小工具中移除了 Zod,改用小型验证检查,并添加了一个测试,证明它们在 270 种表单组合上与服务器的 Zod 模式保持一致。Vercel 的克隆流程没有根目录选项,且 Vercel 只会选择一次框架: 我的全新部署失败了两次:一次是因为 Vercel 构建了仓库根目录,另一次是因为框架仍然设置为 "Other"。仓库现在在
vercel.json中固定了 Next.js,且指南会警告第一次失败。部署按钮的副本不是分支: 从主仓库执行纯粹的
git pull会拒绝合并,因此指南现在提供了一条一次性命令,用来将副本连接到主仓库。GitHub issue 需要 Jev: 我最初将 Jev 列为可选。对指南的仔细审查表明,没有它,真实的报告无法达到置信阈值。指南和设置页面现在已经明确说明这一点。
日志噪声很重要: 每个数据库连接都会在错误级别记录一个 SSL 警告,导致健康的部署看起来像是出故障。解决办法是明确说明驱动程序已经在使用的 SSL 模式,这样警告就消失了,而证书检查保持完全不变。
给我印象最深的教训: 按照自己的文档逐字部署,能够发现自动化测试无法发现的 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 命令 |
我的开发环境
我在终端中完成了大部分工作。我的环境如下:
Ghostty 作为我的终端,运行 Claude Code、Codex 和 OpenCode
Cursor 作为我的编辑器
原生桌面应用(ChatGPT、Claude、OpenCode)
我在 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 未在本项目中使用。
更好的提示词如何改进了代码库
质量的最大提升并非来自更智能的模型。
它来自于为模型提供更好的指令和更适合其工作的结构。
以下是有效的做法:
一次一个阶段:每个提示词都只针对一个具有明确结果的阶段,且在我批准之前,AI 不允许开始下一个阶段。小而可审查的更改相比单一巨型功能要易于检查得多。
写代码之前先出计划:对于较大的阶段,我会先让 AI 拿出方案("先创建计划,我批准后再动手")。读一份计划只要两分钟,而回退一个错误实现可能要搭上一个下午。
把规则写进仓库:用
AGENTS.md文件存放项目规则,比如"在调用外部 AI 或 GitHub 之前,先持久化已接受的工单""绝不把联系人数据发布到 GitHub""不要盲目重试语义不明的 GitHub issue 创建"。每次 AI 会话都会读取这个文件,规则就不用靠我每次口头重复了。如实汇报:指令中明确要求:绝不能把没有实际运行过的检查报告为通过,而且每次交接都要记录实际执行过的命令及其真实结果,包括失败的情况。
明确的提交条件:类似"测试全部通过且没有其他问题时,就提交并推送"这样的提示词,保证了任何代码在进入主分支之前都跑完了完整测试套件。
要证据,不要承诺:与其问"自托管能用吗?",我会让 AI 按照文档指南在一套全新部署上实际验证一遍。仅这一项要求就暴露出七个文档和配置问题。
把真实使用反馈喂回去:我自己部署了测试站点,记下所有让我困惑的地方,这些笔记随后直接融入了指南、安装页面和设置页面。
把 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 的自托管部分已经完成,我每天都在自己的作品集网站上使用它。接下来我想探索的方向包括:
托管版本 的 IssueRelay,这样您就可以注册并添加小部件,而无需自行部署任何内容
测试 Fork 和 Import 路径 端到端,以便它能成为推荐的部署方式
路线图中的一些想法,例如检测重复报告、将多个报告关联到同一个问题、通知以及同步 GitHub 评论
结论
在本教程中,您看到了如何构建一个 AI 支持系统,它将零散的网站反馈转化为已审查的工单,并将确认的错误路由到 GitHub。在此过程中,您学到了如何:
构建一个可嵌入的 React 小部件,可在任何站点上运行,无需 CSS 设置或样式冲突
在调用任何外部服务之前保存每份报告,以免服务提供商中断导致数据丢失
使用 Jev 进行有界且经过验证的分类,它返回标签和概率,而不是自由文本
将路由和发布决策保存在纯粹且可测试的代码中,并让人工参与其中
使用 GitHub App、隐私门禁和防止重复的标记,安全地创建 GitHub 问题
使用已签名且经过验证的 Webhook,保持 GitHub 与您的仪表板同步
在 Vercel 和 Neon 上部署您自己的副本,并进行端到端测试,包括针对您自己的文档
了解 IssueRelay 的最佳方式是亲自尝试。您可以探索 GitHub 上的代码,使用自托管指南部署您自己的副本,并且使用 npm install @issuerelay/widget 从 npm。如果它对您有帮助,为仓库点星 siempre 受到赞赏。