news 2026/9/3 5:57:54

ChatGPT 长对话导航失效?我写了一个无需插件的“上一问 / 下一问”书签脚本

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ChatGPT 长对话导航失效?我写了一个无需插件的“上一问 / 下一问”书签脚本

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 会动态摘除和恢复消息节点

为了确认网页到底发生了什么,我写了一个非常简单的调试脚本,只做两件事:

  1. 输出当前视窗内检测到的 User / Assistant 节点;
  2. 输出整个当前 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=58000top=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 + B

macOS:

Cmd + Shift + B

10.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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/3 5:57:49

K-Wave工具箱:基于MATLAB的声波传播仿真与光声成像应用指南

简介&#xff1a;本资源是面向生物医学成像、声学仿真及光学工程领域研究人员与高年级研究生的MATLAB光声仿真专业工具包&#xff0c;聚焦光声效应建模与图像重建核心问题&#xff0c;特别适用于光声显微成像、肿瘤血管可视化、组织光学参数反演等前沿课题研究。压缩包含619个文…

作者头像 李华
网站建设 2026/9/3 5:56:52

CUDA 20年护城河被打破,0代码、0人类,AI 14天造出真芯片

AI已能自主设计芯片&#xff0c;生成的底层代码人类已无法理解 就在刚刚&#xff0c;OpenAI工程师承认&#xff0c;已经看不懂AI写的代码了。 SemiAnalysis爆料说&#xff0c;在查看自家自研芯片的底层代码时&#xff0c;OpenAI工程师无奈地承认&#xff1a;人类已经彻底无法…

作者头像 李华
网站建设 2026/9/3 5:53:52

手机构建Java项目脚本:Termux环境下的自动化构建实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/3 5:53:07

基于DeepSeek与RAGFlow的私有知识库系统搭建实践

今天我们来快速搭建一个基于 DeepSeek 和 RAGFlow 的私人知识库系统。这个组合最大的优势是&#xff1a;DeepSeek 提供强大的大模型能力&#xff0c;RAGFlow 负责文档处理和检索增强&#xff0c;两者结合让个人或小团队也能拥有专业级的智能知识库。这套方案特别适合需要处理大…

作者头像 李华
网站建设 2026/9/3 5:52:42

Replit免费模式调整背后:AI编程与云IDE的成本博弈

如果你最近关注 Replit&#xff0c;大概率会看到两类声音&#xff1a;一类是说它“变了”“免费用户越来越难用”&#xff0c;另一类是感叹它的 AI Agent 一键生成应用确实强。这两种感受其实指向同一件事&#xff1a;Replit 的免费模式正在发生结构性调整&#xff0c;不再是早…

作者头像 李华
网站建设 2026/9/3 5:52:03

基于SOM的表格数据转图像:TabSOM方法解析与实践

TabSOM 这个名字乍一听有点论文味&#xff0c;但它解决的问题非常接地气&#xff1a;怎么把一张普通的表格数据&#xff0c;变成一张适合卷积神经网络&#xff08;CNN&#xff09;直接处理的“图”。如果你手里有分类任务&#xff0c;比如客户流失预测、信贷违约判断、工业设备…

作者头像 李华