Skip to content

Latest commit

 

History

History
573 lines (454 loc) · 22.7 KB

File metadata and controls

573 lines (454 loc) · 22.7 KB

ChatGPT 对话树浏览器扩展 — 开发规范

这是一份交给 Claude Code 的实现规范。架构和 UX 决策已经经过讨论定稿,请按照本文档实现。任何偏离决策的地方需要先在 Claude Code 中说明理由再改。


0. 项目背景

ChatGPT 的对话天然是一棵树:每次"编辑消息"或"重新生成回答"都会在底层创建一个新的兄弟分支。但 ChatGPT 的官方 UI 只用 < 1/2 > 这种小角标暴露分支的存在,用户在分叉一多以后就完全失去对整棵树的把握,找不到自己以前走过的某个分支里的某条消息

这个扩展要做的事情:在 ChatGPT 网页端注入一个浮动面板,把整棵对话树以纯节点 + 连线的形式可视化出来,让用户能一眼看到树的几何结构,并且能跳转到任意节点。

设计原则:结构第一,文字第二。平时面板上一个字都没有(除了左侧轮次编号),只露出节点和连线。消息内容只在 hover 或 click 时通过浮动卡片展示。这是和市面上 KnowTree、BranchGPT 等扩展的核心差异——它们都把消息预览塞进了节点本体,导致面板拥挤。

1. MVP 范围

1.1 In scope

  • ChatGPT 网页端(chatgpt.comchat.openai.com
  • Manifest V3 Chrome 扩展(同时兼容 Edge;Firefox 可选)
  • 浮动面板,默认折叠为右下角小图标,点击展开为右侧 sidebar
  • 整棵对话树的纯节点可视化
  • 用户消息和助手消息用颜色区分
  • 当前所在位置高亮(当前所在位置外面套一个浅环)
  • 当前 active path 高亮
  • Hover 节点显示消息预览卡片
  • Click 节点跳转到 ChatGPT 中对应的消息
  • 长线性段可手动折叠
  • 基础全文搜索(命中节点高亮)
  • 左侧轮次编号

1.2 Out of scope(V2 或收费版)

  • Claude.ai 支持(需要不同的 adapter 策略,本文档不涉及)
  • 分支命名/标签(收费)
  • 高级搜索:fuzzy、命中上下文片段(收费)
  • 多面板并排对比(收费)

2. 总体架构

三层结构,未来加新的 LLM 产品(Claude 等)只需要新加一个 adapter,不需要动核心逻辑。

content-script.js  (注入到 ChatGPT 页面)
    │
    ├── adapter (chatgpt.js)        — 拉数据 / 写数据
    │
    ├── core (tree, layout, search) — 与平台无关的逻辑
    │
    └── ui (panel, render, card)    — 渲染层

2.1 Adapter 层(数据访问)

src/adapters/base.js 定义统一接口:

interface ChatAdapter {
  getConversationId(): string | null;
  fetchTree(conversationId: string): Promise<RawTree>;
  parseTree(raw: RawTree): Tree;
  switchToNode(conversationId: string, nodeId: string): Promise<void>;
  observeChanges(callback: () => void): () => void; // 返回 unsubscribe
}

src/adapters/chatgpt.js 是这个接口的具体实现,详见 §4。

2.2 Core 层(数据结构与算法)

平台无关。处理 tree 数据结构、布局计算、搜索索引、本地缓存。

2.3 UI 层

浮动面板、SVG 渲染、hover 卡片、搜索框。所有 UI 都从 core 层拿计算好的数据,不直接调 adapter。


3. 数据结构

3.1 内部 TreeNode(adapter 解析后的统一格式)

type TreeNode = {
  id: string;                  // ChatGPT 的 message ID
  parentId: string | null;     // 顶层节点为 null
  childrenIds: string[];       // 顺序非常重要,决定从左到右的渲染顺序,禁止排序
  role: 'user' | 'assistant';  // system 节点在 parse 阶段就过滤掉
  content: string;             // 完整消息文本
  preview: string;             // 前 ~120 字符,用于 hover 卡片
  createTime: number;          // unix 秒
  hidden: boolean;             // tool calls 等不可见节点设为 true
};

type Tree = {
  nodesById: Map<string, TreeNode>;
  rootIds: string[];           // 所有 parentId === null 的 user 节点(注意可能有多个,见 §7.1)
  currentNodeId: string;       // 来自 ChatGPT 的 current_node
};

3.2 布局计算后的 LayoutNode

type LayoutNode = {
  id: string;
  x: number;          // SVG 坐标
  y: number;
  depth: number;      // 从根算起的深度(用于轮次编号)
  isOnActivePath: boolean;
};

4. 数据获取(ChatGPT adapter)

4.1 获取 conversation ID

URL pattern: https://chatgpt.com/c/{conversationId},从 location.pathname 用正则提取。

如果用户在 / 主页(还没创建对话),getConversationId() 返回 null,面板显示"还没有对话"占位。

4.2 拿 bearer token

ChatGPT 把 access token 放在 /api/auth/session 的响应里:

async function getBearerToken() {
  const res = await fetch('/api/auth/session', { credentials: 'include' });
  const data = await res.json();
  return data.accessToken; // 形如 "eyJ..."
}

token 缓存 5 分钟,过期重新拉。

4.3 拉取对话树

async function fetchTree(conversationId) {
  const token = await getBearerToken();
  const res = await fetch(`/backend-api/conversation/${conversationId}`, {
    credentials: 'include',
    headers: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
  });
  if (!res.ok) throw new Error(`fetchTree failed: ${res.status}`);
  return await res.json();
}

返回的数据形如:

{
  "title": "...",
  "current_node": "abc-123",
  "mapping": {
    "abc-123": {
      "id": "abc-123",
      "message": { "author": { "role": "user" }, "content": { "parts": ["..."] }, ... },
      "parent": "def-456",
      "children": ["ghi-789", "jkl-012"]
    },
    ...
  }
}

关键事实:mapping 包含整棵树的所有节点,不是只有 active path。 children 数组是数组而不是单值,正是为了承载兄弟节点。这一点已经验证过,不要怀疑。

4.4 切换 active node

async function switchToNode(conversationId, nodeId) {
  const token = await getBearerToken();
  await fetch(`/backend-api/conversation/${conversationId}`, {
    method: 'PATCH',
    credentials: 'include',
    headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ current_node: nodeId }),
  });
  // PATCH 成功后简单 reload 页面让 ChatGPT 自己 re-render
  // V2 可以尝试不刷新页面,直接触发 ChatGPT 自己的 re-render,但 MVP 用 reload
  location.reload();
}

