freeCodeCamp.org

How to Build an AI-Powered, Local-First Chrome Extension That Turns Your Browsing History into an Intent Map

8.5内容质量
How to Build an AI-Powered, Local-First Chrome Extension That Turns Your Browsing History into an Intent Map

TL;DR · AI 摘要

本文展示如何构建一个基于AI的Chrome扩展,将浏览历史转化为意图地图,实现本地化处理和智能分析。

核心要点

  • 使用IndexedDB实现本地数据处理,无需依赖云端。
  • 通过聚类算法将浏览历史转化为意图线程,并进行评分。
  • 集成Claude和context.dev API,实现意图线程的标签化和品牌信息增强。

结构提纲

按章节快速跳转。

  1. 浏览器记录了所有访问的页面,但无法理解访问的意图。

  2. 构建一个开源、本地优先的Chrome扩展,将浏览历史转化为意图线程。

  3. 使用IndexedDB进行本地数据处理,并通过聚类算法生成意图线程。

  4. 使用Claude进行意图线程的标签化,并通过context.dev API增强品牌信息。

  5. 构建一个包含仪表盘、欢迎界面和AI助手的用户界面。

  6. 总结构建成果,并提供进一步开发的建议。

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • 构建AI驱动的Chrome扩展
    • 本地数据处理
      • IndexedDB
      • 数据清洗与聚类
    • AI集成
      • Claude用于意图线程标签化
      • context.dev API用于品牌信息增强
    • 用户界面
      • 仪表盘设计
      • AI助手集成

金句 / Highlights

值得收藏与分享的关键句。

#Chrome扩展#AI#本地处理#数据聚类
打开原文

如何构建一个由 AI 驱动、以本地优先的 Chrome 扩展,将你的浏览历史转化为意图地图

2026年6月19日

/

#chrome extension

Shola Jegede

你的浏览器记得你曾经打开过的每一页,但它不知道你打开它们的原因。

你可能花了三天时间在十几个标签页中比较笔记本电脑,然后被分散了注意力,一周后回来,你的浏览历史只显示了一串时间戳和标题的平铺列表,没有任何迹象表明这些访问是同一件事,是你开始但从未完成的决定。

在本教程中,你将构建 openloops,一个开源、本地优先的 Chrome 扩展,通过扫描你的浏览历史并将其分组为“意图线程”——你反复回来的决定、研究和开放问题——然后为每个线程评分,评估其当前的活跃程度。可选地,它还使用 Claude 用普通语言标记这些线程,建议具体的下一步行动,并驱动一个聊天助手,你可以问它“这周我应该关闭什么?”

最终,你将构建:

  • 一个 Manifest V3 Chrome 扩展,包含服务工作线程和一个完整的标签页仪表板
  • 一个本地管道,完全在 IndexedDB 中捕获、清理、分段和聚类浏览历史
  • 一个在真实(混乱)浏览数据上调整和调试的聚类算法
  • 一个使用 Claude 的 AI 标记层,其中包含一个使用 context.dev 上的品牌数据进行接地的步骤
  • 一个聊天助手,可以在你的线程之间进行推理,并告诉你下一步该做什么
  • 一个经过打磨的仪表板,包含引导流程、设计系统和一个运行中的管道状态机

所有内容都在设备上运行,唯一的网络调用是可选的,并且需要你自己的 API 密钥进行授权。

目录

  • 你将构建的内容
  • 先决条件
  • openloops 的结构 共享类型 清单文件
  • 如何搭建扩展
  • 如何捕获你的浏览历史 一些共享的辅助函数 数据库层(目前)实时捕获新访问 填充14天的历史记录 检查点
  • 如何将噪音转化为会话 过滤噪音 提取关键词 扩展数据库以支持会话 将事件分段为会话 检查点
  • 如何将会话聚类为意图线程 检测环境域 扩展数据库以支持意图线程 将会话聚类为线程 对线程进行评分和分类 综合起来 检查点
  • 如何清理自指噪音 两个问题 一个定义,应用于所有地方 防止对丰富边界的过度防御 检查点
  • 如何使用 Claude 标记线程 本地存储密钥 第一个版本及其崩溃 批量处理请求 构建提示并合并结果 检查点
  • 如何使用 context.dev 接地标签 API 返回的内容 获取一个品牌 批量丰富域 接地如何反馈到标签 检查点
  • 如何设计仪表板 三列布局 管道状态机 从同一状态机驱动欢迎屏幕 连接处理程序 恢复按钮 检查点
  • 如何构建 AI 助手 接地对话 发送消息 模型和努力控制 渲染回复和空状态 检查点
  • 你构建了什么,以及下一步 你构建的隐私模型总结 下一步可以做什么 总结
  • 资源 源代码 核心文档 使用的服务 构建工具 调试工具 进一步阅读

你将构建的内容

首次运行时,openloops 会以一个居中的欢迎屏幕向你打招呼,并引导你完成三个流程步骤:

一旦你扫描了浏览历史、构建了会话并建立了意图地图,你的浏览内容将重新组织为按状态分组的线程:活跃、停滞和休眠。每个线程都有一个置信度评分、一个通俗易懂的摘要、一个具体的下一步操作,以及一个“继续”按钮,可以重新打开你之前离开的页面。右侧的列中包含一个基于你自己的线程的聊天助手:

该助手的响应会跨用户的实际线程进行推理,根据它们关闭的难易程度与仍需做出的实际决策量进行排序。它还会解释原因,这是此次构建中最创新的部分,依赖于你稍后在本教程中将添加的 context.dev 接地步骤。

先决条件

要跟随教程,你需要:

  • Node 18+ 和一个基于 Chromium 的浏览器(Chrome、Brave、Edge 等)。
  • 熟悉 TypeScript 和 React。你不需要是专家,但应能熟练阅读 hooks 和 async/await。
  • 对 IndexedDB 有基本了解会有所帮助,但不是必需的,因为随着教程的进行,你会学到所需的内容。

此次构建的两个部分是可选的,需要你自己的 API 密钥,每个部分都有免费的层级:

  • 一个 Anthropic API 密钥(来自 platform.claude.com),用于 AI 标签和聊天助手
  • 一个 context.dev API 密钥(来自 context.dev),用于品牌接地步骤

你可以在没有这两个密钥的情况下构建并使用整个核心流程,包括捕获、聚类和评分,因为这两个密钥都是在其基础上的附加层。

openloops 的结构

在编写任何代码之前,先了解整体的结构是有帮助的。openloops 的每个阶段都从一个 IndexedDB 存储中读取,并写入到下一个存储中:

code
chrome.history (backfill) ──┐
chrome.tabs.onUpdated (live)─┴─→ raw_events
                                     │  noise filter
                                     ▼
                                  sessions
                                     │  ambient detection + clustering + scoring
                                     ▼
                               intent_threads
                                     │
                                     ▼
                              React dashboard
                                     │  optional, opt-in
                                     ├──→ brand enrichment   (context.dev)
                                     └──→ AI labeling + next step (Claude)
                                              │
                                              ▼  optional, opt-in
                                        AI assistant chat (Claude)

每个阶段都是 src/pipeline/ 下的一个独立模块,每个模块都可以独立检查:你可以打开 Chrome 开发者工具,在 Application 标签页中直接查看 raw_events、sessions 或 intent_threads,并且可以重新构建任何单个阶段,而无需影响其他部分。

共享类型

每个阶段都使用并生成相同的几个 TypeScript 接口,这些接口在 src/types.ts 中定义一次:

code
// openloops 管道的共享 TypeScript 接口。
// 管道的每个阶段都使用并生成这些类型。

export interface RawEvent {
  id: string;
  url: string;
  domain: string;

  visitedAt: number;         // 时间戳(毫秒)
  source: "backfill" | "live";
}

export interface Session {
  id: string;
  events: RawEvent[];
  startedAt: number;
  endedAt: number;
  domains: string[];
  keywords: string[];
}
code
export interface IntentThread {
  id: string;

  summary?: string;
  nextStep?: string;   // 一个具体的操作,用于推动线程向前发展
  sessions: Session[];
  type: "buying" | "research" | "planning" | "learning" | "unclassified";
  confidence: number;        // 0-1
  status: "active" | "stalled" | "dormant";
  firstSeen: number;
  lastSeen: number;
  distinctDays: number;
  signals: string[];
}

export interface Brand {
  domain: string;
  name: string;
  description: string;
  industry: string;
  logoUrl: string;
  brandColor: string;
}

IntentThread 接口的大多数字段,包括 confidence、status、signals 和 distinctDays,将在本指南后面介绍聚类和评分线程时,通过纯本地启发式方法填充。summary 和 nextStep 字段将保持未定义,直到后续的可选 AI 标签步骤填充它们。

这就是使整个项目运作的模式:核心数据模型可以独立运行,而 AI 则使其更加丰富。

清单文件

openloops 是一个 Manifest V3 扩展,具有三个权限和三个主机权限:

code
{
  "manifest_version": 3,
  "name": "openloops",
  "version": "0.0.1",
  "description": "将你的浏览历史重建为一个带有 AI 标签的意图线程地图:正在进行的决策、停滞的研究、开放的问题。完全本地运行。",

  "permissions": ["history", "tabs", "storage"],
  "host_permissions": [
    "https://api.anthropic.com/*",
    "https://api.context.dev/*",
    "https://logos.context.dev/*"
  ],

  "background": {
    "service_worker": "src/background.ts",
    "type": "module"
  },

  "options_page": "src/dashboard/index.html",

  "icons": {
    "16": "icons/icon16.png",
    "32": "icons/icon32.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  },

  "action": {
    "default_title": "openloops",
    "default_icon": {
      "16": "icons/icon16.png",
      "32": "icons/icon32.png"
    }
  }
}

权限、主机权限和 options_page 条目各自具有特定的重要性:

  • 权限 ["history", "tabs", "storage"] 是核心流程所需的唯一权限。history 用于读取你的浏览历史以进行回填,tabs 允许服务工作线程观察新的页面加载,并允许“恢复”功能重新打开标签页,storage 是存储 API 密钥和偏好设置的地方。
  • 主机权限是独立的,只有在使用可选的 AI 功能时才相关。它们允许仪表板通过 fetch() 调用 Anthropic 和 context.dev 的 API,而不会遇到 CORS 错误。
  • options_page 指向仪表板。设置为这种方式,而不是使用默认的弹出窗口,意味着点击工具栏图标会以完整的浏览器标签页形式打开仪表板,而不是一个小弹出窗口,这对于查看包含按状态分组的卡片和聊天面板的多列布局非常重要。

如何搭建扩展

从 Vite 和 CRXJS 插件开始,它们可以编译一个带有热模块重新加载功能的 Manifest V3 扩展:

code
npm create vite@latest openloops -- --template react-ts
cd openloops
npm install @crxjs/vite-plugin idb react-markdown

你的 vite.config.ts 将 CRXJS 连接到 manifest.json,然后 Vite 会处理将 src/background.ts 编译成 Chrome 可加载的真实 .js 文件(在清单文件中使用原始 .ts 服务工作线程路径会导致注册错误,我们将在下一节中调试该问题)。

仪表板的入口点是一个标准的 React 18 根:

code
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>openloops</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="./main.tsx"></script>
  </body>
</html>
code
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./app.css";
import App from "./App";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <App />
  </StrictMode>
);

构建它,然后将其作为未打包的扩展加载:

code
npm run build

在 Chrome 中,前往 chrome://extensions,启用开发者模式,点击“加载未打包的扩展”,然后选择 dist/ 文件夹。在尚未构建任何其他内容的情况下,点击工具栏图标应会打开一个空白的仪表板标签页,并且服务工作线程(可以通过扩展卡片的“服务工作线程”链接查看)应在安装时记录 [openloops] 扩展已安装。

有了这个基础之后,现在是时候开始用你的实际浏览历史填充 raw_events 了。

如何捕获你的浏览历史

在 openloops 中,每个记录最初都以 RawEvent 的形式存在,你之前看到的类型:一个 URL、一个域名、一个标题、一个时间戳,以及一个来源,来源为 "backfill" 或 "live"。

有两个管道用于填充它:

  • 一次性的 backfill,按需读取你过去14天的 chrome.history
  • live capture,从现在开始监听新的页面加载

这两条路径共享一些小的辅助函数,并通过相同的 IndexedDB 层进行写入,因此值得先构建这些辅助函数。

一些共享的辅助函数

创建 src/lib/util.ts:

code
export function isHttpUrl(url: string): boolean {
  return url.startsWith("http://") || url.startsWith("https://");
}

export function extractDomain(url: string): string {
  try {
    const { hostname } = new URL(url);
    return hostname.replace(/^www\./, "");
  } catch {
    return url;
  }
}

export function isLocalHost(domain: string): boolean {
  if (domain === "localhost" || domain === "127.0.0.1") return true;
  if (domain.endsWith(".local")) return true;

  const octets = domain.split(".");
  if (octets.length === 4 && octets.every((o) => /^\d{1,3}$/.test(o))) {
    const [a, b] = octets.map(Number);
    if (a === 10) return true;
    if (a === 172 && b >= 16 && b <= 31) return true;
    if (a === 192 && b === 168) return true;
  }

  return false;
}

export function hashId(url: string, visitedAt: number): string {
  const str = `\${url}|\${visitedAt}`;
  let hash = 5381;
  for (let i = 0; i < str.length; i++) {
    hash = ((hash << 5) + hash) ^ str.charCodeAt(i);
    hash |= 0;
  }
  return (hash >>> 0).toString(36);
}

这四个函数中的每一个都解决了一个问题,你可能在构建的后期才会注意到这些问题:

  • isHttpUrl 是 live capture 和 backfill 共享的方案守卫,也是唯一一个将 chrome://、chrome-extension://、about:// 和 file:// URL 完全排除在你的数据之外的门。两个捕获路径在执行任何其他操作之前都会调用它。
  • extractDomain 会去掉开头的 www. 并返回主机名,这只是一个简化的处理:bbc.co.uk 和 news.bbc.co.uk 在这种逻辑下不会合并到同一个域名,因为真正的可注册域名提取需要公共后缀列表。如果 URL 格式不正确,它会直接返回输入内容,而不是抛出异常。
  • isLocalHost 的存在只有一个原因:当你在本指南的后面部分添加品牌丰富信息时,你将把域名发送到一个外部 API。localhost:5173 或 192.168.1.50 对该 API 来说毫无意义,只会造成浪费的查询,因此最好在这里一次性过滤掉它们。它会检查 localhost、127.0.0.1、.local 域名以及标准的私有 IPv4 范围(10.x.x.x、172.16.x.x – 172.31.x.x、192.168.x.x)。
  • hashId 使用一个简单的哈希算法(djb2)将 URL 和时间戳组合成一个简短且确定性的字符串,因此相同的(url, visitedAt)对总是生成相同的 ID。这使得写入操作具有幂等性:重新运行回填操作会为相同的访问生成相同的 ID,因此 IndexedDB 的 put 操作会干净地覆盖而不是重复,这正是为什么可以多次点击“扫描我的历史”是安全的。

数据库层(目前为止)

openloops 通过 idb 封装器将所有内容存储在 IndexedDB 中,它提供了基于原始 IndexedDB 调用的类型化、基于 Promise 的 API。创建 src/db/index.ts:

