这是一份交给 Claude Code 的实现规范。架构和 UX 决策已经经过讨论定稿,请按照本文档实现。任何偏离决策的地方需要先在 Claude Code 中说明理由再改。
ChatGPT 的对话天然是一棵树:每次"编辑消息"或"重新生成回答"都会在底层创建一个新的兄弟分支。但 ChatGPT 的官方 UI 只用 < 1/2 > 这种小角标暴露分支的存在,用户在分叉一多以后就完全失去对整棵树的把握,找不到自己以前走过的某个分支里的某条消息。
这个扩展要做的事情:在 ChatGPT 网页端注入一个浮动面板,把整棵对话树以纯节点 + 连线的形式可视化出来,让用户能一眼看到树的几何结构,并且能跳转到任意节点。
设计原则:结构第一,文字第二。平时面板上一个字都没有(除了左侧轮次编号),只露出节点和连线。消息内容只在 hover 或 click 时通过浮动卡片展示。这是和市面上 KnowTree、BranchGPT 等扩展的核心差异——它们都把消息预览塞进了节点本体,导致面板拥挤。
- ChatGPT 网页端(
chatgpt.com、chat.openai.com) - Manifest V3 Chrome 扩展(同时兼容 Edge;Firefox 可选)
- 浮动面板,默认折叠为右下角小图标,点击展开为右侧 sidebar
- 整棵对话树的纯节点可视化
- 用户消息和助手消息用颜色区分
- 当前所在位置高亮(当前所在位置外面套一个浅环)
- 当前 active path 高亮
- Hover 节点显示消息预览卡片
- Click 节点跳转到 ChatGPT 中对应的消息
- 长线性段可手动折叠
- 基础全文搜索(命中节点高亮)
- 左侧轮次编号
- Claude.ai 支持(需要不同的 adapter 策略,本文档不涉及)
- 分支命名/标签(收费)
- 高级搜索:fuzzy、命中上下文片段(收费)
- 多面板并排对比(收费)
三层结构,未来加新的 LLM 产品(Claude 等)只需要新加一个 adapter,不需要动核心逻辑。
content-script.js (注入到 ChatGPT 页面)
│
├── adapter (chatgpt.js) — 拉数据 / 写数据
│
├── core (tree, layout, search) — 与平台无关的逻辑
│
└── ui (panel, render, card) — 渲染层
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。
平台无关。处理 tree 数据结构、布局计算、搜索索引、本地缓存。
浮动面板、SVG 渲染、hover 卡片、搜索框。所有 UI 都从 core 层拿计算好的数据,不直接调 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
};type LayoutNode = {
id: string;
x: number; // SVG 坐标
y: number;
depth: number; // 从根算起的深度(用于轮次编号)
isOnActivePath: boolean;
};URL pattern: https://chatgpt.com/c/{conversationId},从 location.pathname 用正则提取。
如果用户在 / 主页(还没创建对话),getConversationId() 返回 null,面板显示"还没有对话"占位。
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 分钟,过期重新拉。
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 数组是数组而不是单值,正是为了承载兄弟节点。这一点已经验证过,不要怀疑。
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 接口要为它留出位置。
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)。
用 Reingold-Tilford 算法(即 d3-hierarchy 的 d3.tree())。为什么:
- 父节点的 x 坐标会被居中到所有子节点的 x 范围中央 → 整棵树左右对称
- 不同子树之间不会重叠
- 算出来的布局只取决于树的拓扑,不取决于 active path → 切换 active 不需要重排
如果 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)。
- 普通节点直径:10px(半径 5px)
- 当前位置外环:半径 8px,stroke 1.5px
- 节点之间垂直间距:36px(深度 +1 → y +36)
- 节点之间水平间距:64px(兄弟节点最小水平距离)
- D3 tree 调用:
d3.tree().nodeSize([64, 36])
- 面板内容区域
overflow-y: auto - 计算完布局后,把当前节点滚动到视口中央:
scrollIntoView({ block: 'center', behavior: 'smooth' }) - 横向:如果某一层兄弟节点超过面板宽度,允许
overflow-x: auto - 不做虚拟化,DOM 节点几百个也能扛
- 默认状态:右下角一个 36×36 圆形按钮(图标:树的简笔画)
- 点击 → 展开为右侧 sidebar,宽度 360px,可拖动调整 240–600px
- sidebar 顶部:搜索框(左)+ 关闭按钮(右)
- sidebar 主体:SVG 容器(可滚动)
- sidebar 底部:状态条
15 nodes · 3 branches
| 节点 | 状态 | 颜色 |
|---|---|---|
| 用户消息 | 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 主题。
- 所有用户和助手节点都是实心圆
- 不区分分叉点和普通节点(分叉本身已经通过连线表达)
- 当前位置在节点外面套一个浅色环
- 虚拟根用空心菱形
- 父→子之间用 cubic Bezier 曲线
- 控制点放在中间 y:
M px,py C px,(py+cy)/2 cx,(py+cy)/2 cx,cy - 同 x(直线下降)的情况画成直线
- Active path 上的连线 2px,颜色更深;非 active 1.5px,淡灰
- 每个 user 节点的左侧(距离 24px)画一个小数字
- 数字 = 该节点的深度对应的轮次,定义如下:
- 从 root 开始,每经过一个 user 节点深度 +1
- 因为 user 和 assistant 严格交替,所以同一个 y 坐标上的所有 user 节点轮次相同
- 这意味着左侧数字会自然排成一列
- 字号 11px,颜色
var(--text-tertiary),font-variant-numeric: tabular-nums - 助手节点旁边不显示数字
- 虚拟根不参与编号
定义:"线性段" = 从一个分叉点(或起点)到下一个分叉点(或叶子)之间,所有节点都只有一个子节点的那一段连续节点。
默认:全部展开。
触发折叠:用户点击该线性段中间的连线段(不是节点)→ 整段折叠。
折叠后渲染:用一个胶囊形状 [ +N ] 替代被折叠的节点,N = 该段中折叠掉的节点数(不包括两端的分叉点 / 叶子)。
取消折叠:点击胶囊。
折叠状态保存在内存中,不持久化(V2 可考虑存到 chrome.storage)。
ChatGPT 允许用户对第一条消息反复 edit & resubmit,会产生多个没有共同 user 父节点的 root user message。处理方式见 §5.2 的虚拟根。
parse 阶段直接过滤掉 is_visually_hidden_from_conversation === true 或 role 不是 user/assistant 的节点。childrenIds 在构建时要"穿透"隐藏节点,把可见后代直接挂到可见祖先下。
某些节点的 message 字段可能为 null(被用户删除或服务端异常)。parse 阶段直接跳过,但要记录 warning 到 console,方便诊断。
mapping 只有一个 system 节点。过滤后 nodesById 为空。面板显示 "对话还没开始" 占位。
用户在 ChatGPT UI 中编辑、重新生成、发送新消息时,扩展需要感知并刷新树。监听方式:
- 拦截
fetch调用,监听对/backend-api/conversation的 POST/PATCH 请求完成事件 - 或者监听 ChatGPT 主聊天区的 DOM mutation
- 或者轮询
current_node(不推荐)
V1 用 fetch 拦截,最干净。
如 §4.4 所述,准备 fallback 到 DOM 模拟点击。MVP 阶段先让 PATCH 跑,失败时弹一个 toast 让用户手动刷新。
401 错误 → 清缓存重新拉 token → 重试一次 → 还失败就提示用户重新登录 ChatGPT。
- 鼠标进入节点 → 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)
- 触发
adapter.switchToNode(currentConversationId, nodeId) - 显示 loading spinner 在节点上
- PATCH 完成后 reload 页面(MVP)
- 把 hover 卡片"钉"在节点旁边,不再随鼠标移动
- 再次 shift+click 同一节点 → 取消 pin
- 点击其他地方 → 取消 pin
- 顶部搜索框,输入时实时过滤
- 实现:对所有节点的
content字段做toLowerCase().includes(query) - 命中节点:用 CSS 动画做脉冲高亮(
@keyframes pulse,2 秒一次) - 搜索框右侧显示
3 / 12计数(命中 / 总数) - 上下方向键在命中节点之间跳转,每跳一次把对应节点 scroll into view
- ESC 清空搜索
实现成本估算:~50 行 JS + 10 行 CSS。一个晚上能搞完。
见 §6.6。
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" }
}- 写 ChatGPT adapter 的
getConversationId()/getBearerToken()/fetchTree()/parseTree() - 在 console 里能正确打出某个 conversation 的 TreeNode 列表
- 验证
switchToNode()的 PATCH 能正常切换分支
验收标准:在浏览器 console 里调 window.__ext.adapter.fetchTree(id).then(parseTree).then(console.log) 能看到完整树结构,包含所有分支。
- 注入浮动按钮 + sidebar 面板
- 用 d3-hierarchy 算出布局
- SVG 渲染节点、连线、当前位置外环
- 区分 user / assistant 颜色
- 区分 active path / inactive
- 渲染左侧轮次编号
- 处理多 root 虚拟节点
验收标准:打开任意已有 conversation,面板里能看到完整树结构,颜色和当前位置正确。
- Hover 200ms → 预览卡片
- Click → 跳转
- Shift+Click → pin
验收标准:能 hover 任意节点看到预览,click 能正确跳转到 ChatGPT 中那条消息。
- 顶部搜索框
- Substring 实时过滤
- 命中高亮 + 上下方向键导航
验收标准:在 50+ 节点的对话里搜一个关键词,命中节点正确高亮,方向键能跳转。
- 长线性段点击折叠 / 展开
- 隐藏节点穿透
- 空对话占位
- 监听 ChatGPT UI 中的操作并自动刷新树
验收标准:跑通 §7 所有边缘情况清单,没有 console error。
- sidebar 宽度可拖动调整
- light / dark 主题适配
- 加载/错误状态
- 简单的 README 和截图
整个 MVP 大约 3–4 天工作量。
-
adapter 写得宽容一点。ChatGPT 的字段名可能会变,对每个字段访问用可选链
?.,缺失时给合理 fallback,并用console.warn记录,不要直接抛错让整个面板挂掉。 -
不要引入 React / Vue / 任何框架。content script 越轻越好,原生 DOM API 完全够用。整个 UI 大约 500 行 JS 能搞定。
-
不要用整个 d3。只引
d3-hierarchy这个子包(约 12KB)。 -
不要尝试做"实时双向同步"。Phase 1 的
switchToNode先用最简单的location.reload()实现,不要去搞 ChatGPT 内部状态的 hot swap。 -
CSS 全部用作用域前缀避免污染 ChatGPT 自己的样式。建议所有 class 加前缀
.cgt-(chatgpt-tree)。 -
如果遇到本规范没说清楚的地方,需要提出来跟我讨论,不要发挥。后面会有 review。
仅作为路标,避免实现 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 中提出,确认后再改。