降级方案:如果某天 PATCH 接口的鉴权变了导致 401/403,fallback 到 DOM 模拟点击——找到对应消息上的 < n/m > 按钮,连续点击直到切换到目标节点。这个 fallback 的实现细节可以放在 V1.1,但 adapter 接口要为它留出位置。

4.5 解析为 TreeNode

function parseTree(raw) {
  const nodesById = new Map();
  const allChildren = new Set();

  for (const [id, node] of Object.entries(raw.mapping)) {
    const msg = node.message;
    // 跳过没有 message 的节点(虚拟根)
    if (!msg) continue;
    const role = msg.author?.role;
    // 只保留 user 和 assistant
    if (role !== 'user' && role !== 'assistant') continue;
    // 跳过隐藏节点(tool calls 等)
    const hidden = msg.metadata?.is_visually_hidden_from_conversation === true;
    if (hidden) continue;

    const content = (msg.content?.parts || []).join('\n');
    nodesById.set(id, {
      id,
      parentId: null, // 后面填
      childrenIds: [],
      role,
      content,
      preview: content.slice(0, 120),
      createTime: msg.create_time || 0,
      hidden: false,
    });
  }

  // 第二遍:建立 parent / children 关系
  // 注意:原始 mapping 里某个 user 节点的 parent 可能是 system 节点,跳过 system 后要"穿透"找到最近的 user/assistant 祖先
  for (const [id, node] of Object.entries(raw.mapping)) {
    if (!nodesById.has(id)) continue;
    const myNode = nodesById.get(id);
    // 找最近的可见祖先
    let p = node.parent;
    while (p && !nodesById.has(p)) {
      p = raw.mapping[p]?.parent;
    }
    myNode.parentId = p || null;
    if (p) {
      // 严格按 raw children 数组顺序加入(不要排序!)
      const parentChildren = raw.mapping[p].children || [];
      // 临时存一下,下一步统一按 raw 顺序填 childrenIds
    }
  }

  // 第三遍:按 raw children 顺序构建 childrenIds(保证从左到右顺序 = 1/n → n/n)
  for (const [id, node] of Object.entries(raw.mapping)) {
    if (!nodesById.has(id)) continue;
    const myNode = nodesById.get(id);
    const orderedChildren = [];
    for (const cid of (node.children || [])) {
      // 找最近的可见后代(如果 cid 自身是隐藏的,要穿透下去)
      const visible = findFirstVisibleDescendant(cid, raw.mapping, nodesById);
      for (const v of visible) orderedChildren.push(v);
    }
    myNode.childrenIds = orderedChildren;
  }

  // rootIds = 所有 parentId === null 的节点
  const rootIds = [];
  for (const [id, n] of nodesById) {
    if (n.parentId === null) rootIds.push(id);
  }

  return { nodesById, rootIds, currentNodeId: raw.current_node };
}