code
import { openDB, type DBSchema, type IDBPDatabase } from "idb";
import type { RawEvent } from "../types";

interface OpenloopsDB extends DBSchema {
  raw_events: {
    key: string;
    value: RawEvent;
    indexes: { by_visitedAt: number };
  };
}

const DB_NAME = "openloops";
const DB_VERSION = 1;

let _db: Promise<IDBPDatabase<OpenloopsDB>> | null = null;

export function getDB(): Promise<IDBPDatabase<OpenloopsDB>> {
  if (!_db) {
    _db = openDB<OpenloopsDB>(DB_NAME, DB_VERSION, {
      upgrade(db) {
        if (!db.objectStoreNames.contains("raw_events")) {
          const s = db.createObjectStore("raw_events", { keyPath: "id" });
          s.createIndex("by_visitedAt", "visitedAt");
        }
      },
    });
  }
  return _db;
}

export async function clearEvents(): Promise<void> {
  const db = await getDB();
  return db.clear("raw_events");
}

export async function putEvents(events: RawEvent[]): Promise<void> {
  if (events.length === 0) return;
  const db = await getDB();
  const tx = db.transaction("raw_events", "readwrite");
  await Promise.all([...events.map((e) => tx.store.put(e)), tx.done]);
}

export async function getAllEvents(): Promise<RawEvent[]> {
  const db = await getDB();
  return db.getAllFromIndex("raw_events", "by_visitedAt");
}

export async function getEventCount(): Promise<number> {
  const db = await getDB();
  return db.count("raw_events");
}

这第一个版本的数据库层由四个小函数组成:clearEvents 会清除存储,回填操作首先调用它,因此每次扫描都从一个干净的快照开始。putEvents 使用 IDB 的 put 方法写入一批数据,它会覆盖而不是重复。getAllEvents 通过索引返回所有按 visitedAt 排序的数据。getEventCount 返回一个简单的计数用于仪表板。

_db 是一个模块级别的单例 Promise,因此扩展的每个部分,包括服务工作线程和仪表板,都共享一个连接。DB_VERSION 在这里从 1 开始。当你在后面的章节中添加会话、意图线程和品牌数据时,你会通过 if (!db.objectStoreNames.contains(...)) 添加新的存储,并增加这个数字。这个保护机制确保现有用户可以安全地升级而不会影响到已存在的存储。

code
import { hashId, extractDomain, isHttpUrl } from "./lib/util";
import { putEvents } from "./db/index";
import type { RawEvent } from "./types";

chrome.runtime.onInstalled.addListener(() => {
  console.log("[openloops] 扩展已安装。");
});

chrome.action.onClicked.addListener(() => {
  chrome.runtime.openOptionsPage();
});

const DEDUP_MS = 3_000;
const recentCaptures = new Map<number, { url: string; at: number }>();

chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
  if (changeInfo.status !== "complete" || !tab.url) return;

  const url = tab.url;

  if (!isHttpUrl(url)) return;

  const last = recentCaptures.get(tabId);
  const now = Date.now();
  if (last && last.url === url && now - last.at < DEDUP_MS) {
    console.log(`[openloops] 去重跳过 — tab \({tabId} \){url}`);
    return;
  }

  recentCaptures.set(tabId, { url, at: now });

  const event: RawEvent = {
    id: hashId(url, now),
    url,
    domain: extractDomain(url),

    visitedAt: now,
    source: "live",
  };

  putEvents([event]).then(() => {
    console.log(`[openloops] 捕获到 \({event.domain} — \){event.title}`);
  }).catch((err) => {
    console.error("[openloops] putEvents 失败:", err);
  });
});

chrome.action.onClicked 是让工具栏图标打开仪表板作为标签页而不是弹出窗口的功能,它与你的清单文件中的 options_page 条目协同工作。

实时捕获发生在 tabs.onUpdated 监听器中,Chrome 在页面加载、重定向和更新标题时会反复触发这个监听器,但你只需关注 changeInfo.status === "complete" 的那一刻。从那里开始,isHttpUrl 会过滤掉非真实网页的内容,去重保护机制会减少单页应用(SPAs)喜欢触发的重复 "complete" 事件,其余内容将变成一个源为 "live" 的 RawEvent。

该去重机制是尽力而为的设计:recentCaptures 是一个普通的内存 Map,Chrome 可能在事件之间暂停服务工作线程,这会同时清除 Map。它仍然可以在单次唤醒会话中合并重复的突发数据,但不能跨服务工作线程重启,这在 hashId 已经使得任何漏过的重复数据在到达 IndexedDB 后变得无害的情况下,是一个可以接受的权衡。

最后的写入操作看起来稍微有些不同:putEvents([event]).then(...).catch(...) 而不是使用 await。监听器不需要等待写入完成,服务工作线程会存活足够长的时间以完成一次 IndexedDB 写入,即使它即将被暂停,因此触发写入并继续即可。

该 source 字段的重要性比它看起来的更大,因为它就是后续代码区分“用户确实扫描了他们的历史记录”和“扩展程序只打开了五分钟”的方式。这在你稍后设计仪表板时的引导过程中非常重要。

