作者为标签页管理扩展加入Cloudflare Worker代理,使用Workers AI免费配额,让缺少端侧AI的用户免填API密钥。该方案按文中设计在配额耗尽后停止服务,以限制费用风险。
简单说:我想让自己的标签页扩展用 AI 为标签组命名、整理标签页。但大多数电脑都用不了 Chrome 内置 AI,也没人愿意为了一个标签页工具粘贴 API key。于是,我在中间加了一个小小的 Cloudflare Worker,使用 Workers AI 的免费额度:额度用完就停止服务,不会产生任何费用。没有本地 AI 的用户,只要在设置里打开一个开关,就能用 AI 为标签组命名。
Groupify 是我开发的 Chrome 标签页整理扩展。开始一个新项目时,我通常会同时打开 Pinterest、Next.js 文档、Cloudflare 和 Vercel。如果按网站分组,这些标签页反而会被拆散。我想要的是,点一下就把这些标签页放进同一个组,再起个合理的名字,比如“网站部署”。
这需要 AI。问题是,该用哪种 AI。
Chrome 内置 AI(Gemini Nano):在用户电脑上运行,免费又保护隐私,是最理想的选择。但它需要性能足够的电脑,以及大量可用磁盘空间。我自己的电脑都用不了,大多数用户恐怕也一样。
让用户提供自己的 API key:技术上最简单,但我的用户不是开发者。没人会为了给标签页改名,专门去注册申请一个 key。
使用我自己的 OpenAI 或 Azure API:可以,但这两者都没有真正适合这个场景的免费套餐。预算告警只会给我发邮件,并不会停止服务,所以只要有人滥用,照样会产生账单。
我想要的是一种用完就停的免费额度,就像我在另一个扩展里使用的 Azure 语音服务免费套餐那样。
Workers AI 为每个账户提供每日免费额度。在 Workers Free 套餐下,超出额度的请求会失败,而不是继续计费,因此它最多也只会让我花零元。我只需要在中间放一个很小的 Worker,做三件事:识别我的扩展、限制每个人的用量,以及把标签页信息组织成 prompt。