关于 children 顺序的硬约束:raw mapping 中 children 数组的顺序就是 ChatGPT UI 上 1/n, 2/n, ..., n/n 的顺序。任何环节都不允许 sort。如果用 D3 tree layout,确认它默认按 children 数组顺序展开(它会的,但要小心不要传 sort comparator)。


5. 树布局算法

5.1 算法选择

Reingold-Tilford 算法(即 d3-hierarchyd3.tree())。为什么:

  • 父节点的 x 坐标会被居中到所有子节点的 x 范围中央 → 整棵树左右对称
  • 不同子树之间不会重叠
  • 算出来的布局只取决于树的拓扑,不取决于 active path → 切换 active 不需要重排

5.2 多 root 处理(重要边缘情况)

如果 rootIds.length > 1(用户在第一条消息上反复 edit & resubmit 会出现这种情况),创建一个虚拟 anchor 节点作为所有 root 的共同父:

const VIRTUAL_ROOT_ID = '__virtual_root__';
function buildLayoutInput(tree) {
  if (tree.rootIds.length === 1) {
    return { id: tree.rootIds[0], children: ... };
  }
  // 多 root 情况:插入虚拟根
  return {
    id: VIRTUAL_ROOT_ID,
    children: tree.rootIds.map(buildSubtree),
  };
}

虚拟根在渲染时画成一个空心小菱形(区别于正常节点的实心圆),位置在树顶部中央,连线指向所有真正的 root user 节点。它不参与轮次编号(即所有 root user 节点的轮次都是 1)。

5.3 节点尺寸与间距

  • 普通节点直径:10px(半径 5px)
  • 当前位置外环:半径 8px,stroke 1.5px
  • 节点之间垂直间距:36px(深度 +1 → y +36)
  • 节点之间水平间距:64px(兄弟节点最小水平距离)
  • D3 tree 调用:d3.tree().nodeSize([64, 36])

5.4 深树滚动

  • 面板内容区域 overflow-y: auto
  • 计算完布局后,把当前节点滚动到视口中央:scrollIntoView({ block: 'center', behavior: 'smooth' })
  • 横向:如果某一层兄弟节点超过面板宽度,允许 overflow-x: auto
  • 不做虚拟化,DOM 节点几百个也能扛

6. 渲染

6.1 面板形态

  • 默认状态:右下角一个 36×36 圆形按钮(图标:树的简笔画)
  • 点击 → 展开为右侧 sidebar,宽度 360px,可拖动调整 240–600px
  • sidebar 顶部:搜索框(左)+ 关闭按钮(右)
  • sidebar 主体:SVG 容器(可滚动)
  • sidebar 底部:状态条 15 nodes · 3 branches

