ChatGPT 长对话导航失效?我写了一个无需插件的“上一问 / 下一问”书签脚本
最近在使用 ChatGPT 处理很长的对话时,我遇到了一个越来越明显的问题:
当某一轮回答非常长时,想快速回到“上一条用户提问”或者跳到“下一条用户提问”,并没有想象中那么容易。
对于短对话,这个问题几乎感觉不到。但当一次对话持续几十轮,某些 Assistant 回答又长达几屏、十几屏甚至更多时,手动滚动就会变得非常低效。
我最开始以为,这只是一个很简单的 DOM 定位问题:
找到当前 User 节点,然后找到前一个或后一个 User 节点,再调用
scrollIntoView()。
实际调试以后才发现,ChatGPT 长对话页面的 DOM 行为比想象中复杂得多。
最终,我基于实际观察到的 DOM 行为,写了一个不需要安装浏览器扩展、不需要 Tampermonkey 的 Bookmarklet:
ChatGPT Conversation Navigator
GitHub:
https://github.com/zhurui1995/ChatGPT_Conversation_Navigator
它可以在 ChatGPT Web 中增加:
↑ 上一问↓ 下一问Alt + ↑Alt + ↓
并针对超长 Assistant 回答以及 ChatGPT 动态加载 DOM 的情况做了一些处理。
1. 问题:长对话为什么越来越难导航?
如果一轮对话很短,页面大致是这样的:
User A Assistant A User B Assistant B User C Assistant C这时手动滚动并没有什么问题。
但真实使用时,经常会出现这样的结构:
User A Assistant A Assistant A Assistant A Assistant A Assistant A …… 回答非常长 …… Assistant A User B Assistant B假设 Assistant A 占了十几屏。
当你正在回答 A 的中间位置阅读时,希望回到 User A,或者直接跳到 User B,就需要滚动很长距离。
而如果整段会话已经持续几十轮甚至几百轮,这个问题会进一步放大。
我之前也尝试过 ChatGPT Web 中的一些快捷导航方式,但在长对话中并不总是稳定,有时一次操作可能跨过多轮。
所以我开始考虑:
能不能自己做一个真正的“上一问 / 下一问”导航?
2. 最开始的想法:直接找上一个 User 节点
ChatGPT 的消息节点目前可以通过类似下面的属性检测:
[data-message-author-role="user"][data-message-author-role="assistant"]因此最直接的思路就是:
找到当前 User ↓ 找到 DOM 中前一个 User ↓ scrollIntoView()或者下一问:
找到当前 User ↓ 找到 DOM 中后一个 User ↓ scrollIntoView()如果整个对话始终完整地存在于 DOM 中,这个方案确实非常简单。
但问题恰恰在于:
ChatGPT 并不会始终把整个长对话完整地保留在当前 DOM 中。
3. 实际观察:ChatGPT 会动态摘除和恢复消息节点
为了确认网页到底发生了什么,我写了一个非常简单的调试脚本,只做两件事:
- 输出当前视窗内检测到的 User / Assistant 节点;
- 输出整个当前 DOM 中能够检测到的 User / Assistant 节点。
其中会记录:
- 节点角色;
top坐标;- 文本前 10 个字符。
一次实际输出类似这样:
视窗内消息节点: 2 1. assistant <div> top=-887 | 可以。下面这版重新按 2. user <div> top=487 | 现在的逻辑其实又变复 ------------------------------ DOM 中检测到的消息节点: 9 *=当前位于视窗内 1. user <div> top=-1011 | 现在重新编写可用的代 * 2. assistant <div> top=-887 | 可以。下面这版重新按 * 3. user <div> top=487 | 现在的逻辑其实又变复 4. assistant <div> top=763 | 对,这个版本的逻辑更 5. assistant <div> top=58780 | JavaScript 6. user <div> top=58990 | 推翻前面的所有的结果 7. assistant <div> top=59146 | 下面这个脚本不再判断 8. user <div> top=59885 | 功能正确。现在需要你 9. assistant <div> top=59993 | 可以。下面在原有“视这个结果很有意思。
当前视窗附近只有几个节点,但 DOM 中还存在一些top=58000、top=59000的节点。
继续滚动页面后,情况又发生变化。
例如:
视窗内消息节点: 1 1. assistant <div> top=-892 | 这次诊断结果已经说明 ------------------------------ DOM 中检测到的消息节点: 7 1. user <div> top=-1288 | 现在的问题是,我手动 * 2. assistant <div> top=-892 | 这次诊断结果已经说明 3. assistant <div> top=67272 | JavaScript 4. user <div> top=67483 | 推翻前面的所有的结果 5. assistant <div> top=67639 | 下面这个脚本不再判断 6. user <div> top=68378 | 功能正确。现在需要你 7. assistant <div> top=68486 | 可以。下面在原有“视这说明一个很重要的问题:
当前 DOM 中相邻的节点,并不一定是真实会话中相邻的消息。
距离视窗较远的节点可能:
- 被摘除;
- 被重新挂载;
- 暂时不存在;
- 或者出现在距离当前视窗数万像素之外的位置。
因此:
DOM 相邻并不能直接等价于:
对话相邻这也是最开始几个简单实现容易跳错位置的原因。
4. 一个更重要的特征:视窗附近的节点顺序是可信的
虽然远处 DOM 的相邻关系不可靠,但测试过程中我发现了另一个非常有用的特征:
真正出现在当前视窗中的消息节点,其上下顺序是可靠的。
例如:
User Assistant User如果这几个节点同时真实出现在 viewport 中,那么它们的相对顺序是可信的。
这就给导航逻辑提供了一个更安全的基础:
不信任远处 DOM 的“相邻关系”,只信任当前视窗附近已经实际出现的节点。
5. 如何确定“当前问题”?
我最终采用了一个非常简单的规则:
如果当前视窗中存在 User 节点,那么视窗中最靠上的 User 节点就是当前问题。
例如:
viewport top ------------------ Assistant A User B ← 第一个可见 User Assistant B ------------------ viewport bottom那么:
currentUser = User B这个定义的优点是简单,而且不用猜测:
“我现在是不是还属于上一轮 Assistant?”
如果当前视窗完全没有 User 节点,则不强行构造一个“当前 User”。
这一点在处理超长回答时非常重要。
6. 超长 Assistant 回答带来的特殊问题
在测试过程中,我遇到了这样的节点:
assistant top = -24397这意味着:
Assistant 回答顶部 ↑ │ 24397px │ 当前 viewport │ Assistant 回答仍然没有结束也就是说,单个 Assistant 回答可能高达两万多像素甚至更长。
如果这时简单做:
scrollBy(assistant.top-100)其实并不可靠。
原因是:
getBoundingClientRect().top得到的是相对于浏览器 viewport 的坐标。
而:
scrollBy()操作的可能是另一个嵌套滚动容器。
两个坐标体系并不一定完全一致。
因此,我最后没有自己计算:
需要滚动多少 px而是直接利用浏览器原生的:
scrollIntoView()配合:
scrollMarginTop例如:
element.style.scrollMarginTop='100px';element.scrollIntoView({behavior:'instant',block:'start'});这样可以直接表达:
把这个 Assistant 节点的顶部移动到距离视窗顶部约 100px 的位置。
浏览器会自己处理对应的滚动容器。
7. 为什么使用 Assistant 作为导航锚点?
这是整个方案中比较关键的一点。
假设现在正在一个非常长的 Assistant 回答中:
User A Assistant A Assistant A Assistant A Assistant A ← 当前 viewport Assistant A Assistant A User B此时:
- User A 可能已经不在当前 DOM 附近;
- User B 也可能尚未加载到附近;
- 但当前这段 Assistant A 很可能仍然存在。
因此,与其直接寻找 User,不如先利用 Assistant 作为一个锚点。
7.1 向上导航
如果要找上一问:
当前 Assistant ↓ 把 Assistant 顶部移动到约 100px ↓ 等待 ChatGPT 更新附近 DOM ↓ 重新扫描 User / Assistant ↓ 寻找当前视窗中的 User这样就更容易让与这段回答对应的 User 节点重新出现在 DOM 附近。
7.2 向下导航
向下也是类似的:
当前 Assistant ↓ 把 Assistant 底部移动到参考位置 ↓ 等待附近 DOM 更新 ↓ 重新扫描 ↓ 找到后面的 User因此,Assistant 在这里不是最终导航目标,而是一个:
让 ChatGPT 把目标 User 附近内容重新挂载出来的定位锚点。
8. 快速路径:能确认相邻 User 时直接跳转
当然,并不是每次都需要通过 Assistant。
如果当前 DOM 中已经存在:
User A Assistant A User B同时此前已经观察到的顺序也能确认:
User A → User B那么就没有必要再滚动。
可以直接:
userB.scrollIntoView({block:'start',behavior:'instant'});并通过:
scrollMarginTop='100px'把 User B 放到一个比较舒服的位置。
也就是说,导航分成两条路径:
可以可靠确认相邻 User? │ ├─ 是 │ ↓ │ 直接跳转 │ └─ 否 ↓ 找 Assistant 锚点 ↓ 调整位置 ↓ 等待 DOM 更新 ↓ 重新扫描9. 为什么选择 Bookmarklet,而不是浏览器扩展?
最开始我也考虑过 Tampermonkey 或 Chrome Extension。
但对于这样一个功能来说,我更希望:
- 不安装额外扩展;
- 不申请浏览器权限;
- 不配置 userscript manager;
- 直接点击一下就能用。
所以最终做成了 Bookmarklet。
Bookmarklet 本质上就是:
把一段 JavaScript 保存到浏览器书签的 URL 中。
点击这个书签时,脚本就在当前页面执行。
因此整个使用过程只需要:
新建书签 ↓ 把 JavaScript 放到 URL 中 ↓ 打开 ChatGPT ↓ 点击书签10. 安装方法
项目地址:
https://github.com/zhurui1995/ChatGPT_Conversation_Navigator
10.1 显示浏览器书签栏
Chrome / Edge:
Ctrl + Shift + BmacOS:
Cmd + Shift + B10.2 新建书签
例如名称:
ChatGPT Navigator或者:
ChatGPT 对话导航然后把项目中提供的 Bookmarklet JavaScript 粘贴到书签的:
javascript:(()=>{const K='__cgNav4',C={y:100,w:200,j:3};if(window[K]){window[K].p.style.display='flex';return}const G=[],M=new Map(),sl=n=>new Promise(r=>setTimeout(r,n)),nm=s=>(s||'').replace(/\s+/g,' ').trim();let busy=0;function hs(s){let h=2166136261;for(let i=0;i<s.length;i++){h^=s.charCodeAt(i);h=Math.imul(h,16777619)}return(h>>>0).toString(36)+':'+s.length}function scan(){return[...document.querySelectorAll('[data-message-author-role="user"],[data-message-author-role="assistant"]')].map(e=>{const r=e.getBoundingClientRect(),role=e.getAttribute('data-message-author-role'),txt=nm(e.innerText||e.textContent),t=e.closest('[data-testid^="conversation-turn-"]')?.getAttribute(%27data-testid%27)||%27%27;return{e,r,role,txt,k:hs(role+%27\0%27+txt)+(t?%27@%27+t:%27%27)}})}function vis(a){return a.filter(x=>x.r.bottom>0&&x.r.top<innerHeight&&x.r.height>0).sort((a,b)=>a.r.top-b.r.top)}function upd(a){const v=vis(a);if(!v.length)return;if(!G.length){for(const x of v){if(M.has(x.k))continue;const g={k:x.k,role:x.role};G.push(g);M.set(g.k,g)}return}let ch=1,n=0;while(ch&&n++<v.length+1){ch=0;for(let i=0;i<v.length-1;i++){const a=v[i],b=v[i+1],A=M.has(a.k),B=M.has(b.k);if(A&&!B){const j=G.findIndex(x=>x.k===a.k),g={k:b.k,role:b.role};G.splice(j+1,0,g);M.set(g.k,g);ch=1}else if(!A&&B){const j=G.findIndex(x=>x.k===b.k),g={k:a.k,role:a.role};G.splice(j,0,g);M.set(g.k,g);ch=1}}}}function cu(a){return vis(a).find(x=>x.role===%27user%27)||null}function near(a,x,role,d){let i=a.findIndex(n=>n.k===x.k);for(i+=d;i>=0&&i<a.length;i+=d)if(a[i].role===role)return a[i];return null}function gu(k,d){let i=G.findIndex(x=>x.k===k);for(i+=d;i>=0&&i<G.length;i+=d)if(G[i].role===%27user%27)return G[i];return null}function jump(x){if(!x)return;const o=x.e.style.scrollMarginTop;x.e.style.scrollMarginTop=C.y+%27px%27;x.e.scrollIntoView({behavior:%27instant%27,block:%27start%27});requestAnimationFrame(()=>x.e.style.scrollMarginTop=o)}function sc(e){for(let x=e?.parentElement;x&&x!==document.body;x=x.parentElement){const s=getComputedStyle(x);if(/auto|scroll/.test(s.overflowY)&&x.scrollHeight>x.clientHeight+20)return x}return document.scrollingElement}function boxScroller(e){const s=sc(e);return s===document.scrollingElement||s===document.documentElement||s===document.body?document.scrollingElement:s}function scrollScreens(e,d,n=C.j){const x=boxScroller(e),h=x.clientHeight||innerHeight;x.scrollBy({top:d*n*h,behavior:%27instant%27})}function topTo100(x){const o=x.e.style.scrollMarginTop;x.e.style.scrollMarginTop=C.y+%27px%27;x.e.scrollIntoView({behavior:%27instant%27,block:%27start%27});requestAnimationFrame(()=>x.e.style.scrollMarginTop=o)}function bottomTo100(x){const o=x.e.style.scrollMarginBottom;x.e.style.scrollMarginBottom=Math.max(0,innerHeight-C.y)+%27px%27;x.e.scrollIntoView({behavior:%27instant%27,block:%27end%27});requestAnimationFrame(()=>x.e.style.scrollMarginBottom=o)}function anchor(a,k,d){const c=a.find(x=>x.k===k);if(c){const n=near(a,c,%27assistant%27,d);if(n&&(d>0||n.r.top<0))return n}const va=vis(a).filter(x=>x.role===%27assistant%27);return d<0?(va[0]||null):(va[va.length-1]||null)}async function viaAssistant(a,d){if(d<0)topTo100(a);else bottomTo100(a);await sl(C.w);const b=scan();upd(b);const aa=b.find(x=>x.k===a.k),u=aa?near(b,aa,%27user%27,d):cu(b);if(u){jump(u);return true}return false}async function nav(d){if(busy)return;busy=1;try{let a=scan();upd(a);const c=cu(a);if(c){const du=near(a,c,%27user%27,d),g=gu(c.k,d);if(du&&g&&du.k===g.k){jump(du);return}let as=anchor(a,c.k,d);if(as&&await viaAssistant(as,d))return;scrollScreens(c.e,d);await sl(C.w);a=scan();upd(a);as=anchor(a,c.k,d);if(as)await viaAssistant(as,d);return}let as=anchor(a,%27%27,d);if(as&&await viaAssistant(as,d))return;const seed=vis(a)[0]||a[0];if(!seed)return;scrollScreens(seed.e,d);await sl(C.w);a=scan();upd(a);as=anchor(a,%27%27,d);if(as)await viaAssistant(as,d)}finally{busy=0}}const p=document.createElement(%27div%27);Object.assign(p.style,{position:%27fixed%27,right:%2718px%27,bottom:%2722px%27,zIndex:2147483647,display:%27flex%27,gap:%275px%27,padding:%276px%27,borderRadius:%2712px%27,background:%27rgba(35,35,35,.94)%27});function b(t,f){const x=document.createElement(%27button%27);x.textContent=t;Object.assign(x.style,{border:0,borderRadius:%278px%27,padding:%277px 10px%27,background:%27rgba(255,255,255,.1)%27,color:%27#fff',cursor:'pointer'});x.onclick=f;return x}function key(e){const a=document.activeElement;if(e.repeat||a&&(a.tagName==='INPUT'||a.tagName==='TEXTAREA'||a.isContentEditable))return;if(e.altKey&&!e.ctrlKey&&!e.metaKey&&!e.shiftKey&&e.key==='ArrowUp'){e.preventDefault();e.stopImmediatePropagation();nav(-1)}else if(e.altKey&&!e.ctrlKey&&!e.metaKey&&!e.shiftKey&&e.key==='ArrowDown'){e.preventDefault();e.stopImmediatePropagation();nav(1)}}function destroy(){window.removeEventListener('keydown',key,true);p.remove();delete window[K]}p.append(b('↑ 上一问',()=>nav(-1)),b('↓ 下一问',()=>nav(1)),b('×',destroy));document.body.appendChild(p);window.addEventListener('keydown',key,true);upd(scan());window[K]={p,nav,destroy}})();字段。
安装示意图:
10.3 在 ChatGPT 中启用
打开:
https://chatgpt.com/进入一个对话后,点击刚才创建的书签。
页面右下角会出现:
↑ 上一问 ↓ 下一问 ×11. 使用方法
可以直接点击:
↑ 上一问 ↓ 下一问也可以使用快捷键:
Alt + ↑ Alt + ↓如果当前焦点位于:
- 输入框;
- textarea;
- contenteditable 编辑区域;
快捷键不会触发,以免影响正常输入。
12. 点击 × 后会真正退出
这个细节我也专门处理了。
很多简单脚本只是:
panel.style.display='none';虽然按钮消失了,但:
Alt + ↑ Alt + ↓对应的事件监听器仍然存在。
因此我的实现中,点击:
×会真正执行:
移除悬浮面板 + 删除 keydown 监听器 + 删除当前脚本实例退出以后快捷键不会再生效。
如果还需要使用,只要再次点击 Bookmarklet 即可重新启用。
13. 这个方案解决了什么?
目前这个项目专注于:
User Prompt ↑ ↓ User Prompt也就是:
上一条用户提问 / 下一条用户提问
而不是:
User ↓ Assistant ↓ User ↓ Assistant这种所有消息逐个导航。
我实际更需要的是:
在长对话中快速找到我之前问了什么。
因此当前版本优先解决 Prompt-to-Prompt Navigation。
14. 这个方案没有解决什么?
首先,它不是 ChatGPT 官方功能。
它仍然依赖目前 ChatGPT Web 中存在的:
data-message-author-role="user">15. 为什么不直接让 ChatGPT 完整加载所有对话?这其实是另一个解决方向。
如果 ChatGPT 能提供:
Load Full Conversation
或者:
Progressive Loading Full Conversation Loading
这样的模式选择,那么很多导航问题都会简单得多。
另一种更理想的方案是:
右侧 Prompt Navigator 不依赖当前 DOM,而是直接索引完整会话数据。
即使某个历史 Prompt 当前没有加载,也可以点击导航项后直接请求并定位到对应部分。
这会比任何外部脚本都可靠。
16. GitHub 项目
完整源码、Bookmarklet、安装说明和后续更新都放在 GitHub:
ChatGPT Conversation Navigator
https://github.com/zhurui1995/ChatGPT_Conversation_Navigator
项目目前保持尽量轻量:
Bookmarklet + 原生 JavaScript + 不需要第三方依赖 + 不需要浏览器扩展
如果发现某个 ChatGPT 页面更新导致脚本失效,也欢迎提交 Issue。
17. 隐私和安全
这个脚本直接在当前 ChatGPT 页面中运行。
它不会主动:
- 把聊天内容发送到外部服务器;
- 上传会话;
- 收集统计数据;
- 发起额外的数据上报请求。
脚本确实会读取 User / Assistant 的文字内容,用于识别消息节点,但这些处理都发生在浏览器当前页面中。
当然,Bookmarklet 本质上仍然是可以在网页上下文中执行的 JavaScript。
因此无论使用什么 Bookmarklet,都建议先检查源码,而不要执行来源不明的脚本。
18. 最后
一开始我以为:
上一问 / 下一问
只是一个非常简单的前端功能。
真正开始调试以后才发现,在 ChatGPT 长对话的动态 DOM 环境中:
DOM 中存在 ≠ 当前真实相邻 DOM 中相邻 ≠ 对话中相邻 远处节点位置 ≠ 可以直接拿来导航
最终比较可靠的思路反而变成了:
只相信当前视窗附近的真实节点 ↓ 把 User 当作最终导航目标 ↓ 把 Assistant 当作重新加载附近 DOM 的锚点 ↓ 重新扫描 ↓ 再定位 User
目前这个 Bookmarklet 对我自己的长对话使用场景已经比较实用。
不过从产品体验来说,我还是更希望 ChatGPT Web 能够原生提供:
Previous Prompt Next Prompt
以及对应的键盘快捷键。
因为对于长对话而言,“上一问 / 下一问”实际上是一种很基础的阅读和导航能力。
理想情况下,用户不应该为了完成这样的操作,还需要额外安装或者运行一个外部脚本。
如果这个工具对你有帮助,可以在 GitHub 查看源码或反馈问题:
https://github.com/zhurui1995/ChatGPT_Conversation_Navigator