现在构建并重新加载扩展(运行 npm run build,然后点击 chrome://extensions 中扩展卡片上的重新加载图标),浏览几个页面,然后通过点击扩展卡片上的“service worker”打开服务工作线程的 DevTools。你将能够看到 [openloops] captured ... 日志行的出现,这确认了实时捕获正在正常工作。

补充14天的历史记录

实时捕获只能看到你安装扩展后发生的事情,因此为了使 openloops 立即有用,你也需要补充最近的历史记录。创建 src/pipeline/backfill.ts:

code
import { extractDomain, hashId, isHttpUrl } from "../lib/util";
import { putEvents, clearEvents } from "../db/index";
import type { RawEvent } from "../types";

const CONCURRENCY = 50;

async function visitsForItem(
  item: chrome.history.HistoryItem,
  startTime: number
): Promise<RawEvent[]> {
  if (!item.url) return [];
  if (!isHttpUrl(item.url)) return [];

  const visits = await chrome.history.getVisits({ url: item.url });

  const events: RawEvent[] = [];
  for (const visit of visits) {
    if (!visit.visitTime || visit.visitTime < startTime) continue;

    events.push({
      id: hashId(item.url, visit.visitTime),
      url: item.url,
      domain: extractDomain(item.url),

      visitedAt: visit.visitTime,
      source: "backfill",
    });
  }

  return events;
}

export async function backfillHistory(days = 14): Promise<number> {
  await clearEvents();

  const startTime = Date.now() - days * 24 * 60 * 60 * 1000;

  const historyItems = await chrome.history.search({
    text: "",
    startTime,
    maxResults: 100_000,
  });

  let totalWritten = 0;

  for (let i = 0; i < historyItems.length; i += CONCURRENCY) {
    const batch = historyItems.slice(i, i + CONCURRENCY);
    const batchResults = await Promise.all(
      batch.map((item) => visitsForItem(item, startTime))
    );
    const events = batchResults.flat();
    await putEvents(events);
    totalWritten += events.length;
  }

  return totalWritten;
}

backfillHistory 首先调用 clearEvents 以清除存储,这样每次运行都会为选定的时间窗口生成一个干净的快照。所有实际的访问记录仍然保留在 chrome.history 中,因此重新开始不会丢失任何数据。然后,它使用 maxResults: 100_000 进行搜索,因为默认的 100 对于浏览历史超过几天的用户来说远远不够。

每个匹配的 HistoryItem 都会通过 visitsForItem 进行处理,该函数会跳过 Chrome 返回的那些完全没有 url 的条目(这是某些删除历史记录条目的怪癖),并使用 isHttpUrl 跳过非网络 URL,然后再获取该条目的完整访问列表。

在此处调用 getVisits 而不是仅依赖搜索非常重要,因为 chrome.history.search 虽然看起来像是一个简单的调用,但它会将每个 URL 的所有访问记录压缩为最近的一次。如果你在调试某个问题时,两天内访问了同一个 Stack Overflow 答案三次,搜索只会返回一行记录。而在下一节中,当你将事件划分为会话时,你需要这三次访问记录:这是“一次访问,三天前”和“一次持续的调试会话”之间的区别。

getVisits 会返回完整的访问时间戳列表,但它会返回 URL 的所有历史记录,不管时间范围如何,因此 visitsForItem 会自行通过 startTime 进行过滤。此外,由于 chrome.history.search 可能会为浏览器历史记录较多的用户返回成千上万的条目,backfill 会以 CONCURRENCY 批次(设置为 50)的方式调用 getVisits,而不是一次性全部触发。Chrome 没有明确规定并发调用 getVisits 的硬性限制,但每次同时进行 50 次调用可以保持响应性,而不会造成过多的负担。

检查点

你可以通过正常浏览并观察 raw_events 填充来验证实时捕获:打开 chrome://extensions,点击 openloops 卡片上的“service worker”,然后进入 Application 标签页 → IndexedDB → openloops → raw_events,每个条目应是一个源为“live”的 RawEvent。

backfillHistory 本身目前还没有用户界面,但当你在第 13 部分构建仪表板轨道时,你会将其连接到一个“扫描我的历史”按钮。目前,只要它能够编译,并且 raw_events 能够从实时捕获中填充,就已经足够了。在下一部分中,你将开始将这个原始流转换为结构化的数据:会话。

如何将噪声转换为会话

真实的浏览历史中充满了与你实际想要做的事情无关的活动。一个下午的研究可能会夹杂着数十次访问 Gmail、Slack 或 YouTube,以及一些标题只是“新标签页”或“仪表板”的页面,因为当浏览器记录时,页面尚未加载完成。

在这些内容能够被归类为有意义的信息之前,有两件事需要发生:噪声需要被过滤掉,剩下的内容需要被划分为会话,即由时间间隔分隔的连续活动段。

本节将构建这两个步骤,以及一个小型的关键词提取器,每个会话都会使用它来描述其内容,因为这个描述是后续聚类分析的基础。

过滤噪声

创建 src/pipeline/noise.ts:

code
import type { RawEvent } from "../types";
import { isHttpUrl, isLocalHost } from "../lib/util";

export const BLOCKED_DOMAINS: readonly string[] = [
  "mail.google.com",
  "outlook.live.com",
  "outlook.office.com",
  "calendar.google.com",
  "slack.com",
  "app.slack.com",
  "discord.com",
  "web.whatsapp.com",
  "teams.microsoft.com",
  "messenger.com",
];

export const ADULT_DOMAINS: readonly string[] = [
  "xvideos.com",
  "pornhub.com",
  "xnxx.com",
  "xhamster.com",
  "redtube.com",
  "youporn.com",
  "spankbang.com",
];

export const JUNK_DOMAINS: readonly string[] = [
  "trk.myperfect2give.com",
  "t.buenotraffic.com",
  "bwredir.com",
  "osom.saintscommunity.net",
];

const ALL_BLOCKED = [...BLOCKED_DOMAINS, ...ADULT_DOMAINS, ...JUNK_DOMAINS];

function domainIsBlocked(domain: string): boolean {
  return ALL_BLOCKED.some(
    (blocked) => domain === blocked || domain.endsWith("." + blocked)
  );
}

export const NOISE_TITLE_PREFIXES: readonly string[] = [
  "new tab",
  "new chat",
  "untitled",
  "inbox",
  "home",
  "dashboard",
  "sign in",
  "log in",
  "loading",
];

function titleIsGeneric(title: string, domain: string): boolean {
  if (title.trim() === "") return true;
  if (title.toLowerCase() === domain.toLowerCase()) return true;

  const lower = title.toLowerCase();
  return NOISE_TITLE_PREFIXES.some((prefix) => lower.startsWith(prefix));
}

export function isNoise(event: RawEvent): boolean {
  if (!isHttpUrl(event.url)) return true;
  if (isLocalHost(event.domain)) return true;
  return domainIsBlocked(event.domain) || titleIsGeneric(event.title, event.domain);
}

isNoise 是管道其余部分调用的单一函数,它在每个事件上叠加了四次检查,每次检查都捕捉不同类型的噪声。

前两次检查重用了之前的辅助函数:isHttpUrl 和 isLocalHost 会丢弃不是真实网页或指向本地开发服务器的内容,这些过滤器与捕获功能中已有的保护措施相同。在这里再次检查它们是一种有意为之的双重保障措施:如果任何内容在没有通过捕获检查的情况下到达 raw_events,它仍然无法进入会话。

BLOCKED_DOMAINS 包括通信和生产力工具、Gmail、Slack、Discord、WhatsApp Web 以及类似的网站。这些是你经常访问但本身不带有研究意图的工具。domainIsBlocked 会匹配精确的域名以及任何子域名,因此列表中的 slack.com 也会匹配 app.slack.com。ADULT_DOMAINS 和 JUNK_DOMAINS 的存在也是出于类似的原因,它们可以完全将成人内容和已知的跟踪或重定向域名排除在你的线程之外。

BLOCKED_DOMAINS 是一个经过精心挑选的静态列表,稍后在本指南中,它会由 ambient.ts 中的第二个基于频率的检测器进行补充。这个检测器会过滤掉那些在几乎所有会话中都会出现的域名,无论该域名具体是什么。

最后一次检查,titleIsGeneric,会捕获那些标题没有提供任何有用信息的页面:空标题、与域名相同的标题,或者以通用前缀开头的标题,例如 "New Tab"、"Dashboard"、"Loading..." 或 "Sign in"。NOISE_TITLE_PREFIXES 会与小写标题的开头进行匹配,因此 "Dashboard | Vercel" 会被过滤掉,与单纯的 "Dashboard" 一样,而同一域名上的内容丰富的标题则会通过而不受影响。

提取关键词

创建 src/pipeline/keywords.ts。这不是自然语言处理,只是在去除停用词之后的频率统计。这足以从相关浏览会话中提取出类似 "typescript generics" 或 "react hooks" 的关键词:

code
import { BLOCKED_DOMAINS } from "./noise";

export const STOPWORDS: ReadonlySet<string> = new Set([
  "the", "and", "for", "with", "you", "your", "how", "what", "this", "that",
  "from", "are", "was", "not", "but", "all", "can", "has", "have", "will",
  "its", "out", "one", "get", "our", "had", "just", "about", "also", "more",
  "into", "than", "then", "when", "their", "there", "which", "would", "been",
  "his", "her", "who", "they", "she", "him", "now", "any", "way", "use",
  "using", "used", "make", "made",
  "google", "youtube", "search", "chat", "new", "home", "www", "com", "org",
  "net", "page", "site", "tab", "view", "app", "log", "sign", "login",
  "official", "free", "online", "best", "top", "open",
]);

export const PLATFORM_STOPWORDS: ReadonlySet<string> = new Set([
  "instagram", "facebook", "youtube", "claude", "google", "linkedin",
  "twitter", "reddit", "netflix", "amazon", "gmail", "whatsapp", "tiktok",
  "messenger",
  "stories", "story", "reel", "reels", "shorts", "short", "feed", "watch",
  "video", "videos", "music", "post", "posts", "message", "messages",
  "dm", "dms", "notification", "notifications", "profile", "home", "login",
  "signin", "follow", "followers",
]);

function derivedDomainLabels(): Set<string> {
  const labels = new Set<string>();
  for (const domain of BLOCKED_DOMAINS) {
    const label = domain.split(".").at(-2);
    if (label) labels.add(label);
  }
  return labels;
}

const ALL_STOP_TOKENS: ReadonlySet<string> = new Set([
  ...STOPWORDS,
  ...PLATFORM_STOPWORDS,
  ...derivedDomainLabels(),
]);

export function extractKeywords(titles: string[], max = 8): string[] {
  const freq = new Map<string, number>();

  for (const title of titles) {
    const tokens = title.toLowerCase().split(/[^a-z0-9]+/);
    for (const token of tokens) {
      if (token.length < 3) continue;
      if (/^\d+$/.test(token)) continue;
      if (ALL_STOP_TOKENS.has(token)) continue;

      freq.set(token, (freq.get(token) ?? 0) + 1);
    }
  }
code
return [...freq.entries()]
  .sort((a, b) => b[1] - a[1])
  .slice(0, max)
  .map(([token]) => token);
}

extractKeywords 从一组事件的页面标题中提取出关键词,返回出现频率最高的几个词,这些词是去除所有非主题内容后得到的。这个去除过程所做的工作比“停用词”这个名字所暗示的要多得多。

STOPWORDS 包含了常见的英语功能词,如 "the" 和 "with",还包括一些通用的网站元素,如 "search"、"login" 和 "page"。仅靠这些停用词,仍然无法过滤掉像 "instagram" 或 "reels" 这样的词,比如标题 "Reels · Instagram" 中的这些词,它们仍会被提取为关键词。

这个差距就是 PLATFORM_STOPWORDS 要解决的问题。像 "Reels · Instagram" 或 "Watch - YouTube" 这样的标题,标识的是你使用的工具,而不是你用它做了什么。因此,PLATFORM_STOPWORDS 会过滤掉平台和品牌名称,以及社交媒体的界面元素,如 "stories"、"feed"、"dm" 和 "notifications"。如果没有这个列表,社交平台上的会话将提取出像 "instagram" 或 "watch" 这样的关键词。这些关键词会成为线程标题,在聚类过程中悄悄地将不相关的会话聚集在一起,因为每个社交媒体会话都会共享这个无意义的关键词。

derivedDomainLabels 自动同步了第三个停用词来源:对于 BLOCKED_DOMAINS 中的每个域名,它会提取顶级域名之前的标签。例如,mail.google.com 会变成 google,web.whatsapp.com 会变成 whatsapp。之后,如果将新的域名添加到这个黑名单中,也可以防止其名称污染关键词,而无需额外的管理。

当所有三个集合在模块加载时合并到 ALL_STOP_TOKENS 中,extractKeywords 本身变得简单:将每个标题转换为小写,按非字母或数字的字符进行分割,丢弃长度小于三个字符或完全由数字组成的词,同时丢弃 ALL_STOP_TOKENS 中的词。然后统计剩余的内容,并返回出现频率最高的条目。

为会话扩展数据库

会话需要一个存储的地方。在本指南的前面部分,src/db/index.ts 定义了一个仅包含 raw_events 的版本为 1 的模式。现在,我们将添加一个 sessions 存储,并将版本提升到 2。

首先,扩展模式和升级回调:

code
import type { RawEvent, Session } from "../types";

interface OpenloopsDB extends DBSchema {
  raw_events: {
    key: string;
    value: RawEvent;
    indexes: { by_visitedAt: number };
  };
  sessions: {
    key: string;
    value: Session;
    indexes: { by_startedAt: number };
  };
}

const DB_VERSION = 2;

export function getDB(): Promise<IDBPDatabase<OpenloopsDB>> {
  if (!_db) {
    _db = openDB<OpenloopsDB>(DB_NAME, DB_VERSION, {
      upgrade(db) {
        if (!db.objectStoreNames.contains("raw_events")) {
          const s = db.createObjectStore("raw_events", { keyPath: "id" });
          s.createIndex("by_visitedAt", "visitedAt");
        }
        if (!db.objectStoreNames.contains("sessions")) {
          const s = db.createObjectStore("sessions", { keyPath: "id" });
          s.createIndex("by_startedAt", "startedAt");
        }
      },
    });
  }
  return _db;
}

然后添加会话所需的辅助函数,与你已经编写的 raw_events 辅助函数一起。它们遵循相同的结构:putSessions 以幂等方式写入一批数据,clearSessions 在重建之前清空存储,getAllSessions 通过索引返回按 startedAt 排序的所有内容,getSessionCount 返回总数。

code
export async function putSessions(sessions: Session[]): Promise<void> {
  if (sessions.length === 0) return;
  const db = await getDB();
  const tx = db.transaction("sessions", "readwrite");
  await Promise.all([...sessions.map((s) => tx.store.put(s)), tx.done]);
}

export async function clearSessions(): Promise<void> {
  const db = await getDB();
  return db.clear("sessions");
}

export async function getAllSessions(): Promise<Session[]> {
  const db = await getDB();
  return db.getAllFromIndex("sessions", "by_startedAt");
}

export async function getSessionCount(): Promise<number> {
  const db = await getDB();
  return db.count("sessions");
}

前面提到的 if (!db.objectStoreNames.contains(...)) 保护机制使得这一切变得安全:任何已经拥有一个版本 1 数据库的用户,其中 raw_events 中包含真实数据,都会在不修改已有内容的前提下,新增一个 sessions 存储。

将事件划分为会话

会话是一段连续的浏览活动,当两个连续事件之间的间隔超过 SESSION_GAP_MS 时,就会开始一个新的会话。创建 src/pipeline/sessions.ts

code
import { getAllEvents, clearSessions, putSessions } from "../db/index";
import { isNoise } from "./noise";
import { extractKeywords } from "./keywords";
import { hashId } from "../lib/util";
import type { RawEvent, Session } from "../types";

const SESSION_GAP_MS = 30 * 60 * 1000;

function rankDomains(events: RawEvent[]): string[] {
  const freq = new Map<string, number>();
  for (const e of events) {
    freq.set(e.domain, (freq.get(e.domain) ?? 0) + 1);
  }
  return [...freq.entries()]
    .sort((a, b) => b[1] - a[1])
    .map(([domain]) => domain);
}

function buildSession(events: RawEvent[]): Session {
  const startedAt = events[0].visitedAt;
  const endedAt = events[events.length - 1].visitedAt;

  return {
    id: hashId(events[0].url, startedAt),
    events,
    startedAt,
    endedAt,
    domains: rankDomains(events),
    keywords: extractKeywords(events.map((e) => e.title)),
  };
}

export async function buildSessions(): Promise<{ events: number; sessions: number }> {
  const allEvents = await getAllEvents();

  const meaningful = allEvents.filter((e) => !isNoise(e));

  if (meaningful.length === 0) {
    await clearSessions();
    return { events: 0, sessions: 0 };
  }

  const sessions: Session[] = [];
  let currentGroup: RawEvent[] = [meaningful[0]];

  for (let i = 1; i < meaningful.length; i++) {
    const gap = meaningful[i].visitedAt - meaningful[i - 1].visitedAt;

    if (gap > SESSION_GAP_MS) {
      sessions.push(buildSession(currentGroup));
      currentGroup = [meaningful[i]];
    } else {
      currentGroup.push(meaningful[i]);
    }
  }
  sessions.push(buildSession(currentGroup));

  const substantive = sessions.filter(
    (s) => !(s.events.length === 1 && s.keywords.length === 0)
  );

  await clearSessions();
  await putSessions(substantive);

  return { events: meaningful.length, sessions: substantive.length };
}

buildSessions 按照以下五个步骤依次执行:

  • 加载按时间排序的所有原始事件,
  • 过滤掉所有 isNoise 标志为真的事件,
  • 遍历剩余的事件列表,当两个连续事件之间的间隔超过 SESSION_GAP_MS 时,开始一个新的会话(循环结束后,将最后一个未完成的组加入会话列表,因为没有其他操作会关闭它),
  • 过滤掉那些最终只包含一个事件且没有可提取关键词的会话(通常是孤立的页面加载,没有与其他内容连接),
  • 并持久化结果。

每个会话的域名和关键词来自于仅对该组中的事件运行 rankDomainsextractKeywordsrankDomains 统计每个域名的事件数量,并按频率排序,因此一个会话中最常访问的域名排在最前面。

一个实际的例子可以让“遍历列表”这个概念变得具体。假设有五个通过噪声过滤的事件,分别标记为 A 到 E:

code
A  t= 0 min  "TypeScript generics - Stack Overflow"   stackoverflow.com
B  t= 5 min  "TypeScript Handbook"                    typescriptlang.org
C  t=10 min  "microsoft/TypeScript - GitHub"          github.com
   ↑ 到 D 的间隔为 45 分钟  >  SESSION_GAP_MS (30 分钟)  → 此处分割
D  t=55 min  "React hooks explained - YouTube"         youtube.com
E  t=60 min  "useEffect cleanup - Stack Overflow"     stackoverflow.com

当循环从 A 到 B 再到 C 时,每个间隔都在 30 分钟的限制内,因此这三个事件保留在同一个组中。从 C 到 D 的间隔是 45 分钟,超过了 SESSION_GAP_MS,因此循环将 [A, B, C] 作为会话 1 关闭,并从 D 开始一个新的组。从 D 到 E 的间隔只有 5 分钟,因此 E 加入 D,循环结束后,该组成为会话 2。

会话 1 最终被标记为诸如 typescript 和 generics 等关键词,而会话 2 被标记为 react 和 hooks,即使这两个会话发生在同一天。

SESSION_GAP_MS 设置为 30 分钟,因为这是 Google Analytics 和类似工具使用的默认值,并且对大多数浏览模式都适用。

权衡在两个方向都存在:较短的间隔会产生更多、更小的会话,这为聚类提供了更细致的信号,但可能会将一个连续的任务分割成多个部分。较长的间隔会产生更少、更大的会话,这可能会将实际上无关的活动合并在一起。

30 分钟是一个合理的起点,它是一种你可以在看到自己的线程结果后回来调整的常量。

buildSessions 目前也没有用户界面。稍后在本指南中设计仪表板时,它将与“Scan my history”按钮并排连接到一个“Build sessions”按钮。

目前,目标只是让本节中的所有内容都能干净地编译:src/pipeline/noise.ts、src/pipeline/keywords.ts、更新后的 src/db/index.ts 以及 src/pipeline/sessions.ts 都应能无错误地构建。下次扩展程序重新加载时,getDB() 应该报告版本 2(在 DevTools 的 Application → IndexedDB → openloops 下可见,数据库现在将 raw_events 和 sessions 列为对象存储)。

有了会话之后,下一节将把这种结构化但未连接的数据组合在一起,形成本项目命名的意图线程。

如何将会话聚类为意图线程

会话将时间上接近的事件分组。但你实际尝试做的事情很少能包含在一个会话中。比较笔记本电脑可能需要四天内的三个会话。你一直想查的问题可能每隔几天出现十分钟,持续两周。

本节将相关会话分组为意图线程,然后对每个线程进行评分,以衡量 openloops 对其代表真实事物的置信度以及其仍然活跃的程度。

两个文件负责完成这项工作。src/pipeline/ambient.ts 用于检测那些属于你日常使用习惯的域名,而不是特定意图的域名,因此它们不会在不相关的会话之间产生虚假的相似性。src/pipeline/threads.ts 负责实际的聚类和评分。

检测背景域名

一些域名几乎在每次会话中都会出现,无论你正在做什么:例如 youtube.com 作为背景噪音,github.com 如果你是一名每天都提交代码的开发者,或者 claude.ai 如果你将其用作通用助手。如果聚类算法在这些域名上以与其它域名相同的方式进行比较,那么两个完全不相关的会话仅仅因为它们都访问了 youtube.com 就会看起来相似,最终所有内容都会合并成一个巨大的线程。

ambient.ts 通过频率检查来解决这个问题:如果一个域名在足够多的活跃日中出现,无论主题如何,它都被视为背景域名。

创建 src/pipeline/ambient.ts:

code
import type { Session } from "../types";

export const UBIQUITY_THRESHOLD = 0.6;
export const MIN_ACTIVE_DAYS = 3;

function toDay(epochMs: number): string {
  return new Date(epochMs).toDateString();
}

export function detectAmbientDomains(sessions: Session[]): Set<string> {
  const allEvents = sessions.flatMap((s) => s.events);

  const activeDays = new Set(allEvents.map((e) => toDay(e.visitedAt)));
  const totalActiveDays = activeDays.size;

  if (totalActiveDays < MIN_ACTIVE_DAYS) {
    return new Set();
  }

  const domainDayMap = new Map<string, Set<string>>();
  for (const event of allEvents) {
    const day = toDay(event.visitedAt);
    if (!domainDayMap.has(event.domain)) {
      domainDayMap.set(event.domain, new Set());
    }
    domainDayMap.get(event.domain)!.add(day);
  }

  const ambient = new Set<string>();
  for (const [domain, days] of domainDayMap) {
    const ubiquity = days.size / totalActiveDays;
    if (ubiquity >= UBIQUITY_THRESHOLD) {
      ambient.add(domain);
      console.log(
        `[openloops] ambient: \${domain} (\${days.size}/\${totalActiveDays} days, ubiquity=\${ubiquity.toFixed(2)})`
      );
    }
  }

  return ambient;
}

toDay 函数将时间戳压缩为日历日字符串,因此同一天的两个事件会产生相同的键,无论具体时间如何。

detectAmbientDomains 首先统计有多少个不同的日子有浏览活动,即 totalActiveDays,然后构建一个从每个域名到它出现的日期集合的映射。一个域名的普遍性是 days.size / totalActiveDays,即该域名出现在你活跃日中的比例。任何达到或超过 UBIQUITY_THRESHOLD 0.6 的域名都会被添加到返回的集合中。

MIN_ACTIVE_DAYS 存在的原因是,如果只有 1 或 2 天的数据,你访问的几乎所有域名在技术上都会出现在你所有活跃日的 100%,检测器会将所有内容标记为背景。活跃日少于三天时,它会返回一个空集合并完全跳过检测。

这种方法存在一个真正的权衡。它能够正确识别真正背景的工具,但也可能抑制你恰好在一周内每天深入研究的某个域名,这个域名也会超过 60% 的阈值。

UBIQUITY_THRESHOLD 是这个权衡的调节器:提高它会减少误报,但代价是让一些真正的背景噪音重新出现。

扩展数据库以支持意图线程

线程需要自己的存储。将 DB_VERSION 提升到 3,并添加 intent_threads,按 lastSeen 进行索引,这样仪表板可以优先显示最近最活跃的线程:

code
import type { RawEvent, Session, IntentThread } from "../types";

interface OpenloopsDB extends DBSchema {
  raw_events: {
    key: string;
    value: RawEvent;
    indexes: { by_visitedAt: number };
  };
  sessions: {
    key: string;
    value: Session;
    indexes: { by_startedAt: number };
  };
  intent_threads: {
    key: string;
    value: IntentThread;
    indexes: { by_lastSeen: number };
  };
}

const DB_VERSION = 3;

export function getDB(): Promise<IDBPDatabase<OpenloopsDB>> {
  if (!_db) {
    _db = openDB<OpenloopsDB>(DB_NAME, DB_VERSION, {
      upgrade(db) {
        if (!db.objectStoreNames.contains("raw_events")) {
          const s = db.createObjectStore("raw_events", { keyPath: "id" });
          s.createIndex("by_visitedAt", "visitedAt");
        }
        if (!db.objectStoreNames.contains("sessions")) {
          const s = db.createObjectStore("sessions", { keyPath: "id" });
          s.createIndex("by_startedAt", "startedAt");
        }
        if (!db.objectStoreNames.contains("intent_threads")) {
          const s = db.createObjectStore("intent_threads", { keyPath: "id" });
          s.createIndex("by_lastSeen", "lastSeen");
        }
      },
    });
  }
  return _db;
}

然后添加匹配的辅助函数:

code
export async function putThreads(threads: IntentThread[]): Promise<void> {
  if (threads.length === 0) return;
  const db = await getDB();
  const tx = db.transaction("intent_threads", "readwrite");
  await Promise.all([...threads.map((t) => tx.store.put(t)), tx.done]);
}

export async function clearThreads(): Promise<void> {
  const db = await getDB();
  return db.clear("intent_threads");
}

export async function getAllThreads(): Promise<IntentThread[]> {
  const db = await getDB();
  const index = db
    .transaction("intent_threads", "readonly")
    .store.index("by_lastSeen");

  let cursor = await index.openCursor(null, "prev");
  const results: IntentThread[] = [];
  while (cursor) {
    results.push(cursor.value);
    cursor = await cursor.continue();
  }
  return results;
}

export async function getThreadCount(): Promise<number> {
  const db = await getDB();
  return db.count("intent_threads");
}

putThreads、clearThreads 和 getThreadCount 的模式与之前会话辅助函数相同。getAllThreads 是一个特例:与只返回升序的 getAllFromIndex 不同,它在 by_lastSeen 上以 "prev" 方向打开游标并手动遍历,这样可以按照最近最活跃的顺序返回线程,符合仪表板对状态分组卡片的排序需求。

将会话聚类为线程

在识别出环境域后,src/pipeline/threads.ts 现在执行真正的操作:将会话聚类为线程,然后对每个线程进行评分和分类。

采用的方法是贪婪的凝聚聚类。按时间顺序遍历会话,对每个会话,要么将其合并到最相似的现有线程中,要么如果没有足够相似的线程,则创建一个新线程。

从导入、调整常量和相似度计算开始:

code
import { getAllSessions, clearThreads, putThreads } from "../db/index";
import { detectAmbientDomains } from "./ambient";
import { hashId } from "../lib/util";
import type { Session, IntentThread } from "../types";
ts
export const SIMILARITY_THRESHOLD = 0.15;
export const DOMAIN_WEIGHT = 0.5;
export const KEYWORD_WEIGHT = 0.5;

interface ThreadBuilder {
  id: string;
  sessions: Session[];
  domainSet: Set<string>;
  keywordSet: Set<string>;
}

function jaccard(a: Set<string>, b: Set<string>): number {
  if (a.size === 0 && b.size === 0) return 0;
  let intersection = 0;
  for (const item of a) {
    if (b.has(item)) intersection++;
  }
  const union = a.size + b.size - intersection;
  return intersection / union;
}

function similarity(
  session: Session,
  thread: ThreadBuilder,
  ambient: Set<string>
): number {
  const sessionDomains  = new Set(session.domains.filter((d) => !ambient.has(d)));
  const threadDomains   = new Set([...thread.domainSet].filter((d) => !ambient.has(d)));
  const sessionKeywords = new Set(session.keywords);

  const domainScore   = jaccard(sessionDomains, threadDomains);
  const keywordScore  = jaccard(sessionKeywords, thread.keywordSet);

  return DOMAIN_WEIGHT * domainScore + KEYWORD_WEIGHT * keywordScore;
}

ThreadBuilder 是一个可变的累加器,仅在聚类过程中使用:一个正在进行中的线程,包含其会话以及到目前为止看到的所有域名和关键词的并集。jaccard 是标准的集合相似度度量方法,即交集大小除以并集大小,当两个集合都为空时返回 0,而不是 0 除以 0。

similarity 将一个候选会话与一个正在进行中的线程进行比较。在比较域名之前,它会从两边过滤掉环境域名,因此一个共享的 youtube.com 永远不会对得分产生贡献。然后它分别计算域名的 Jaccard 分数和关键词的 Jaccard 分数,并使用 DOMAIN_WEIGHT 和 KEYWORD_WEIGHT(均为 0.5)将它们结合起来,使域名重叠和关键词重叠在最终得分中具有同等的重要性。

接下来是聚类循环本身:

ts
function clusterSessions(
  sessions: Session[],
  ambient: Set<string>
): ThreadBuilder[] {
  const threads: ThreadBuilder[] = [];

  for (const session of sessions) {
    let bestThread: ThreadBuilder | null = null;
    let bestScore = 0;

    for (const thread of threads) {
      const score = similarity(session, thread, ambient);
      if (score > bestScore) {
        bestScore = score;
        bestThread = thread;
      }
    }

    if (bestThread && bestScore >= SIMILARITY_THRESHOLD) {
      bestThread.sessions.push(session);
      for (const d of session.domains)  bestThread.domainSet.add(d);
      for (const k of session.keywords) bestThread.keywordSet.add(k);
    } else {
      threads.push({
        id: hashId(session.id, session.startedAt),
        sessions: [session],
        domainSet:  new Set(session.domains),
        keywordSet: new Set(session.keywords),
      });
    }
  }

  return threads;
}

clusterSessions 依赖于会话已经按时间顺序排序,getAllSessions 通过其索引保证了这一点。对于每个会话,它会与到目前为止构建的每个线程进行评分,并保留最佳匹配。

如果最佳得分超过 SIMILARITY_THRESHOLD,该会话将合并到线程中,其域名和关键词将被合并到线程的累积集合中。这意味着后续的会话将与线程的整个累积历史进行比较,而不仅仅是其初始会话。如果没有得分超过阈值,该会话将成为一个全新线程的种子。

一个实际例子展示了这一过程。假设 detectAmbientDomains 返回 { youtube.com },并且有三个会话按以下顺序到达:

code
S1: domains=[stackoverflow.com, typescriptlang.org]
    keywords=[typescript, generics, interface, mapped]

S2: domains=[stackoverflow.com, typescriptlang.org, github.com]
    keywords=[typescript, generics, utility, types]

S3: domains=[python.org, docs.python.org]
    keywords=[python, async, await, coroutine]

S1 是第一个出现的。此时还没有任何线程,因此它创建了线程 A:domainSet = {stackoverflow.com, typescriptlang.org},keywordSet = {typescript, generics, interface, mapped}。

S2 与线程 A 进行评分。两个集合中都没有 youtube.com,因此没有内容被过滤掉。域的 Jaccard 相似度是 |{stackoverflow.com, typescriptlang.org}| / |{stackoverflow.com, typescriptlang.org, github.com}|,即 2/3 ≈ 0.667。关键词的 Jaccard 相似度是 |{typescript, generics}| / |{typescript, generics, interface, mapped, utility, types}|,即 2/6 ≈ 0.333。综合相似度是 0.5 × 0.667 + 0.5 × 0.333 = 0.5,明显高于 SIMILARITY_THRESHOLD(0.15),因此 S2 合并到线程 A,线程 A 的集合扩展以包含 github.com、utility 和 types。

S3 与线程 A 进行评分。{python.org, docs.python.org} 与线程 A 的域之间没有任何重叠,它们的关键词集合之间也没有任何重叠,因此两个 Jaccard 分数都是 0,综合相似度也是 0。这低于阈值,因此 S3 创建了一个新的线程 B。

结果:线程 A 包含了两次 TypeScript 的研究,而线程 B 独自包含 Python 的会话。

SIMILARITY_THRESHOLD 是这个文件中最重要的常量,0.15 比你可能预期的要低,对于一个 50/50 权重的 Jaccard 分数来说。一个起始值比如 0.3 听起来更有原则。这意味着两个会话需要共享大约三分之一的合并域和关键词,才会被认为属于同一线程。

然而,当这个值应用于真实的、混乱的浏览历史时,它会产生过多的线程:明显属于同一研究但没有共享足够关键词以达到 0.3 的会话,最终会分散到不同的线程中。

将阈值降低到 0.15 允许会话在较弱但仍然真实的信号上合并。只要两个会话共享了几个域和关键词中的一个域和一个关键词,就可以超过 0.15,结果是更少、更连贯的线程,这些线程实际上与浏览历史相符。

这就是你通过经验调整而不是从第一原理推导出的常量:构建线程,查看结果,然后进行调整。

接下来将介绍 buildThreads,它会打印出每个线程的标题、类型、状态、置信度和顶级关键词的表格,以便你直接观察。如果两个线程明显属于同一个主题,就降低 SIMILARITY_THRESHOLD。如果一个线程明显是多个不相关主题拼接在一起,就提高它。

评分和分类线程

聚类生成了会话组,但一个会话组还不是 IntentThread。threads.ts 的其余部分将每个组转换为具有类型、置信度评分、状态和一组可读信号的结构,这些信号解释了为什么。

首先是一些小的辅助函数:

code
export const BUYING_WORDS: readonly string[] = [
  "vs", "versus", "alternative", "alternatives",
  "comparison", "pricing", "price", "review", "reviews", "best",
];

export const LEARNING_WORDS: readonly string[] = [
  "how to", "tutorial", "tutorials", "docs", "documentation",
  "guide", "learn", "example", "examples", "crash course", "introduction",
];
ts
const STATUS_ACTIVE_MS  = 48 * 60 * 60 * 1000;
const STATUS_STALLED_MS = 7  * 24 * 60 * 60 * 1000;

function toTitleCase(s: string): string {
  return s.charAt(0).toUpperCase() + s.slice(1);
}

function findMatches(titles: string[], wordList: readonly string[]): string[] {
  const lower = titles.map((t) => t.toLowerCase());
  const found = new Set<string>();

  for (const word of wordList) {
    const isPhrase = word.includes(" ");
    for (const title of lower) {
      if (isPhrase) {
        if (title.includes(word)) found.add(word);
      } else {
        const tokens = title.split(/[^a-z0-9]+/);
        if (tokens.includes(word)) found.add(word);
      }
    }
  }

  return [...found];
}

function toCalendarDay(epochMs: number): string {
  return new Date(epochMs).toDateString();
}

BUYING_WORDS 和 LEARNING_WORDS 是一些小的词汇表,用于表示意图。findMatches 函数会将页面标题列表与这些词汇表之一进行匹配,并对单个单词和短语进行不同的处理:像 "how to" 这样的多词条目会作为子字符串进行检查,因为它足够具体,不太可能出现误判。但像 "review" 这样的单个单词则会作为完整的标记进行检查,并通过非字母数字字符从标题中分离出来。

如果没有这种区分,"review" 也会匹配到 "overview" 中,这会导致任何涉及 "Overview" 页面的线程被错误分类。toTitleCase 和 toCalendarDay 是一些小的格式化辅助函数,被评分函数所使用。

该评分函数 scoreThread 是项目中最长的函数,因为它是在这里将所有到目前为止收集的信号转换为 IntentThread 上的字段:

ts
function scoreThread(builder: ThreadBuilder): IntentThread {
  const { sessions, keywordSet } = builder;

  const firstSeen  = sessions[0].startedAt;
  const lastSeen   = sessions[sessions.length - 1].endedAt;

  const allEvents  = sessions.flatMap((s) => s.events);
  const totalEvents = allEvents.length;
  const daySet     = new Set(allEvents.map((e) => toCalendarDay(e.visitedAt)));
  const distinctDays = daySet.size;

  const allTitles      = allEvents.map((e) => e.title);
  const buyingMatches  = findMatches(allTitles, BUYING_WORDS);
  const learningMatches = findMatches(allTitles, LEARNING_WORDS);

  let type: IntentThread["type"];
  if (buyingMatches.length > 0) {
    type = "buying";
  } else if (learningMatches.length > 0) {
    type = "learning";
  } else if (distinctDays > 5 && sessions.length >= 3) {
    type = "planning";
  } else if (totalEvents >= 3) {
    type = "research";
  } else {
    type = "unclassified";
  }

  const age = Date.now() - lastSeen;
  const status: IntentThread["status"] =
    age < STATUS_ACTIVE_MS  ? "active"  :
    age < STATUS_STALLED_MS ? "stalled" :
    "dormant";

  const confidence = parseFloat((
    Math.min(distinctDays / 5, 1) * 0.35 +
    Math.min(sessions.length / 5, 1) * 0.25 +
    Math.min(totalEvents / 20, 1)  * 0.20 +
    (type !== "unclassified" ? 1 : 0)  * 0.20
  ).toFixed(2));

  const signals: string[] = [];
javascript
if (distinctDays > 1)
  signals.push(`revisited across ${distinctDays} days`);
if (type === "buying" && buyingMatches.length > 0)
  signals.push(`comparison language: ${buyingMatches.join(", ")}`);
if (type === "learning" && learningMatches.length > 0)
  signals.push(`learning language: ${learningMatches.join(", ")}`);
signals.push(`\${sessions.length} session\${sessions.length !== 1 ? "s" : ""}`);
if (totalEvents > 5)
  signals.push(`${totalEvents} total events`);
if (type === "planning")
  signals.push("sustained activity across many days");

const ageDays = Math.floor(age / (24 * 60 * 60 * 1000));
if (ageDays === 0)       signals.push("last active today");
else if (ageDays === 1)  signals.push("last active yesterday");
else                     signals.push(`last active ${ageDays} days ago`);

const title =
  [...keywordSet].slice(0, 3).map(toTitleCase).join(" ") || "Untitled Thread";

return {
  id: builder.id,
  title,
  sessions,
  type,
  confidence,
  status,
  firstSeen,
  lastSeen,
  distinctDays,
  signals,
};
}

这里内容很多,因此值得按顺序逐一查看 IntentThread 上的每个字段。

firstSeen 和 lastSeen 直接来自边界会话,因为会话按照时间顺序从聚类中传入。distinctDays 重用了 ambient.ts 中相同的日历日合并方式。这次它计算的是该线程事件跨越的不同天数,无论你整体上总共有多少个活跃天数。

类型分类是一个级联过程,顺序很重要。首先检查比较语言(BUYING_WORDS),因为即使线程中包含教程页面,只要你在比较两个框架,该线程就被视为“购买”类型。比较意图是一个更强的信号。

接下来是学习语言。之后,规划类型保留给那些跨越五天以上且至少有三次持续、重复活动的会话,而不是一次深入的活动。

研究类型是所有至少有三个事件但未匹配到更具体分类的默认类型,而未分类则是剩余的,通常是活动太少无法做出任何明确判断的线程。

状态完全取决于 lastSeen 距离现在有多久:48 小时内为活跃,7 天内为停滞,超过 7 天则为休眠。

置信度是四个信号的加权总和,每个信号在加权前都归一化到最大值 1,因此总和也不能超过 1。distinctDays / 5,上限为 1,贡献最多 35%,将五天或更多天视为该轴上的完全置信。sessions.length / 5,上限为 1,贡献最多 25%。totalEvents / 20,上限为 1,贡献最多 20%。类型不是未分类则贡献最后 20% 的全部或无奖励。

一个线程如果在五天以上被重新访问,跨越五次以上会话,有二十次以上事件,并且分类清晰,其置信度得分为 1.0。而一个线程如果只是一次会话,有两个事件且没有分类,则得分接近 0。

signals 是置信度评分和状态的英文审计跟踪:它解释了线程为何呈现当前状态,列出诸如它被重新访问的天数、发现的比较或学习语言、会话和事件数量,以及最后一次活跃时间等信息。仪表板直接展示了这些信息。

最后,title 是一个占位符:从线程累积的 keywordSet 中提取出排名前三的关键词,首字母大写并用空格连接,如果没有关键词则显示 "Untitled Thread"。

这是有意为之的弱处理方式。在本指南的后续部分,AI 标签会用基于线程实际内容的标题替换这个启发式标题,同时也会替换摘要和 nextStep。不过,即使没有这一步,线程也可以正常使用。

综合起来

buildThreads 将本节所有内容整合在一起:

code
export async function buildThreads(): Promise<{ sessions: number; threads: number }> {
  const sessions = await getAllSessions();

  if (sessions.length === 0) {
    await clearThreads();
    return { sessions: 0, threads: 0 };
  }

  const ambient = detectAmbientDomains(sessions);

  const builders = clusterSessions(sessions, ambient);

  const substantive = builders.filter(
    (b) => !(b.sessions.length === 1 && b.sessions[0].events.length < 3)
  );

  const threads = substantive.map(scoreThread);

  await clearThreads();
  await putThreads(threads);

  console.table(
    threads.map((t) => ({

      type:         t.type,
      status:       t.status,
      confidence:   t.confidence,
      distinctDays: t.distinctDays,
      sessions:     t.sessions.length,
      events:       t.sessions.reduce((n, s) => n + s.events.length, 0),
      keywords:     [...new Set(t.sessions.flatMap((s) => s.keywords))].slice(0, 5).join(", "),
    }))
  );

  return { sessions: sessions.length, threads: threads.length };
}

这里的顺序很重要。detectAmbientDomains 在聚类发生之前运行一次,覆盖所有会话,因为环境检测需要完整的浏览信息才能知道什么才算作“每天”。

clusterSessions 接着生成 ThreadBuilder,这些 ThreadBuilder 在评分之前会被过滤:一个只包含一个会话且事件数少于三个的 ThreadBuilder 几乎总是代表一个没有与其他内容合并的孤立页面加载,因此会被丢弃,而不是成为一个置信度接近零的线程。

所有通过筛选的内容都会通过 scoreThread 进行评分,保存,并通过 console.table 打印出来,这就是之前提到的调节辅助工具。如果你在运行此代码后打开服务工作线程的控制台,每个线程都会以可排序的表格形式列出。这是发现 SIMILARITY_THRESHOLD 设置过高或过低的最快方法。

与前两节一样,buildThreads 目前还没有用户界面。当你在本指南的后面部分设计仪表板时,它会与另外两个按钮一起连接到一个“构建意图地图”的按钮上。

目前,请确认 src/pipeline/ambient.ts、更新后的 src/db/index.ts 和 src/pipeline/threads.ts 都能成功构建,没有错误,并且在扩展程序重新加载后,getDB() 报告版本 3。现在,intent_threads 应该与 raw_events 和 sessions 一起出现在 DevTools 中。

到目前为止,整个核心流程已经可以在本地端到端运行,无需任何 API 密钥:你的浏览历史记录会变成原始事件,原始事件会变成会话,而会话会变成评分和分类的意图线程。

从这里开始的所有内容都是可选的,并且是附加的:清理这个流程尚未处理的自指噪声源(你可能想要查看并整合它),然后是 AI 标签、品牌定位,以及将所有内容整合在一起的仪表板。

如何清理自指噪声

多次运行该流程,针对你自己的浏览记录,一种奇特的线程开始出现:这种线程完全由 openloops 本身构成。

仪表盘是一个网页,因此每次你打开它查看线程时,该页面的加载都会被记录为一个事件。如果你还在开发扩展程序,你的本地开发服务器和任何私有网络地址也会被包含在数据中。

该工具最终会监视自己使用自己,这种自我引用以两种不同的方式污染了意图映射,这两种方式值得分开讨论。

两个问题

第一个问题是扩展程序自身的页面。Chrome 扩展程序的仪表盘是从 chrome-extension:// URL 加载的,而 Chrome 自身的内部页面使用 chrome:// 。如果不进行过滤,一个下午内打开 openloops 仪表盘十次,就会产生十次 chrome-extension:// 原始的事件,这些事件会聚集成一个线程,本质上是关于查看你自己的线程。

这种线程是循环且无用的,而且由于你通常在浏览其他内容较少时频繁打开仪表盘,这种自我线程在近期性和会话次数上可能会产生误导性高的得分。

第二个问题是本地开发基础设施。如果你正在构建扩展程序或任何本地项目,你的浏览历史中会充满 localhost:5173、127.0.0.1:8080,以及可能的局域网地址,如 192.168.1.40。在 Chrome 看来,这些都是真实的页面访问,但它们在 openloops 所关心的浏览意图方面没有任何意义。更糟糕的是,它们之后会被发送到 context.dev 进行品牌丰富处理,但它们永远无法解析为任何内容,只会浪费 API 信用额度。

这两个问题有一个共同的根源:流程捕获了那些本就不是你浏览的一部分的 URL。解决方法是定义什么才算作一个真正的、外部网页,并在整个系统中一致地应用这个定义。

一个定义,应用于所有地方

执行这个任务的两个辅助函数 isHttpUrl 和 isLocalHost,是你最初构建 src/lib/util.ts 时编写的。我们特意在早期就引入了它们,就是为了这一刻。

isHttpUrl 仅对 http:// 和 https:// URL 返回 true,这一步就排除了 chrome-extension://、chrome://、about: 和 file://。isLocalHost 对 localhost、环回地址和私有 IP 范围以及 .local 主机名返回 true。

使它们有效的关键在于一致性:相同的两个函数保护了每一个入口点,因此“真实页面”的定义在流程的任何部分都不会发生偏移。共有三个这样的入口点。

实时捕获,在 src/background.ts 中,在记录任何内容之前调用 isHttpUrl:

code
if (!isHttpUrl(url)) return;

回填,在 src/pipeline/backfill.ts 中,对每个历史项应用相同的防护,再获取其访问记录:

code
if (!item.url) return [];
if (!isHttpUrl(item.url)) return [];

噪声过滤器,在 src/pipeline/noise.ts 中,在 isNoise 函数的最开始处检查这两个辅助函数,任何域名或标题规则运行之前:

code
export function isNoise(event: RawEvent): boolean {
  if (!isHttpUrl(event.url)) return true;
  if (isLocalHost(event.domain)) return true;
  return domainIsBlocked(event.domain) || titleIsGeneric(event.title, event.domain);
}

捕获和回填过程已经过滤掉了非网络 URL,因此在 isNoise 中再次检查 isHttpUrl 显得有些多余,正常运行时确实如此。第三次检查是一种保障:如果某个非网络事件通过你未预料到的路径(比如未来的捕获机制、导入的数据或一个错误)进入 raw_events,它仍然无法进入会话。

每个阶段都会保护自己的输入,而不是依赖于前面的阶段已经完成了其工作。正是这种做法防止了单个遗漏的情况静默地传播到意图映射中。

同样也要防御增强边界

同样的 isLocalHost 检查再次出现,在你接下来要构建的品牌增强步骤中,其中域名会被发送到 context.dev。即使 isNoise 在会话化之前已经过滤了本地地址,增强函数在进行任何网络调用之前仍会再次过滤它们:

code
const unique = [...new Set(domains)].filter((d) => !isLocalHost(d));

这种做法背后的理由是同样的纵深防御理念,但应用在了一个错误代价更高的边界上。如果某个本地地址以某种方式进入了线程的域名列表,它不应该只是在 UI 中无用的噪音。它永远不应该作为 API 请求的一部分离开你的机器。将过滤器直接放在网络边界意味着无论上游发生了什么,这种保障都能成立。

加载更新后的构建后,openloops 应该不再出现在自己的意图映射中。为验证这一点,可以多次打开仪表盘,浏览一些真实页面,然后重新构建管道:chrome-extension:// 自线程应该消失,任何线程的域名列表中都不应再出现 localhost 或私有 IP 域名。

如果你在 DevTools 中检查 raw_events,你可能仍然能看到修复前实时捕获的事件,因为回填会清除并重写事件,而实时捕获则会追加。运行一次“扫描我的历史”会彻底清除并按照新规则重新填充 raw_events。

现在管道生成了一个真正外部浏览的干净意图映射,值得让这些线程更加易读。

到目前为止,每个线程的标题只是其前三个关键词拼接而成,而且没有任何摘要或建议的下一步。下一节将添加第一个可选的、需要密钥的层:使用 Claude 进行线程标注。

如何使用 Claude 标注线程

一个标题为 "Typescript Generics Handbook" 的线程是可读的,但它只是对关键词的描述,而不是你试图完成的事情。"Learning TypeScript's advanced type system" 是一个人实际会写的标签,这两者的区别正是本节要填补的空白。

Claude 会读取每个线程的关键词、域名和示例页面标题,然后返回一个真实的标题、一句摘要、一个分类和一个具体的下一步建议。

这是 openloops 中第一个调用外部 API 并需要密钥的部分。其设计的每一个方面都受到一个约束:请求必须能够处理真实数据,其中一个人可能有三十到四十个线程,每个线程都携带着十几个页面标题。

这种功能的原始版本是将所有线程放在一个请求中,并要求返回所有标签。而第一版实现确实就是这样做的。但它失败了,这种失败值得走一遍,因为修复是本节最有指导意义的部分。

本地存储密钥

在任何 API 调用之前,密钥需要有一个存储的地方。openloops 将其保存在 chrome.storage.local 中,它不会同步到任何地方,也不会离开设备。创建 src/lib/settings.ts 文件:

code
export async function getApiKey(): Promise<string | null> {
  const result = await chrome.storage.local.get("anthropicApiKey");
  return (result.anthropicApiKey as string) ?? null;
}

export async function setApiKey(key: string): Promise<void> {
  await chrome.storage.local.set({ anthropicApiKey: key });
}

之后,同一个文件会增加用于 context.dev 密钥、助手的模型和努力偏好的并行获取器和设置器,所有这些都遵循相同的结构。因此,只要理解这一对函数,就能理解所有类似的函数。

第一个版本,以及它为何失败

第一个标签实现将每个线程一次性发送给 Claude:将所有四十个线程序列化为一个 JSON 负载,请求返回四十个标签的 JSON 数组,解析后写回。在早期测试中,当只有五到六个线程时,它运行得非常完美,但当实际处理三十多个线程的历史记录时,它却默默地没有产生任何输出。没有任何错误或抛出的异常,只是线程保留了它们旧的关键词标题,仿佛标签从未运行过。

原因在于输出令牌截断。请求指定了 max_tokens,这是模型可以生成响应的最大输出上限,而四十个线程的标题、摘要和下一步操作是大量输出。当响应在生成过程中达到该上限时,JSON 数组会在一个 [ 开始处被截断,接着是三十个完整的对象,然后是第 31 个对象的一半,但没有闭合的 ]。对这样的 JSON 进行解析会抛出异常,捕获块会记录它并返回空值,而由于标签设计为优雅地失败并保持现有标题不变,因此这个失败在用户界面中是不可见的。

由此产生了两个设计上的更改,这两个更改最终都包含在代码中:将工作拆分为小批次,以确保单个响应不会大到被截断;并使解析足够健壮,以确保一个坏批次不会影响整个运行。

批处理请求

创建 src/pipeline/label.ts 文件,从每个批次的请求函数开始:

code
import { getAllThreads, putThreads, getAllBrands } from "../db/index";
import type { IntentThread } from "../types";

interface ThreadDescriptor {
  id: string;
  keywords: string[];
  domains: string[];
  sampleTitles: string[];
  domainContext: string[];
}

interface LabelResult {
  id: string;

  summary: string;
  type: string;
  nextStep: string;
}

const VALID_TYPES: ReadonlySet<IntentThread["type"]> = new Set([
  "buying",
  "research",
  "learning",
  "planning",
  "unclassified",
]);

const BATCH_SIZE = 10;
const MAX_TOKENS_PER_BATCH = 4000;

async function callClaudeBatch(
  apiKey: string,
  systemPrompt: string,
  batch: ThreadDescriptor[],
): Promise<LabelResult[] | null> {
  const response = await fetch("https://api.anthropic.com/v1/messages", {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "x-api-key": apiKey,
      "anthropic-version": "2023-06-01",
      "anthropic-dangerous-direct-browser-access": "true",
    },
    body: JSON.stringify({
      model: "claude-haiku-4-5-20251001",
      max_tokens: MAX_TOKENS_PER_BATCH,
      system: systemPrompt,
      messages: [
        {
          role: "user",
          content: JSON.stringify(batch),
        },
      ],
    }),
  });

if (!response.ok) { let body = ""; try { body = (await response.text()).slice(0, 400); } catch { } console.error( [openloops] label: API request failed\n + → HTTP (${response.status} ${response.statusText})\n + body: ${body || "(empty)"}, ); if (response.status === 401) { throw new Error("Invalid API key. Check your Anthropic API key and try again."); } throw new Error(API request failed: (${response.status} ${response.statusText}); }

const data = await response.json(); const raw: string = data.content[0].text;

const cleaned = raw .trim() .replace(/^``(?:json)?\s*/, "") .replace(/``\s*$/, "") .trim();

try { return JSON.parse(cleaned); } catch (err) { console.error([openloops] label: parse error: ${err instanceof Error ? err.message : String(err)}); console.error([openloops] label: raw tail (last 400 chars):\n${raw.slice(-400)}); return null; } }

code

将 BATCH_SIZE 设置为 10,MAX_TOKENS_PER_BATCH 设置为 4000,这是直接解决截断问题的方法。10 个线程的标签内容可以轻松地容纳在 4000 个输出 token 中,还有剩余空间,因此一个批次不会达到上限并被截断。40 个线程的历史记录会变成 4 个独立的请求,而不是一个过大的请求。

该请求本身使用的是原始的 fetch 而不是 Anthropic 的 TypeScript SDK,因为 SDK 并未构建为在浏览器或扩展上下文中运行。

从浏览器发起的对 Anthropic API 的调用还需要使用 `anthropic-dangerous-direct-browser-access` 头,这正是启用这种使用模式所必需的。模型是 Claude Haiku,这是系列中最快且最便宜的模型,非常适合这种高吞吐量、结构化输出的任务,当你需要多次调用并希望它们快速完成时,这种模型非常匹配。

错误处理分为两种故意不同的行为。HTTP 层级的失败(例如,由于密钥错误导致的 401,或由于速率限制导致的 429)会抛出错误,因为每个后续批次都会以相同方式失败,继续下去没有意义。相比之下,解析失败会返回 null 而不是抛出错误,这样调用者可以跳过该批次并继续处理其余部分。

在 JSON.parse 之前的“围栏”移除处理了一个常见的现实问题:模型有时会将 JSON 输出包裹在 Markdown 代码围栏(```json)中,即使请求的是原始 JSON。两次 .replace 调用会删除前导和尾随的围栏(如果存在),并容忍周围的空白,因此无论响应是否被包裹,都可以正常通过。

当解析仍然失败时,catch 会记录原始响应的最后 400 个字符,这正是你看到被截断数组的截断特征的地方,这种诊断信息可以让原始错误在几分钟内变得显而易见。

### 构建提示并合并结果

公共的 labelThreads 函数构建描述符,运行批次,并合并返回的结果:

export async function labelThreads(apiKey: string): Promise<{ labeled: number }> { const threads = await getAllThreads(); if (threads.length === 0) return { labeled: 0 };

const allBrands = await getAllBrands(); const brandMap = new Map(allBrands.map((b) => [b.domain, b])); }

ts
  const descriptors: ThreadDescriptor[] = threads.map((t) => {
    const keywords = [...new Set(t.sessions.flatMap((s) => s.keywords))].slice(0, 8);
    const domains  = [...new Set(t.sessions.flatMap((s) => s.domains))].slice(0, 5);
    const titles   = [...new Set(t.sessions.flatMap((s) => s.events.map((e) => e.title)))].slice(0, 20);

    const domainContext = domains
      .map((d) => {
        const brand = brandMap.get(d);
        if (!brand || !brand.name) return null;
        let line = `\({d}: \){brand.name}`;
        if (brand.description) line += ` — ${brand.description}`;
        if (brand.industry)    line += ` (${brand.industry})`;
        return line;
      })
      .filter((s): s is string => s !== null);

    return { id: t.id, keywords, domains, sampleTitles: titles, domainContext };
  });

  const systemPrompt = `你负责标记浏览意图线程。只返回一个 JSON 数组 —— 不要使用 Markdown 栏,不要解释。
每个元素: { "id": "<线程 ID>", "title": "<3-6 个词的标题>", "summary": "<一句话>", "type": "<buying|research|learning|planning|unclassified>", "nextStep": "<一个具体、明确的行动,以推动线程前进或完成闭环>" }
nextStep 必须基于用户实际查看的内容。要具体 —— 指出具体的决定、比较或行动(例如:"决定在 MacBook Pro 和 Dell XPS 之间 —— 你之前的问题是电池寿命"),而不是泛泛的建议("继续研究")。使用 sampleTitles 和 domainContext 来支撑它。
每个线程描述符可能包含一个 "domainContext" 数组,其中包含访问过的网站的公司描述。如果存在,使用这些信息来生成更清晰、更具体的标题、摘要和下一步行动,基于每个公司实际的业务。
请只返回一个数组,涵盖请求中的所有线程。`;

  const allResults: LabelResult[] = [];
  let failedBatches = 0;
  for (let i = 0; i < descriptors.length; i += BATCH_SIZE) {
    const batch = descriptors.slice(i, i + BATCH_SIZE);
    const results = await callClaudeBatch(apiKey, systemPrompt, batch);
    if (results === null) {
      failedBatches++;
      continue;
    }
    allResults.push(...results);
  }

  const byId = new Map(allResults.map((r) => [r.id, r]));

  let labeled = 0;
  const updated = threads.map((t) => {
    const label = byId.get(t.id);
    if (!label) return t;

    const type = VALID_TYPES.has(label.type as IntentThread["type"])
      ? (label.type as IntentThread["type"])
      : t.type;

    labeled++;
    return {
      ...t,

      summary:  label.summary  || undefined,
      nextStep: label.nextStep || undefined,
      type,
    };
  });

  await putThreads(updated);
  return { labeled };
}

每个线程都被压缩为一个 ThreadDescriptor,其中只包含 Claude 标记它所需的必要信息:最多八个关键词、五个域名和二十个示例页面标题。这些信息被限制,以防止拥有数百个事件的线程使负载过大。

domainContext 字段是下一节中介绍的品牌关联步骤的钩子。目前它是空的,因为还没有获取任何品牌信息,这也是为什么标记可以独立进行,并且在添加关联后会更加精确。

合并步骤是失败批次只影响其自身线程的地方。结果以所有成功批次的扁平列表形式返回,并通过线程 ID 索引到 byId 中。

然后,每个线程都会被处理:如果该线程返回了标签,AI 生成的标题、摘要、下一步操作和类型将被合并进来,同时返回的类型会与 VALID_TYPES 进行验证,如果模型返回了意外的结果,则会回退到启发式类型。如果某个线程没有返回标签,是因为该线程的批次解析失败,那么该线程将保持原样返回,保留其原有的关键词标题和启发式分类。

一个失败的批次只会损失你十个线程的打磨工作,而不会影响整个运行过程,也不会用格式错误的数据污染线程。

请注意,titlesummarynextStep 都通过 || t.title|| undefined 来防止出现空字符串。即使模型返回了空白标题,线程始终会有一个可用的标题,而摘要和下一步操作则会保持为 undefined,而不是变成空字符串。这样可以确保仪表板上“这个线程是否有摘要?”的检查保持真实。

标签功能需要一个密钥和一个按钮,这两个元素会在本指南后面的仪表板部分提供,因此完整的端到端测试要等到那时才能进行。

你现在可以验证的是 src/lib/settings.tssrc/pipeline/label.ts 是否能成功编译,并且可以通过使用真实密钥(如果需要即时反馈,可以使用临时测试工具)调用 labelThreads 来确认请求的格式是否正确。当它运行在已构建的线程上时,控制台会显示批次的进度,并且你的线程标题在 IndexedDB 中将从关键词片段变为可读的短语,摘要和下一步操作字段也将首次出现。

标签已经带来了显著的改进,但它们目前仍基于关键词和域名。这意味着围绕 mastra.ailangchain.com 构建的线程,根本不知道这些是 AI 代理框架。它只能看到两个域名字符串。

下一节将弥补这一差距,通过在打标签之前将域名解析为真实的公司描述。这是给 AI 提供具体信息进行推理的关键步骤。

如何使用 context.dev 为标签提供上下文

这是 openloops 中最具特色的想法,因此在涉及任何代码之前,值得明确说明:openloops 不是让模型根据关键词和域名直接对线程进行标签分类,而是首先将每个域名解析为真实的公司描述——公司是做什么的、属于什么行业、实际做了什么——然后将这些描述输入到标签提示中。这样,模型在对线程进行分类时,会知道 mastra.ailangchain.com 都是 AI 代理框架,而不是看到两个需要猜测的不透明字符串。

一个关键词为 "mastra langchain sholajegede" 的线程,在没有上下文的情况下,生成的标题可能是 "Mastra Langchain Sholajegede",这只是一个对关键词的字面重复。但当它知道这些域名是竞争的代理框架时,同样的线程会变成 "Benchmarking Mastra against LangChain",一个明确表达了实际意图的标题。

在浏览过程中,始终有可用于生成优质标签的原始材料。所缺失的是解读这些材料的上下文,而这种上下文正是品牌情报 API 提供的。

API 返回的内容

openloops 使用 context.dev,它将域名解析为结构化的品牌记录:公司名称、一行描述、行业分类、品牌颜色和标志 URL。上下文步骤需要公司名称、描述和行业分类,而品牌颜色和标志则会在稍后由仪表板用于渲染域名芯片。

此步骤是可选的:上一节中的标注功能无需它即可正常工作,而添加 grounding 仅在存在 context.dev 密钥时使输出更加清晰。

与 Anthropic 密钥类似,context.dev 密钥存储在 chrome.storage.local 中,通过 src/lib/settings.ts 中相同的 getter/setter 模式实现:

code
export async function getContextKey(): Promise<string | null> {
  const result = await chrome.storage.local.get("contextDevApiKey");
  return (result.contextDevApiKey as string) ?? null;
}

export async function setContextKey(key: string): Promise<void> {
  await chrome.storage.local.set({ contextDevApiKey: key });
}

品牌记录也需要一个缓存的位置,因为重复解析同一个域名会浪费资源并消耗 API 信用点。将 DB_VERSION 提升到 4,并添加一个以域名为键的 domain_brands 存储:

code
import type { RawEvent, Session, IntentThread, Brand } from "../types";

interface OpenloopsDB extends DBSchema {
  raw_events: { key: string; value: RawEvent; indexes: { by_visitedAt: number } };
  sessions: { key: string; value: Session; indexes: { by_startedAt: number } };
  intent_threads: { key: string; value: IntentThread; indexes: { by_lastSeen: number } };
  domain_brands: {
    key: string;
    value: Brand;
  };
}

const DB_VERSION = 4;

在升级回调中,通过与其它存储相同的保护机制添加新的存储,domain_brands 以域名而不是 id 作为键,因为域名本身就是一个天然的唯一键:

code
if (!db.objectStoreNames.contains("domain_brands")) {
  db.createObjectStore("domain_brands", { keyPath: "domain" });
}

匹配的辅助函数中添加了一个专门用于缓存的函数 getCachedDomains。该函数返回已解析的域名集合,以便丰富步骤可以跳过它们:

code
export async function getBrand(domain: string): Promise<Brand | undefined> {
  const db = await getDB();
  return db.get("domain_brands", domain);
}

export async function putBrands(brands: Brand[]): Promise<void> {
  if (brands.length === 0) return;
  const db = await getDB();
  const tx = db.transaction("domain_brands", "readwrite");
  await Promise.all([...brands.map((b) => tx.store.put(b)), tx.done]);
}

export async function getAllBrands(): Promise<Brand[]> {
  const db = await getDB();
  return db.getAll("domain_brands");
}

export async function getCachedDomains(): Promise<Set<string>> {
  const db = await getDB();
  const keys = await db.getAllKeys("domain_brands");
  return new Set(keys);
}

获取一个品牌

创建 src/pipeline/enrich.ts。核心是一个函数,用于解析单个域名,其大部分代码是为了确保缓慢或失败的查找不会导致整个步骤挂起或崩溃:

code
import { getCachedDomains, putBrands } from "../db/index";
import { isLocalHost } from "../lib/util";
import type { Brand } from "../types";

const API_BASE        = "https://api.context.dev/v1";
const LOGO_LINK_BASE  = "https://logos.context.dev";

const REQUEST_TIMEOUT_MS = 15_000;
const BATCH_SIZE     = 3;
const BATCH_DELAY_MS = 2_000;

interface FetchResult {
  brand: Brand | null;
  errorCode?: string;
}

async function fetchBrand(domain: string, contextKey: string): Promise<FetchResult> {
  const url = `\${API_BASE}/brand/retrieve?domain=\${encodeURIComponent(domain)}`;
  const headers = { Authorization: `Bearer ${contextKey}` };

async function attempt(): Promise<Response> { const ctrl = new AbortController(); const tid = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS); try { return await fetch(url, { headers, signal: ctrl.signal }); } finally { clearTimeout(tid); } }

try { let res = await attempt();

if (res.status === 408) { res = await attempt(); }

if (!res.ok) { let body = ""; try { body = (await res.text()).slice(0, 400); } catch { } console.error([openloops] enrich: HTTP \({res.status} for "\){domain}" — ${body}); return { brand: null, errorCode: String(res.status) }; }

let data: { status?: string; brand?: Record<string, unknown> }; try { data = await res.json(); } catch (e) { return { brand: null, errorCode: "parse" }; }

if (data.status !== "ok" || !data.brand) { return { brand: null, errorCode: "shape" }; }

const b = data.brand as { title?: string; description?: string; colors?: { hex?: string }[]; logos?: { url?: string }[]; industries?: { eic?: { industry?: string; subindustry?: string }[] }; };

const logoUrl = b.logos?.[0]?.url || \({LOGO_LINK_BASE}?domain=\){encodeURIComponent(domain)};

return { brand: { domain, name: b.title ?? domain, description: b.description ?? "", industry: b.industries?.eic?.[0]?.industry ?? "", logoUrl, brandColor: b.colors?.[0]?.hex ?? "", }, }; } catch (err) { if (err instanceof Error && err.name === "AbortError") { return { brand: null, errorCode: "timeout" }; } return { brand: null, errorCode: "network" }; } }

该请求使用 bearer token 进行身份验证,并访问一个单一的 brand/retrieve 端点。attempt 内部函数为每个调用封装在 AbortController 中,设置 15 秒的超时时间,因此如果连接停滞,它会自行中止,而不是无限期地挂起富化步骤。

无论请求成功、失败还是中止,finally 块都会清除定时器。来自 context.dev 的 408 响应表示其端的冷缓存未命中,其文档说明应重试一次,因此在放弃之前进行一次重试即可处理。

响应在每个层级都进行了防御性解包:非 OK 状态返回带有 HTTP 状态码的 FetchResult,无法解析的正文返回“parse”错误,而形状不符合预期的响应返回“shape”错误。

当品牌记录确实到达时,如果某些字段缺失,每个字段都会回退到合理的默认值,公司名称会回退到域名本身,描述和行业会回退为空字符串,如果记录中没有标志 URL,则标志会回退到 context.dev 的无密钥标志 CDN。

每条失败路径都会返回 { brand: null, errorCode } 而不是抛出异常,这使得上面的批量驱动程序可以将单个域名的失败视为跳过而不是崩溃。

批量富化域名

公共的 enrichDomains 函数解析域名列表,跳过已缓存的域名,并尊重 API 的速率限制:

code
export async function enrichDomains(
  contextKey: string,
  domains: string[],
): Promise<{ enriched: number; failed: number; error?: string }> {
  const unique = [...new Set(domains)].filter((d) => !isLocalHost(d));
ts
let cached: Set<string>;
try {
  cached = await getCachedDomains();
} catch (err) {
  return { enriched: 0, failed: 0, error: "DB error" };
}

const toFetch = unique.filter((d) => !cached.has(d));
if (toFetch.length === 0) return { enriched: 0, failed: 0 };

let enriched = 0;
let failed   = 0;
let firstErrorCode: string | undefined;

for (let i = 0; i < toFetch.length; i += BATCH_SIZE) {
  const batch   = toFetch.slice(i, i + BATCH_SIZE);
  const results = await Promise.all(batch.map((d) => fetchBrand(d, contextKey)));

  const brands = results.map((r) => r.brand).filter((b): b is Brand => b !== null);

  for (const r of results) {
    if (!r.brand) {
      failed += 1;
      if (!firstErrorCode) firstErrorCode = r.errorCode;
    }
  }

  if (brands.length > 0) {
    try {
      await putBrands(brands);
      enriched += brands.length;
    } catch (err) {
      failed += brands.length;
    }
  }

  if (i + BATCH_SIZE < toFetch.length) {
    await new Promise<void>((resolve) => setTimeout(resolve, BATCH_DELAY_MS));
  }
}

let error: string | undefined;
if (firstErrorCode) {
  const map: Record<string, string> = {
    "401":     "401 — invalid key",
    "403":     "403 — check key permissions",
    "429":     "429 — rate limited, try again later",
    "timeout": "request timeout (15 s)",
    "network": "unreachable — check network/CORS",
  };
  error = map[firstErrorCode] ?? firstErrorCode;
}

return { enriched, failed, error };
}

该函数首先通过 isLocalHost 过滤掉本地地址,这是在“自我参照噪声”部分讨论的丰富边界保护机制。这意味着即使开发服务器意外地出现在线程的域名列表中,它也永远不会被发送到 context.dev。接着,函数通过 getCachedDomains 移除已缓存的域名,这样重新运行丰富操作时只会获取之前未见过的域名。这样可以确保信用使用量与新的浏览量成比例,而不是与总浏览量成比例。

剩余的域名每次获取三个,并在批次之间暂停两秒钟。这可以确保请求速率远低于 API 的限制,同时不会让用户等待漫长的串行队列。

错误会被统计而不是抛出:如果某个域名无法解析,failed 会增加,并记录其错误代码,但循环会继续执行。遇到的第一个错误代码会在最后映射为一个可读的错误信息,这样 UI 可以显示有用的信息,例如无效密钥或速率限制提示。

整个函数返回统计结果而不是抛出错误,这一点很重要,因为仪表盘会在标记之前立即运行丰富操作,而获取品牌信息时出现的问题不应该阻止后续的标记操作。

如何将 Grounding 反馈到标记中

Grounding 会反馈到上一部分提到的 labelThreads 函数中,该函数已经通过查找品牌缓存中的每个域名,为每个线程构建了一个 domainContext 数组:

ts
const domainContext = domains
  .map((d) => {
    const brand = brandMap.get(d);
    if (!brand || !brand.name) return null;
    let line = `(${d}: ${brand.name}`;
    if (brand.description) line += ` — ${brand.description}`;
    if (brand.industry)    line += ` (${brand.industry})`;
    return line;
  })
  .filter((s): s is string => s !== null);

在丰富处理运行之前,品牌缓存是空的,每次查找都返回空结果,domainContext 是一个空数组,提示信息会退回到仅使用关键词和域名。

在丰富处理之后,相同的代码会产生如 mastra.ai: Mastra — 用于构建 AI 代理的 TypeScript 框架(开发工具)这样的行,标签提示的指令“使用 domainContext 生成更清晰、更具体的标题、摘要和下一步操作”终于有了可以操作的内容。

这两个步骤在设计上是解耦的:标签生成从不需要进行定位,但定位可以显著提升标签生成的效果。这就是为什么仪表板会将它们按顺序作为单一的“丰富,然后标签”操作来运行。

与标签生成步骤一样,丰富处理也是通过仪表板进行的,因此完整的路径需要等待仪表板部分。目前,请确认 src/pipeline/enrich.ts 和更新后的 src/db/index.ts 能够编译,并且 getDB() 报告版本 4,且在 DevTools 中 domain_brands 存在。

一旦它在具有 context.dev 键的真实线程上运行,domain_brands 存储就会填充缓存记录,你的线程标签应该会明显变得更加清晰。最明显的单一演示将是围绕利基或技术领域构建的任何线程,这些领域的名称本身并不能揭示它们的性质。

引擎的每个部分现在都已存在:捕获、会话、聚类、评分、标签和定位。所缺少的是驱动它们并展示结果的界面。

下一节将构建仪表板,这是一个具有入门流程和管道状态机的三列 React 界面,它将这个管道转化为人们实际使用的工具。

如何设计仪表板

仪表板是一个单一的 React 组件树,渲染到你最初设置 manifest 中 options_page 时连接的完整标签页。

它有三个任务:它驱动管道(运行扫描、会话构建、线程构建和标签生成的按钮),它显示生成的意图地图(按状态分组的线程),并托管下一节中介绍的助手。

本节重点介绍结构和真正有趣的逻辑部分:决定在任何时刻哪个管道按钮处于活动状态的状态机。在这里,我们将对样式进行简要概述,因为主要是常规的 CSS。

三列布局

src/dashboard/App.tsx 在一个 flex 容器中布局了三列。左侧轨道包含管道控制、API 密钥输入和状态过滤器。中间列是主要内容:要么是入门欢迎屏幕,要么是线程的意图地图。右侧列包含概述统计信息和助手聊天。

code
┌──────────────┬───────────────────────────┬──────────────────┐
│  LEFT RAIL   │       MAIN COLUMN         │  RIGHT COLUMN    │
│              │                           │                  │
│  Pipeline    │  Welcome screen           │  Overview stats  │
│   · Scan     │    — or —                 │                  │
│   · Sessions │  Intent map:              │  Assistant chat  │
│   · Threads  │   ACTIVE   threads        │   · messages     │
│              │   STALLED  threads        │   · composer     │
│  Keys        │   DORMANT  threads        │   · model/effort │
│  Filter      │                           │                  │
└──────────────┴───────────────────────────┴──────────────────┘

每个线程以卡片形式呈现,显示其标题、类型和状态徽章、AI摘要、带有“继续”按钮的下一步操作行、置信度条,以及一个可折叠的详细信息部分,包含域名、关键词和信号。

卡片按 ACTIVE(活动)、STALLED(停滞)和 DORMANT(休眠)部分进行分组,并在每个组内按置信度排序。最值得采取行动的线程会出现在最紧急组的顶部。

样式定义在 src/dashboard/app.css 中,采用常规设计:通过 CSS 自定义属性定义的深色主题(接近黑色的背景,单个橙色强调色 --accent: #ff5c33,用于文本和边框的灰色渐变),标签和元数据使用等宽字体,内容使用无衬线字体。

对可用性至关重要的设计选择包括基于状态的颜色编码(活动状态使用强调色,停滞状态使用柔和的琥珀色,休眠状态使用灰色),以及置信度条的宽度直接映射到线程的置信度评分。

CSS 本身对于理解构建过程并不关键,因此我们不在此重复它,本节其余部分将重点介绍样式所依赖的逻辑。

管道状态机

管道具有严格的顺序:在扫描历史记录之前不能构建会话,构建线程之前不能构建会话。仪表板将此逻辑编码为一个小型状态机,正确实现这一点使界面显得引导性强而非令人困惑。每个按钮的状态要么是禁用(其输入尚未存在),要么被高亮为下一步操作,要么是完成(可重新运行,但不再是显而易见的下一步)。

ts
type PipelineState = "disabled" | "next" | "done";

function pipelineStates(
  hasScanned: boolean,
  eventCount: number | null,
  sessionCount: number | null,
  threadCount: number | null,
): { scan: PipelineState; sessions: PipelineState; threads: PipelineState } {
  const hasEvents   = (eventCount   ?? 0) > 0;
  const hasSessions = (sessionCount ?? 0) > 0;
  const hasThreads  = (threadCount  ?? 0) > 0;

  if (!hasScanned)  return { scan: "next", sessions: "disabled", threads: "disabled" };
  if (!hasSessions) return { scan: "done", sessions: hasEvents ? "next" : "disabled", threads: "disabled" };
  if (!hasThreads)  return { scan: "done", sessions: "done", threads: "next" };
  return { scan: "done", sessions: "done", threads: "done" };
}

该函数读取每个阶段的数据是否存在,并返回三个按钮的状态。在任何扫描之前,只有“扫描”按钮是可用的,标记为“下一步”,而其他两个按钮被禁用。

一旦存在事件但尚未创建会话,“扫描”按钮变为“完成”,“会话”按钮变为“下一步”。一旦存在会话但尚未创建线程,“线程”按钮变为“下一步”。一旦所有三个阶段都生成了输出,所有按钮都变为“完成”,每一步都可以重新运行,但都不再需要立即关注。状态机按顺序处理管道,每次只点亮一个“下一步”操作,这正是将三个按钮排成一行变成引导性流程的关键。

第一个参数 hasScanned 比简单的计数更微妙。它正是来自最初捕获部分的一个管道组件的体现。

检查不能仅仅是“是否有事件”,因为实时捕获在扩展程序安装后立即开始填充 raw_events。这将始终存在事件,引导流程会直接跳过“扫描”步骤,而用户甚至尚未进行过扫描。

修复方法是每个 RawEventsource 字段,设置为 "backfill""live",这在你最初构建捕获功能时就已经确定。hasScanned 来自一个专门的查询,该查询专门用于检查 "backfill" 事件:

ts
export async function hasBackfillEvents(): Promise<boolean> {
  const db = await getDB();
  let cursor = await db.transaction("raw_events", "readonly").store.openCursor();
  while (cursor) {
    if (cursor.value.source === "backfill") return true;
    cursor = await cursor.continue();
  }
  return false;
}

这个函数会遍历 raw_events,直到找到一个 source === "backfill" 的事件,一旦找到就会立即返回。仅由实时捕获的事件永远不会满足这个条件,因此在用户实际执行一次回填操作之前,“扫描我的历史”按钮会一直亮起,这是正确的引导行为。看似微不足道的决定,即在之前几节中为每个事件标记其来源,使得现在能够做出这种区分成为可能。

从同一台机器驱动欢迎界面

对于一个没有任何线程的新用户,会看到一个居中的欢迎界面,而不是一个空的意图地图。但而不是为这个界面单独编写逻辑,仪表板通过相同的 pipelineStates 输出来驱动它。当前的下一步决定了欢迎界面显示的单一操作按钮:

ts
let welcomeStep: 1 | 2 | 3 = 1;
let welcomeCtaLabel = "Scan my history";
let welcomeCtaClick = handleScan;
if (scanState === "next") {
  welcomeStep = 1;
  welcomeCtaLabel = scanning ? "Scanning…" : "Scan my history";
  welcomeCtaClick = handleScan;
} else if (sessionsState === "next") {
  welcomeStep = 2;
  welcomeCtaLabel = buildingSessions ? "Building…" : "Build sessions";
  welcomeCtaClick = handleBuildSessions;
} else if (threadsState === "next") {
  welcomeStep = 3;
  welcomeCtaLabel = buildingThreads ? "Building…" : "Build your intent map";
  welcomeCtaClick = handleBuildThreads;
}

欢迎界面的单一按钮始终与轨道的下一步操作保持一致,因此用户可以通过点击一个突出的按钮三次,依次完成扫描、构建会话和构建线程。一旦线程存在,欢迎界面就会被意图地图替换。轨道和欢迎界面在下一步操作上永远不会出现分歧,因为两者都从同一个真实来源读取数据。

连接处理函数

处理函数本身非常简单:每个函数执行一个管道阶段,然后刷新组件对数据库的视图。执行接地和标记操作的处理函数尤其值得关注,因为它将前面两节中描述的解耦实践付诸实施:

ts
async function handleEnrichAndLabel() {
  setLabelError(null);
  setEnrichError(null);

  if (contextKey.trim() && contextKeySaved) {
    setEnriching(true);
    try {
      const allDomains = [...new Set(
        threads.flatMap((t) => t.sessions.flatMap((s) => s.domains))
      )];
      const result = await enrichDomains(contextKey.trim(), allDomains);
      if (result.error) setEnrichError(`context.dev: ${result.error}`);
      if (result.enriched > 0) {
        const all = await getAllBrands();
        setBrands(new Map(all.map((b) => [b.domain, b])));
      }
    } catch (err) {
      setEnrichError(`context.dev: ${err instanceof Error ? err.message : "unknown error"}`);
    } finally {
      setEnriching(false);
    }
  }
}
javascript
setLabeling(true);
try {
  await labelThreads(apiKey.trim());
  setThreads(await getAllThreads());
} catch (err) {
  setLabelError(err instanceof Error ? err.message : "Labeling failed.");
} finally {
  setLabeling(false);
}

丰富功能仅在存在 context.dev 键时运行,并且它被封装,以便任何失败(比如网络错误、密钥错误或速率限制)只会设置一个错误信息,而不会停止执行。标记功能则在丰富功能块之后无条件运行,无论丰富功能是否成功、失败或因缺少密钥而完全跳过。

这种结构是将意图映射与基础部分解耦的具体体现:当基础功能正常工作时,它会提升标记效果;而当基础功能无法工作时,标记功能会优雅地降级为基于关键词和域名的上下文。

丰富功能的错误以琥珀色而不是红色显示,因为它是一个警告(标记仍然发生),而不是一个阻止性错误。这是一个小的用户界面提示,与实际问题的严重程度相匹配。

恢复按钮

有一种交互方式将意图映射重新连接到实时浏览。每个线程卡片都有一个“恢复”按钮,可以重新打开你之前访问的页面,因此对线程采取操作只需一次点击,而不是在历史记录中搜索:

javascript
const RESUME_SKIP_DOMAINS = new Set([
  "google.com", "youtube.com", "bing.com", "duckduckgo.com",
  "gmail.com", "mail.google.com",
]);

function resumeThread(thread: IntentThread): void {
  const seen = new Set<string>();
  const urls: string[] = [];

  const sorted = thread.sessions
    .flatMap((s) => s.events)
    .sort((a, b) => b.visitedAt - a.visitedAt);

  for (const ev of sorted) {
    if (RESUME_SKIP_DOMAINS.has(ev.domain)) continue;
    if (seen.has(ev.url)) continue;
    seen.add(ev.url);
    urls.push(ev.url);
    if (urls.length >= 3) break;
  }

  urls.forEach((url, i) => {
    chrome.tabs.create({ url, active: i === 0 });
  });
}

“恢复”功能按时间顺序从新到旧排序线程的事件,跳过搜索引擎和网络邮件(这些是中转站,而不是你希望返回的目的地),通过 URL 去重,并打开最近的三个有意义的页面。第一个页面是当前活动的标签页,其余的在后台打开。这是一个小功能,但它让线程感觉像是一个你可以返回的地方,而不仅仅是你去过的地方的记录。

当仪表板连接好后,整个流程终于可以通过界面端到端使用。重新加载扩展,打开仪表板,你应该会看到欢迎屏幕提示你进行扫描。

点击扫描,构建会话,构建你的意图映射,线程应该会出现,并按状态分组。添加一个 Anthropic 密钥,可选地添加一个 context.dev 密钥,然后点击“标记和丰富”以查看标题和下一步骤更加明确。你现在构建的完整循环将从一个屏幕运行。

剩下的部分是右边的对话层:一个可以同时推理所有线程并回答问题(如“这周我应该关闭什么?”)的 AI 助手。下一节将构建它。

如何构建 AI 助手

标记步骤要求 Claude 一次描述一个线程。助手则要求更难的问题:同时推理所有线程,并回答关于它们的开放式问题,比如这周应该关闭什么、你最长停滞在什么上,或者如何完成特定的线程。

这是一个聊天界面,但是一个受到限制的界面 – 完全基于你自己的线程数据,因此它的回答会通过名称引用真实的线程,而不是提供通用的生产力建议。

整个设计基于一个核心理念:聊天助手的表现取决于它所获得的上下文。因此,这里大部分的工作在于为每条消息构建合适的上下文,而不是聊天机制本身。

对对话进行定位

在任何消息发送给 Claude 之前,助手会生成一个系统提示,描述用户的线程。它根据用户是否点击了特定线程,以两种模式中的一种进行操作。

如果没有选择线程,它会为每个线程生成一个简洁的摘要。如果选择了一个线程,它会详细描述该线程,并简要列出其他线程。

code
function buildGroundingContext(
  threads: IntentThread[],
  brands: Map<string, Brand>,
  selectedThread: IntentThread | null,
): string {
  if (!selectedThread) {
    const digest = threads
      .map((t) => {
        const domains = [...new Set(t.sessions.flatMap((s) => s.domains))].slice(0, 5).join(", ");
        return `- \({t.title} (\){t.status}, \({t.type}): \){t.summary ?? "no summary yet"} | next: \({t.nextStep ?? "none"} | domains: \){domains || "none"}`;
      })
      .join("\n");

    return `\({SYSTEM_INSTRUCTION}\n\nHere is a digest of all the user's open intent threads:\n\){digest || "(no threads yet)"}`;
  }

  const keywords = [...new Set(selectedThread.sessions.flatMap((s) => s.keywords))].slice(0, 10).join(", ");
  const domains = [...new Set(selectedThread.sessions.flatMap((s) => s.domains))].slice(0, 5);

  const domainLines = domains
    .map((d) => {
      const brand = brands.get(d);
      if (brand?.description) return `- \({d}: \){brand.name} — ${brand.description}`;
      return `- ${d}`;
    })
    .join("\n");

  const sampleTitles = [...new Set(selectedThread.sessions.flatMap((s) => s.events.map((e) => e.title)))]
    .slice(0, 20)
    .map((t) => `- ${t}`)
    .join("\n");

  const otherTitles = threads
    .filter((t) => t.id !== selectedThread.id)
    .map((t) => t.title)
    .join(", ");

  return `${SYSTEM_INSTRUCTION}

The user is focused on this thread:

Status: ${selectedThread.status}
Type: ${selectedThread.type}
Summary: ${selectedThread.summary ?? "none"}
Next step: ${selectedThread.nextStep ?? "none"}
Keywords: ${keywords || "none"}

Domains visited:
${domainLines || "(none)"}

Recent page titles:
${sampleTitles || "(none)"}

For context, the user's other open threads are: ${otherTitles || "none"}.`;
}

这两种模式对应人们提出的两种类型的问题。像“我这周应该关闭哪些线程?”这样的问题涉及整个线程集合,因此摘要模式会为 Claude 提供每条线程的一行摘要。这足以在所有线程之间进行比较和优先级排序。

另一方面,像“我该如何完成这个线程?”这样的问题只涉及一个线程,因此聚焦模式会以深度换取广度。它会提供该线程的关键词、其包含的品牌描述的域名,以及最多二十个实际的页面标题,同时仍然列出其他线程,以便 Claude 知道还有哪些线程在进行中。

聚焦模式是品牌定位再次体现的地方。在丰富过程中获取的相同品牌记录会被编织到域名列表中,因此当用户询问某个线程时,Claude 会看到 mastra.ai: Mastra — 一个用于构建 AI 代理的 TypeScript 框架,而不是一个裸露的域名。这是与标签相同的定位原则,现在应用于对话。

前缀两种模式的系统指令将助手固定在其数据上:

code
const SYSTEM_INSTRUCTION =
  `你是在 "openloops" 浏览器扩展中的助手,该扩展将用户的浏览历史重建为 "意图线程" —— 用户开始但尚未关闭的决策、研究或计划。帮助用户理解并采取行动这些未完成的线程。要具体:通过名称引用实际的线程,并建议真实的下一步操作。你仅基于下方提供的线程数据进行定位 —— 如果用户询问的内容不在其中,请直接说明,而不是猜测。`;

最后的指令非常重要:告诉模型在数据中找不到某些内容时要承认,而不是编造一个看似合理的答案,这是在用户询问一个不存在的线程或数据中没有包含的细节时,保持助手可信的关键。

发送消息

发送函数在每次发送消息时都会重新构建定位上下文。助手始终反映线程的当前状态(包括自对话开始以来发生更改的任何线程),并将整个消息历史发送给 Claude:

code
async function send(text: string) {
  const trimmed = text.trim();
  if (!trimmed || sending) return;

  if (!keySaved) {
    setError("在上方添加你的 Anthropic 密钥以进行聊天。");
    return;
  }

  setError(null);
  const nextMessages: Message[] = [...messages, { role: "user", content: trimmed }];
  setMessages(nextMessages);
  setInput("");
  setSending(true);

  try {
    const systemPrompt = buildGroundingContext(threads, brands, selectedThread);
    const maxTokens = EFFORT_OPTIONS.find((e) => e.id === effort)?.maxTokens ?? 1024;

    const response = await fetch("https://api.anthropic.com/v1/messages", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "x-api-key": apiKey,
        "anthropic-version": "2023-06-01",
        "anthropic-dangerous-direct-browser-access": "true",
      },
      body: JSON.stringify({
        model,
        max_tokens: maxTokens,
        system: systemPrompt,
        messages: nextMessages.map((m) => ({ role: m.role, content: m.content })),
      }),
    });

    if (!response.ok) {
      if (response.status === 401) {
        throw new Error("无效的 API 密钥。检查你的 Anthropic API 密钥并重试。");
      }
      throw new Error(`API 请求失败: \${response.status} \${response.statusText}`);
    }

    const data: { content: AnthropicContentBlock[] } = await response.json();
    const reply = data.content
      .filter((b) => b.type === "text" && b.text)
      .map((b) => b.text)
      .join("");

    setMessages((prev) => [...prev, { role: "assistant", content: reply || "(empty response)" }]);
  } catch (err) {
    setError(err instanceof Error ? err.message : "发生了一些错误。");
  } finally {
    setSending(false);
  }
}