6.2 节点颜色(区分 user / assistant)

节点 状态 颜色
用户消息 active #185FA5(蓝 600)
用户消息 inactive #185FA5 透明度 35%
助手消息 active #0F6E56(青 600)
助手消息 inactive #0F6E56 透明度 35%
当前位置外环 跟随节点本体颜色,stroke 1.5px
Active 连线 #444 (light) / #bbb (dark),2px
Inactive 连线 同上但 35% 透明度,1.5px
虚拟根菱形 空心,stroke var(--text-tertiary),1px

实现时用 CSS 变量 + media query 适配 ChatGPT 的 light/dark 主题。

6.3 节点形状

  • 所有用户和助手节点都是实心圆
  • 不区分分叉点和普通节点(分叉本身已经通过连线表达)
  • 当前位置在节点外面套一个浅色环
  • 虚拟根用空心菱形

6.4 连线

  • 父→子之间用 cubic Bezier 曲线
  • 控制点放在中间 y:M px,py C px,(py+cy)/2 cx,(py+cy)/2 cx,cy
  • 同 x(直线下降)的情况画成直线
  • Active path 上的连线 2px,颜色更深;非 active 1.5px,淡灰

6.5 轮次编号(左侧数字)

  • 每个 user 节点的左侧(距离 24px)画一个小数字
  • 数字 = 该节点的深度对应的轮次,定义如下:
    • 从 root 开始,每经过一个 user 节点深度 +1
    • 因为 user 和 assistant 严格交替,所以同一个 y 坐标上的所有 user 节点轮次相同
    • 这意味着左侧数字会自然排成一列
  • 字号 11px,颜色 var(--text-tertiary),font-variant-numeric: tabular-nums
  • 助手节点旁边不显示数字
  • 虚拟根不参与编号

6.6 长直线段折叠

定义:"线性段" = 从一个分叉点(或起点)到下一个分叉点(或叶子)之间,所有节点都只有一个子节点的那一段连续节点。

默认:全部展开。 触发折叠:用户点击该线性段中间的连线段(不是节点)→ 整段折叠。 折叠后渲染:用一个胶囊形状 [ +N ] 替代被折叠的节点,N = 该段中折叠掉的节点数(不包括两端的分叉点 / 叶子)。 取消折叠:点击胶囊。

折叠状态保存在内存中,不持久化(V2 可考虑存到 chrome.storage)。


7. 边缘情况清单

7.1 多 root user message(已在 §5.2 处理)

ChatGPT 允许用户对第一条消息反复 edit & resubmit,会产生多个没有共同 user 父节点的 root user message。处理方式见 §5.2 的虚拟根。

7.2 隐藏节点(tool calls / 思考链 / function call)

parse 阶段直接过滤掉 is_visually_hidden_from_conversation === true 或 role 不是 user/assistant 的节点。childrenIds 在构建时要"穿透"隐藏节点,把可见后代直接挂到可见祖先下。

7.3 删除的 / 损坏的节点

某些节点的 message 字段可能为 null(被用户删除或服务端异常)。parse 阶段直接跳过,但要记录 warning 到 console,方便诊断。

7.4 空对话

mapping 只有一个 system 节点。过滤后 nodesById 为空。面板显示 "对话还没开始" 占位。

7.5 用户在 ChatGPT 上的操作引起树变化

用户在 ChatGPT UI 中编辑、重新生成、发送新消息时,扩展需要感知并刷新树。监听方式:

  • 拦截 fetch 调用,监听对 /backend-api/conversation 的 POST/PATCH 请求完成事件
  • 或者监听 ChatGPT 主聊天区的 DOM mutation
  • 或者轮询 current_node(不推荐)

V1 用 fetch 拦截,最干净。

7.6 PATCH 接口失败

如 §4.4 所述,准备 fallback 到 DOM 模拟点击。MVP 阶段先让 PATCH 跑,失败时弹一个 toast 让用户手动刷新。