一个端点为一组标签页命名,另一个端点按任务把一堆标签页整理成组。Worker 不存储任何数据,也不记录任何日志,因此不会记住任何人发送的内容。
扩展只发送每个标签页的标题和网站名,绝不发送完整地址,因为地址里经常带有 token 或私密参数。每次请求最多包含 40 个标签页,过长的标题也会被截短。
Worker 通过 CORS,只向我的扩展对应的 origin 返回响应,包括商店版和我的开发版。这意味着扩展不需要新增权限,用户更新时也不会看到新的权限警告。origin 可以伪造,所以除了免费额度的硬性上限,我还加了按 IP 计算的每分钟请求限制。最坏的情况,也就是有一天用不了 AI。
我的大多数用户阅读繁体中文,所以我选了一个价格便宜、中文能力不错的开放模型,并要求它跳过思考过程的输出,以节省额度。给一个标签组命名消耗很少,以我目前的用户数量,免费额度能用很久。
云端 AI 是设置里的一个开关,默认关闭,旁边有一行说明:标签页标题和网站名会发送到我的服务器,但不会保留任何内容。扩展始终先尝试本地 AI,再尝试云端 AI,最后回退到按日期命名。因此,无论哪一步失败,用户都不会看到错误。
半个汉字:模型偶尔会在名字中间输出一个损坏的字符。遇到这种情况,Worker 会重新请求一次,服务端和扩展端也都会清除损坏的字符。
不保证返回 JSON:并不是每个模型都支持 JSON mode,所以我会提取回答中的第一个 JSON 对象,再检查每个字段,比如标签页编号是否重复、是否越界。
请求限流只是近似值:Cloudflare 按位置分别计数,我测试时,大约到第九次请求才开始被拒绝。用来防滥用足够了,但它不是一个精确的计数器。
开发版的 ID 不一样:未打包扩展的 ID 由其文件夹路径决定,所以允许列表里也要加入这个 ID。
Groupify 能自动整理标签页后,我又遇到了一个问题:自动整理不能拆散我手动创建的组。比如,我把自己博客的标签页放进一个叫“Me”的组,按用途整理时,就不应该再把它们移到“阅读”组里。
但 Chrome 不记录是谁修改了标签页或标签组,只记录它们当前的状态。所以,扩展必须自己判断。每次修改标签组之前,它都会记下一件事:“接下来几秒钟内的变更是我做的。”一个后台 Worker 监听 Chrome 的标签页和标签组事件,把这个时间窗口之外的所有变更都视为用户的决定,包括在 Chrome 自带标签栏里拖动标签页、重命名标签组。
// The extension's own changes are wrapped, so they never count as the user's
async function asExtension<T>(change: () => Promise<T>): Promise<T> {
await storage.session.set({ ownChangesUntil: Date.now() + 15_000 })
try {
return await change()
} finally {
// Chrome's events can arrive a little late, so keep the window open for a second
await storage.session.set({ ownChangesUntil: Date.now() + 1_000 })
}
}
const isOwnChange = async () => (await storage.session.get('ownChangesUntil')) > Date.now()
// Background worker: every other change is the user's decision
tabs.onUpdated.addListener(async (tabId, change, tab) => {
if (change.groupId === undefined || change.groupId === NO_GROUP) return
if (await isOwnChange()) return
const group = await tabGroups.get(change.groupId)
if (group.title) rememberSite(siteOf(tab.url), group.title) // e.g. "amsyn.org" -> "Me"
})
tabGroups.onUpdated.addListener(async (group) => {
const before = lastKnownTitle(group.id) // from a snapshot the worker keeps
if (before === group.title || (await isOwnChange())) return
renameRulesAndLock(before, group.title) // rules and locks follow the new name
})
// Reclassify: what the user decided goes first, automatic sorting gets the rest
function reclassify(tabs: Tab[], rules: SiteRule[], mode: Mode) {
const movable = tabs.filter((tab) => !tab.pinned && !isLocked(tab.group))
const { claimed, rest } = splitByRules(movable, rules) // sites with a rule
return [...groupsFromRules(claimed), ...plan(mode, rest)]
}
用名字,不用 ID:规则记录的是“网站 → 标签组名称”。Chrome 重启后会为标签组分配新的 ID,但名称会保留。
侧边栏操作直接记录:拖动标签页、给新组起名或重命名,本来就是用户在操作,不需要再猜测。
以最新决定为准:把某个网站的标签页拖到另一个组,规则就会更新;设置里会列出所有规则,方便你删除某一条。
手动修改过的组会自动锁定:用户亲手改过的标签组会加上一把锁,Reclassify 就不会再动它。
如果用量增长,或者有人滥用,最坏的结果也只是有一天用不了 AI,扩展会退回到按日期命名。对于一个只有少量用户的业余项目,这是最让我安心的方案。
如果你也想为自己的扩展或小工具添加免费、不会产生账单的 AI 功能,可以把下面这段内容粘贴到你的 AI 编程助手里:
Add an optional shared AI feature to my browser extension, using this design:
1. Server: a Cloudflare Worker using the Workers AI binding (free daily allocation, so on the
Workers Free plan it fails instead of billing). Pick a cheap model that handles my users'
languages. Expose only the endpoints the feature needs, keep no logs or state, and validate
every request body (cap the number of items and the length of each string).
2. Access: answer CORS only for my extension's origins (store ID plus my unpacked dev ID, read
from a variable), so the extension needs no new permission. Add a per-IP rate limit with the
Workers rate-limiting binding.
3. Data: send only what the prompt needs (for tabs: title and hostname, never full URLs).
4. Answers: ask for JSON, but parse leniently (find the first JSON object, ignore any thinking
block) and validate every field. Retry once if the answer contains U+FFFD.
5. Extension: an opt-in setting, off by default, that says exactly what is sent and where. Try
any on-device AI first, then the Worker, then a non-AI fallback, so no error ever reaches the
user. Treat every HTTP error or timeout as "no AI".
6. Tests for request parsing, answer parsing and the fallback order, plus a README for the
Worker with the deploy steps.
Don't ask me for any secret. Stop after writing the code so I can review and deploy it.
封面:美国邮政的邮件分拣室,作者为 libraryofcongress,采用 CC0 1.0 许可。
后续你可以考虑屏蔽此人,或举报滥用行为。