机制与标记请求一致,使用相同的端点、相同的浏览器访问头信息,以及相同的 401 错误处理方式,因为两者都从相同的受限环境中调用相同的 API。用户的消息会被追加到运行中的消息数组中,整个数组会被发送,以便模型了解到目前为止的对话内容,而组装后的上下文信息则作为系统提示一同传递。回复内容通过将响应中的文本块拼接起来提取,如果模型未返回任何可用内容,则使用一个备用字符串。

每次发送时重建 buildGroundingContext 而不是在每次对话中只重建一次,这是有意为之的选择:如果用户重新运行管道或在对话过程中对线程进行标记,下一条消息会自动反映更新后的数据,而不会出现对话开始时的过时快照。

模型与努力控制

助手提供了两个选择器:使用哪个模型以及允许多大的深度。这两个选项通过与键相同的设置模式保存到 chrome.storage.local 中:

code
const MODEL_OPTIONS = [
  { id: "claude-haiku-4-5-20251001", label: "Haiku 4.5 — 最快" },
  { id: "claude-sonnet-4-6",          label: "Sonnet 4.6 — 平衡" },
  { id: "claude-opus-4-8",            label: "Opus 4.8 — 功能最强" },
];

const EFFORT_OPTIONS = [
  { id: "low",    label: "低",    maxTokens: 512 },
  { id: "medium", label: "中",   maxTokens: 1024 },
  { id: "high",   label: "高",   maxTokens: 2048 },
];