7.7 token 失效

401 错误 → 清缓存重新拉 token → 重试一次 → 还失败就提示用户重新登录 ChatGPT。


8. 交互细节

8.1 Hover 节点

  • 鼠标进入节点 → 200ms 防抖 → 浮出预览卡片
  • 卡片位置:跟随鼠标,但不遮挡节点本身
  • 卡片内容:
    ┌──────────────────────────────────┐
    │ You · branch 2 of 3              │
    │                                  │
    │ Wait, can you also explain why   │
    │ the active path matters when...  │
    │                                  │
    │ click to jump · ⇧ click to pin   │
    └──────────────────────────────────┘
    
  • 卡片宽度 240px,预览 2 行截断,超出部分 text-overflow: ellipsis
  • 离开节点 100ms 后卡片消失(除非已 pinned)

8.2 Click 节点

  • 触发 adapter.switchToNode(currentConversationId, nodeId)
  • 显示 loading spinner 在节点上
  • PATCH 完成后 reload 页面(MVP)

8.3 Shift + Click 节点

  • 把 hover 卡片"钉"在节点旁边,不再随鼠标移动
  • 再次 shift+click 同一节点 → 取消 pin
  • 点击其他地方 → 取消 pin

8.4 搜索(基础版,免费)

  • 顶部搜索框,输入时实时过滤
  • 实现:对所有节点的 content 字段做 toLowerCase().includes(query)
  • 命中节点:用 CSS 动画做脉冲高亮(@keyframes pulse,2 秒一次)
  • 搜索框右侧显示 3 / 12 计数(命中 / 总数)
  • 上下方向键在命中节点之间跳转,每跳一次把对应节点 scroll into view
  • ESC 清空搜索

实现成本估算:~50 行 JS + 10 行 CSS。一个晚上能搞完。

8.5 折叠 / 展开线性段

见 §6.6。


9. 文件结构

chatgpt-tree-extension/
├── manifest.json
├── icons/
│   ├── 16.png
│   ├── 48.png
│   └── 128.png
└── src/
    ├── content-script.js          # 入口,注入面板
    ├── adapters/
    │   ├── base.js                # ChatAdapter 接口定义
    │   └── chatgpt.js             # ChatGPT 实现
    ├── core/
    │   ├── tree.js                # TreeNode / Tree 数据结构 + parse helpers
    │   ├── layout.js              # d3-hierarchy 包装,输出 LayoutNode[]
    │   ├── search.js              # 基础 substring search
    │   └── storage.js             # chrome.storage 包装(V1 几乎不用)
    ├── ui/
    │   ├── panel.js               # sidebar 面板生命周期
    │   ├── tree-render.js         # SVG 渲染(节点、连线、轮次编号)
    │   ├── preview-card.js        # hover 卡片
    │   ├── search-box.js
    │   └── styles.css
    └── lib/
        └── d3-hierarchy.min.js    # 单独抽出 d3 的 hierarchy 模块,避免引入整个 d3

依赖:只用 d3-hierarchy 这一个 npm 包(约 12KB),不要引入完整的 d3。其他全部原生 JS + DOM API,不用 React 不用框架,content script 越轻越好。

manifest.json 关键字段:

{
  "manifest_version": 3,
  "name": "ChatGPT Conversation Tree",
  "version": "0.1.0",
  "permissions": ["storage"],
  "host_permissions": [
    "https://chatgpt.com/*",
    "https://chat.openai.com/*"
  ],
  "content_scripts": [{
    "matches": ["https://chatgpt.com/*", "https://chat.openai.com/*"],
    "js": ["src/content-script.js"],
    "css": ["src/ui/styles.css"],
    "run_at": "document_idle"
  }],
  "icons": { "16": "icons/16.png", "48": "icons/48.png", "128": "icons/128.png" }
}

10. 实现阶段(强烈建议按顺序实现,不要并行)