模型选择器涵盖了速度与功能之间的范围:Haiku 用于快速回答,Opus 用于对复杂的线程集进行更深入的推理。努力选择器映射到 max_tokens,控制模型可以生成的回答长度。这在 Messages API 没有专门的深度控制时,是一个合理的深度代理。希望得到一行回答的用户选择“低”,而希望得到一个经过推理并优先排序的计划的用户则选择“高”。

渲染回复与空白状态

助手将 Claude 的回复渲染为 Markdown,因为模型自然地使用标题和项目符号来格式化优先级列表和逐步建议。如果以纯文本形式渲染,这些内容将只是原始的星号和井号。使用 react-markdown,回复组件对于助手消息基本上是 <ReactMarkdown>{m.content}</ReactMarkdown>,而用户消息则以纯文本形式渲染。配套的样式会针对渲染后的 Markdown 元素进行调整,以匹配仪表板的字体比例。

在任何对话开始之前,面板会显示一个空白状态,包含一行解释和几个可点击的提示芯片,例如:“我这周应该关闭什么?”、“总结我未完成的事项”、“我最长停滞在什么上?”这些提示既展示了助手的功能,也提供了一键式启动方式。

当线程被聚焦时,建议的提示会略有变化,提供“我该如何完成这个?”而不是整个集合的总结,与聚焦的上下文模式相匹配。

一条隐私声明始终显示在编辑器下方,说明聊天会将线程标题和摘要发送给 Anthropic,而其他内容不会离开设备。这是在整个应用中采用的诚实披露原则,放置在用户输入前就能看到的位置。

在助手就位后,openloops 已经具备完整的功能。重新加载,构建你的意图地图,添加你的 Anthropic 密钥,然后尝试建议的提示。询问本周应该关闭哪些内容,助手应能具体命名线程,并说明哪些是容易的胜利,哪些需要真正的决策。点击进入单个线程并询问如何完成它,答案应缩小到该线程的具体内容。

对话反映了你当前真实的线程,除了你在 grounding context 中看到的线程摘要外,其他内容都不会离开你的机器。

构建已经完成。最后一部分将回顾你所构建的内容:它与这一想法的主流尝试相比如何,隐私模型带来了什么,以及你下一步可以如何发展它。

你所构建的内容及下一步方向

你构建了一个完整的系统:浏览历史通过捕获进入系统,经过清理并分割成会话,聚类并评分成意图线程,可选地由 AI 进行标记和 grounding,并通过带有对话助手的仪表板展示出来。每个阶段都在你自己的机器上运行,AI 层是可选的,添加在无需它们即可运行的管道之上。

如果聚类让你想起了 Chrome 旧版的 Journeys 功能,这是一个合理的联系。按主题而不是按时间对历史进行分组是相同的起点。