Phase 1 — 数据通路(半天)

  • 写 ChatGPT adapter 的 getConversationId() / getBearerToken() / fetchTree() / parseTree()
  • 在 console 里能正确打出某个 conversation 的 TreeNode 列表
  • 验证 switchToNode() 的 PATCH 能正常切换分支

验收标准:在浏览器 console 里调 window.__ext.adapter.fetchTree(id).then(parseTree).then(console.log) 能看到完整树结构,包含所有分支。

Phase 2 — 静态渲染(一天)

  • 注入浮动按钮 + sidebar 面板
  • 用 d3-hierarchy 算出布局
  • SVG 渲染节点、连线、当前位置外环
  • 区分 user / assistant 颜色
  • 区分 active path / inactive
  • 渲染左侧轮次编号
  • 处理多 root 虚拟节点

验收标准:打开任意已有 conversation,面板里能看到完整树结构,颜色和当前位置正确。

Phase 3 — 交互(半天)

  • Hover 200ms → 预览卡片
  • Click → 跳转
  • Shift+Click → pin

验收标准:能 hover 任意节点看到预览,click 能正确跳转到 ChatGPT 中那条消息。

Phase 4 — 搜索(半天)

  • 顶部搜索框
  • Substring 实时过滤
  • 命中高亮 + 上下方向键导航

验收标准:在 50+ 节点的对话里搜一个关键词,命中节点正确高亮,方向键能跳转。

Phase 5 — 折叠 + 边缘情况(半天)

  • 长线性段点击折叠 / 展开
  • 隐藏节点穿透
  • 空对话占位
  • 监听 ChatGPT UI 中的操作并自动刷新树

验收标准:跑通 §7 所有边缘情况清单,没有 console error。

Phase 6 — 打磨

  • sidebar 宽度可拖动调整
  • light / dark 主题适配
  • 加载/错误状态
  • 简单的 README 和截图

整个 MVP 大约 3–4 天工作量。


11. 给 Claude Code 的工作提示

  1. adapter 写得宽容一点。ChatGPT 的字段名可能会变,对每个字段访问用可选链 ?.,缺失时给合理 fallback,并用 console.warn 记录,不要直接抛错让整个面板挂掉。

  2. 不要引入 React / Vue / 任何框架。content script 越轻越好,原生 DOM API 完全够用。整个 UI 大约 500 行 JS 能搞定。

  3. 不要用整个 d3。只引 d3-hierarchy 这个子包(约 12KB)。

  4. 不要尝试做"实时双向同步"。Phase 1 的 switchToNode 先用最简单的 location.reload() 实现,不要去搞 ChatGPT 内部状态的 hot swap。

  5. CSS 全部用作用域前缀避免污染 ChatGPT 自己的样式。建议所有 class 加前缀 .cgt-(chatgpt-tree)。

  6. 如果遇到本规范没说清楚的地方,需要提出来跟我讨论,不要发挥。后面会有 review。


12. 已知的 V2 改进方向(不要现在做)

仅作为路标,避免实现 MVP 时把架构封死:

  • Claude.ai adapter:Claude 后端可能不返回完整树,需要"渐进式发现"策略——从当前 active path 开始画,监听用户在 Claude UI 上点 < 1/2 > 按钮的行为,逐步把发现的兄弟分支增量写进本地树缓存。所以 adapter 接口要预留 mergePartialTree(partial) 这种增量更新的方法。
  • 本地持久化树快照:用 chrome.storage.local 缓存每个 conversation 的树,关闭再打开瞬间显示而不用等 fetch。
  • 分支命名:每个节点可以打 label,存在 chrome.storage.local 中,按 conversationId 索引。
  • Fuzzy 搜索:用 fuse.js,命中时高亮匹配片段。
  • 跨对话搜索:需要后台索引所有对话,比较复杂。
  • 导出树为 markdown:把整棵树(或某条 path)导出为 markdown 文件。
  • 不刷新页面切换分支:研究 ChatGPT 内部状态,PATCH 后直接触发它自己的 re-render,不 reload。

文档结束。如果实现过程中发现规范本身有问题或矛盾,请先停下来在 Claude Code 中提出,确认后再改