openloops 进一步推进了这一点:每个线程都带有置信度评分和状态,AI 层添加了标签和具体的下一步操作,助手可以根据需求跨线程进行推理,整个系统是开源且以本地优先的。这意味着你可以确切地阅读和修改它对你的数据所做的操作。

隐私模型带来的影响

隐私在每一步都塑造了构建过程,值得将这些影响汇总在一个地方。整个核心管道,从捕获到评分线程,都在 IndexedDB 中本地运行,没有任何类型的网络调用。你的浏览历史——原始事件、会话、线程——在不需要密钥的系统部分中永远不会离开你的机器。

两个 AI 层是唯一的数据离开设备的路径,而且两者都是可选的,需要你提供自己的 API 密钥才能启用。当它们运行时,它们发送的数据是刻意最小化的:品牌增强只发送裸域名到 context.dev,从不发送 URL 或页面内容,并且首先剥离了任何本地地址。标记和助手发送线程标题、摘要、关键词和示例页面标题到 Anthropic,以及你可以直接在代码中阅读的 grounding context,不再有其他内容。密钥本身存储在 chrome.storage.local 中,它从不进行同步。

下一步的方向

构建过程留下了一些有意识的简化,这些简化可以作为很好的练习。

最令人满意的练习之一是直接基于你已经编写的代码进行构建。域名方面有 ambient.ts,它会删除在你大多数活跃日中出现的域名。但关键词方面没有对应的处理,因此对于你来说非常常见的一个词(比如 typescript,如果你是 TypeScript 开发者),会在每个会话的关键词中存活下来,并可能将不相关的线程聚在一起。

修复方法是一个基于频率的关键词检测器,几乎与 detectAmbientDomains 行对行地镜像,只是计算的是每个关键词的天数,而不是每个域名的天数:

code
export function detectAmbientKeywords(sessions: Session[]): Set<string> {
  const allEvents = sessions.flatMap((s) => s.events);
  const activeDays = new Set(allEvents.map((e) => new Date(e.visitedAt).toDateString()));
  const totalActiveDays = activeDays.size;
  if (totalActiveDays < MIN_ACTIVE_DAYS) return new Set();

  const keywordDayMap = new Map<string, Set<string>>();
  for (const session of sessions) {
    const day = new Date(session.startedAt).toDateString();
    for (const kw of session.keywords) {
      if (!keywordDayMap.has(kw)) keywordDayMap.set(kw, new Set());
      keywordDayMap.get(kw)!.add(day);
    }
  }

  const ambient = new Set<string>();
  for (const [kw, days] of keywordDayMap) {
    if (days.size / totalActiveDays >= UBIQUITY_THRESHOLD) ambient.add(kw);
  }
  return ambient;
}

然后,你可以在相似性计算中像今天剥离环境域一样剥离这些关键词,将它们从 sessionKeywords 和线程的 keywordSet 中过滤掉,然后再进行 Jaccard 计算。

另外两个较小的练习补充了这些内容。会话间隔、相似性阈值和环境普及阈值都是硬编码的常量。将它们提升到一个由 chrome.storage.local 支持的设置面板中(与 API 密钥使用的存储相同),可以让你根据自己的浏览习惯调整聚类。

此外,extractDomain 仅剥离了前导的 www.,因此 news.bbc.co.uk 和 bbc.co.uk 被视为不同的域。将其主机名逻辑替换为使用公共后缀列表(浏览器用来确定可注册域实际结束位置的标准域后缀列表)的库,可以正确地将同一网站的子域合并。

由于整个流程是本地的且可检查的,你可以轻松地将这些方法应用到自己的真实数据上,并立即看到效果。

总结

openloops 将你浏览器中保持的扁平、按时间顺序的记录转化为你实际尝试做的事情的地图,并帮助你关闭你留下的循环。

其背后的工程实现——时间间隔分割、带有环境域修正的加权 Jaccard 聚类、启发式评分、基于真实公司数据的 AI 标签,以及结果上的对话层——是一种分层系统,其中每个阶段本身都很简单,而价值来自于它们如何组合在一起。

资源

源代码

  • 完整的源代码可在 GitHub 上获得,使用的是 MIT 许可证,因此你可以运行它、阅读它,并根据自己的浏览方式对其进行重塑。如果它对你有帮助,考虑给它加一颗星。

核心文档

  • Chrome 扩展:Manifest V3:openloops 构建的扩展平台
  • chrome.history API:回填依赖的搜索和 getVisits 方法
  • chrome.tabs API:用于实时捕获的 onUpdated 和用于恢复的 create
  • chrome.storage API:API 密钥和偏好设置存储的本地位置
  • Anthropic API 参考:用于标签和助手的 Messages 端点

使用的服务

  • Anthropic 控制台:创建用于 AI 标签和助手的 API 密钥
  • context.dev 文档:用于定位的商标情报 API
  • IndexedDB(MDN):每个流程阶段读取和写入的本地数据库

构建工具

  • Vite:构建工具和开发服务器
  • CRXJS Vite 插件:编译带有热重载的 Manifest V3 扩展
  • idb:类型化、基于 Promise 的 IndexedDB 封装器
  • react-markdown:渲染助手的 Markdown 回复

调试工具

  • Chrome 扩展服务工作者 DevTools:检查实时捕获日志和 pipeline console.table 输出
  • Chrome DevTools 中的 Application → IndexedDB 面板:直接浏览 raw_events、sessions、intent_threads 和 domain_brands,以验证每个阶段

进一步阅读

  • Jaccard 系数:线程聚类背后的基础集合相似性度量
  • 公共后缀列表:提取可注册域名的正确方法,作为未来改进的参考

如果本教程对你有帮助,不妨与可能从中受益的其他人分享。我非常欢迎你的反馈,你可以在 X 上提到我 @wani_shola 或者通过 LinkedIn 与我联系。

我喜欢构建和撰写开发者真正想使用的工具。作为一名开发者关系工程师和倡导者,我工作在代码与社区的交汇点,这里是优秀产品与使用它们的人相遇的地方。我撰写过被数千名开发者阅读的文章,为全球使用的开源工具做出过贡献,并与 freeCodeCamp、Forem(dev.to)、Convex 和 Kinde 等社区进行过合作。我非常重视开发者的体验,我相信最好的工具是那些能将复杂问题变得简单、有趣且极其有用的工具。

如果你读到这里,请感谢作者以表达你对他们的关心。说声谢谢

免费学习编程。freeCodeCamp 的开源课程帮助了超过 40,000 人成为开发者。立即开始

ADVERTISEMENT