ChatGPT 对话导航、问题答案导出与搜索定位增强脚本。
// ==UserScript==
// @name GPT-Navigation
// @version 1.0.0
// @license GPL-3.0-or-later
// @author 凌致
// @description ChatGPT 对话导航、问题答案导出与搜索定位增强脚本。
// @match https://chat.openai.com/**
// @match https://chatgpt.com/**
// @icon https://t1.gstatic.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&size=32&url=https://chatgpt.com
// @namespace ai-conversation-navigator
// @run-at document-idle
// @grant none
// ==/UserScript==
(function() {
'use strict';
// ============================================================
// ==Config== 配置层 - 常量、选择器、开关
// ============================================================
// [START: 配置常量]
const DOM_MARK = 'data-ai-conversation-navigator';
const STORAGE_PREFIX = 'ai_navigator';
const CHATGPT_MESSAGE_SELECTOR = '[data-message-author-role]';
// Gemini selectors removed (GPT-only build)
let conversationKey = null;
let loaded = false;
let activeIndex = null;
let refreshScheduled = false;
// [END: 配置常量]
const cssText = `
:host {
--brand-primary: #7fbf7b;
--brand-primary-strong: #5f9f5c;
--panel-bg: rgba(240, 248, 237, 0.98);
--panel-border: rgba(95, 159, 92, 0.18);
--panel-shadow: 0 18px 45px rgba(80, 112, 78, 0.18);
--text-primary: #203126;
--text-secondary: #5f7461;
--surface-muted: rgba(230, 241, 227, 0.96);
--line-strong: rgba(22, 33, 25, 0.16);
--panel-width: 336px;
--panel-max-height: min(72vh, 760px);
display: block;
position: fixed;
top: 10vh;
right: 16px;
z-index: 2147483647;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: var(--text-primary);
font-size: 13px;
user-select: none;
}
.app-shell {
position: relative;
}
.panel {
position: relative;
right: 0;
width: var(--panel-width);
max-height: var(--panel-max-height);
display: flex;
flex-direction: column;
border-radius: 22px;
background: linear-gradient(180deg, color-mix(in srgb, var(--panel-bg) 98%, white 2%), color-mix(in srgb, var(--panel-bg) 92%, transparent 8%));
border: 1px solid var(--panel-border);
box-shadow: 0 24px 64px rgba(15, 23, 42, 0.14), 0 2px 0 rgba(255, 255, 255, 0.35) inset;
backdrop-filter: blur(22px) saturate(1.08);
overflow: hidden;
}
.header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 12px 10px;
border-bottom: 1px solid var(--line-strong);
cursor: move;
background: linear-gradient(180deg, color-mix(in srgb, var(--panel-bg) 94%, white 6%), color-mix(in srgb, var(--panel-bg) 74%, transparent 26%));
}
.title-block {
flex: 1;
min-width: 0;
}
.header-actions {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px;
border-radius: 14px;
background: color-mix(in srgb, var(--panel-bg) 80%, transparent 20%);
border: 1px solid var(--line-strong);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.75);
}
.title {
font-size: 14px;
font-weight: 700;
line-height: 1.15;
letter-spacing: -0.01em;
}
.subtitle {
margin-top: 2px;
color: var(--text-secondary);
font-size: 10px;
}
.icon-btn {
width: 30px;
height: 30px;
border: 1px solid rgba(15, 23, 42, 0.07);
border-radius: 10px;
background: linear-gradient(180deg, rgba(255,255,255,0.96), rgba(248,250,252,0.92));
color: var(--text-primary);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
flex-shrink: 0;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
transition: transform 0.14s ease, border-color 0.14s ease, background 0.14s ease, color 0.14s ease, box-shadow 0.14s ease;
}
.icon-btn[data-active="true"] {
color: var(--brand-primary-strong);
border-color: rgba(25, 195, 125, 0.28);
background: linear-gradient(180deg, rgba(220,252,231,0.98), rgba(209,250,229,0.9));
box-shadow: 0 8px 18px rgba(25, 195, 125, 0.12);
}
.icon-btn:hover {
border-color: rgba(25, 195, 125, 0.35);
color: var(--brand-primary-strong);
background: #fff;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
transform: translateY(-1px);
}
.content {
display: flex;
flex-direction: column;
min-height: 0;
}
:host(:not([open])) .panel {
width: auto;
max-height: none;
overflow: visible;
border-radius: 18px;
}
:host(:not([open])) .header {
padding: 8px;
border-bottom: none;
background: transparent;
cursor: default;
}
:host(:not([open])) .title-block,
:host(:not([open])) .content,
:host(:not([open])) .header-actions > :not(.toggle-eye) {
display: none !important;
}
:host(:not([open])) .header-actions {
padding: 0;
border: none;
background: transparent;
box-shadow: none;
}
.list-container {
position: relative;
padding: 8px;
max-height: 44vh;
overflow-y: auto;
scrollbar-width: thin;
}
.list-container::-webkit-scrollbar {
width: 6px;
}
.list-container::-webkit-scrollbar-thumb {
background: rgba(107, 114, 128, 0.35);
border-radius: 999px;
}
ul {
list-style: none;
margin: 0;
padding: 0;
}
li {
padding: 10px 11px;
margin-bottom: 5px;
border-radius: 12px;
cursor: pointer;
color: var(--text-secondary);
background: linear-gradient(180deg, rgba(248,250,252,0.95), rgba(244,247,250,0.88));
border: 1px solid rgba(15, 23, 42, 0.04);
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: background 0.15s ease, color 0.15s ease, transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.question-item {
padding: 0;
overflow: hidden;
}
.question-row {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 11px;
position: sticky;
top: 0;
z-index: 1;
background: inherit;
}
.question-text {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.expand-btn {
width: 24px;
height: 24px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 8px;
background: rgba(255,255,255,0.88);
color: var(--text-secondary);
cursor: pointer;
flex-shrink: 0;
}
.expand-btn:hover {
color: var(--brand-primary-strong);
border-color: rgba(25, 195, 125, 0.24);
}
.question-item.active .question-row {
color: var(--brand-primary-strong);
font-weight: 600;
}
.answer-tree {
display: flex;
flex-direction: column;
gap: 4px;
padding: 0 8px 8px 8px;
}
.answer-node {
padding: 7px 10px;
border-radius: 10px;
color: var(--text-secondary);
background: rgba(255,255,255,0.7);
border: 1px solid rgba(15, 23, 42, 0.04);
font-size: 12px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.answer-node:hover {
color: var(--brand-primary-strong);
border-color: rgba(25, 195, 125, 0.14);
}
li:hover {
color: var(--brand-primary-strong);
background: rgba(220, 252, 231, 0.9);
transform: translateX(-1px);
border-color: rgba(25, 195, 125, 0.14);
}
li.active {
color: var(--brand-primary-strong);
background: linear-gradient(180deg, rgba(220,252,231,0.98), rgba(209,250,229,0.94));
font-weight: 600;
border-color: rgba(25, 195, 125, 0.2);
box-shadow: inset 0 0 0 1px rgba(25, 195, 125, 0.08), 0 8px 16px rgba(25, 195, 125, 0.08);
}
.fade-bottom {
position: sticky;
bottom: 0;
width: 100%;
height: 16px;
margin-top: -16px;
background: linear-gradient(to top, rgba(255,255,255,0.94), rgba(255,255,255,0));
pointer-events: none;
}
.text-input,
display: flex;
flex-direction: column;
gap: 6px;
}
.field-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.field-label {
color: var(--text-primary);
font-size: 12px;
font-weight: 600;
}
.range-row,
.toggle-row,
.style-row {
display: flex;
gap: 6px;
align-items: center;
width: 100%;
}
.range-row.column {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.range-row span {
color: var(--text-secondary);
font-size: 12px;
}
.text-input {
width: 100%;
box-sizing: border-box;
border: 1px solid rgba(15, 23, 42, 0.1);
border-radius: 12px;
background: color-mix(in srgb, var(--panel-bg) 88%, white 12%);
color: var(--text-primary);
box-shadow: inset 0 1px 1px rgba(15, 23, 42, 0.02);
font: inherit;
}
.text-input {
min-height: 42px;
padding: 10px 12px;
}
.text-area {
min-height: 132px;
padding: 12px;
resize: vertical;
line-height: 1.5;
}
.primary-btn {
min-height: 40px;
padding: 0 14px;
border: 1px solid #6eaf6a;
border-radius: 12px;
background: linear-gradient(180deg, #8dc887, #72b46e);
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 8px 18px rgba(95, 159, 92, 0.18);
}
.primary-btn:hover {
filter: brightness(1.02);
}
.select-check {
appearance: none;
-webkit-appearance: none;
width: 18px;
height: 18px;
margin: 0;
border: 1.5px solid #000;
border-radius: 5px;
background: color-mix(in srgb, var(--panel-bg) 92%, white 8%);
cursor: pointer;
flex-shrink: 0;
position: relative;
box-shadow: inset 0 1px 1px rgba(255,255,255,0.6);
}
.select-check:checked {
background: linear-gradient(180deg, #9fd49a, #7fbf7b);
border-color: #000;
}
.select-check:checked::after {
content: '';
position: absolute;
left: 5px;
top: 1px;
width: 4px;
height: 9px;
border: solid #14311c;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.mini-btn {
min-width: 46px;
height: 28px;
padding: 0 10px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 9px;
background: rgba(255,255,255,0.96);
color: var(--text-primary);
font-size: 12px;
cursor: pointer;
}
.mini-btn:hover {
border-color: rgba(25, 195, 125, 0.28);
color: var(--brand-primary-strong);
}
.mini-btn.danger:hover {
border-color: rgba(239, 68, 68, 0.26);
color: #dc2626;
}
.ghost-btn {
min-height: 40px;
padding: 0 14px;
border: 1px solid rgba(15, 23, 42, 0.1);
border-radius: 12px;
background: rgba(255,255,255,0.96);
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.ghost-btn:hover {
border-color: rgba(25, 195, 125, 0.24);
color: var(--brand-primary-strong);
}
.button-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.button-row > * {
flex: 1;
}
.tag-chip {
display: inline-flex;
align-items: center;
width: fit-content;
margin-top: 6px;
padding: 3px 8px;
border-radius: 999px;
background: rgba(226, 232, 240, 0.75);
color: var(--text-secondary);
font-size: 11px;
line-height: 1;
}
.status-line {
margin-top: 6px;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.5;
}
.status-line[data-tone="success"] {
color: #047857;
}
.status-line[data-tone="warning"] {
color: #b45309;
}
.status-line[data-tone="danger"] {
color: #b91c1c;
}
.editor-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 10px;
}
.editor-badge {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
background: rgba(226, 232, 240, 0.8);
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
}
.toolbar-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.toolbar-row.stack {
align-items: stretch;
flex-direction: column;
gap: 10px;
}
.toolbar-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.toolbar-head {
display: flex;
justify-content: flex-start;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
}
.master-check {
display: inline-grid;
grid-auto-flow: column;
grid-template-columns: 18px auto;
gap: 8px;
align-items: center;
justify-content: center;
height: 38px;
padding: 0 10px;
border: 1px solid var(--line-strong);
border-radius: 12px;
background: color-mix(in srgb, var(--panel-bg) 88%, var(--surface-muted) 12%);
color: var(--text-primary);
font-size: 12px;
font-weight: 700;
box-sizing: border-box;
vertical-align: middle;
}
.master-check .select-check {
display: block;
align-self: center;
justify-self: center;
}
.master-check span {
display: inline-flex;
align-items: center;
justify-content: center;
height: 100%;
line-height: 1;
white-space: nowrap;
}
.helper-text {
margin-top: 6px;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.5;
}
.field-select {
width: 100%;
min-height: 42px;
padding: 0 12px;
border-radius: 12px;
border: 1px solid var(--line-strong);
background: color-mix(in srgb, var(--panel-bg) 86%, white 14%);
color: var(--text-primary);
font: inherit;
}
.field-row-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.inline-action-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: end;
}
.menu-wrap {
position: relative;
}
.menu-panel {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 204px;
padding: 8px;
border-radius: 14px;
border: 1px solid var(--line-strong);
background: color-mix(in srgb, var(--panel-bg) 96%, transparent 4%);
box-shadow: var(--panel-shadow);
display: none;
z-index: 20;
}
.menu-panel.show {
display: block;
}
.menu-panel.disabled {
display: none !important;
}
.menu-item {
width: 100%;
min-height: 36px;
border: 1px solid transparent;
border-radius: 10px;
background: transparent;
color: var(--text-primary);
text-align: left;
cursor: pointer;
padding: 0 10px;
}
.menu-item:hover {
background: var(--surface-muted);
border-color: var(--line-strong);
}
.search-bar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 8px;
margin-bottom: 0;
align-items: stretch;
}
.search-bar .text-input {
min-height: 30px;
padding: 4px 12px;
}
.icon-mini-btn {
width: 40px;
height: 38px;
border: 1px solid rgba(15, 23, 42, 0.1);
border-radius: 12px;
background: rgba(255,255,255,0.96);
color: var(--text-primary);
cursor: pointer;
font-size: 15px;
}
.icon-mini-btn:hover {
border-color: rgba(25, 195, 125, 0.24);
color: var(--brand-primary-strong);
}
.search-hit {
display: inline;
padding: 0 1px;
border-radius: 3px;
background: var(--search-hit-bg, #fff59d) !important;
color: var(--search-hit-text, #111827) !important;
box-shadow: inset 0 0 0 1px var(--search-hit-outline, rgba(161, 98, 7, 0.18));
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.current-search-hit {
background: var(--search-hit-current-bg, #f7c948) !important;
color: var(--search-hit-current-text, #111827) !important;
box-shadow: 0 0 0 2px var(--search-hit-shadow, rgba(245, 158, 11, 0.28));
}
.search-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 8px;
align-items: end;
}
.selection-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.selection-row > * {
margin: 0;
align-self: center;
}
.selection-row .editor-badge,
.selection-row .compact-btn,
.selection-row .master-check {
min-width: 108px;
min-height: 38px;
box-sizing: border-box;
}
.selection-row .editor-badge {
margin-left: 0;
}
.selection-row .master-check {
display: inline-flex;
align-items: center;
justify-content: flex-start;
padding-left: 12px;
padding-right: 12px;
padding-top: 0;
padding-bottom: 0;
}
.selection-row .master-check .select-check {
position: relative;
top: 2px;
flex-shrink: 0;
}
.selection-row .master-check.checkbox-only {
min-width: 38px;
width: 38px;
padding-left: 0;
padding-right: 0;
justify-content: center;
gap: 0;
}
.selection-row .master-check.checkbox-only span {
display: none;
}
.selection-row .master-check.checkbox-only .select-check {
top: 1px;
}
.selection-row .master-check span {
display: inline-flex;
align-items: center;
min-height: 0;
height: auto;
}
.compact-btn {
min-width: 108px;
min-height: 38px;
padding: 0 12px;
border: 1px solid var(--line-strong);
border-radius: 12px;
background: color-mix(in srgb, var(--panel-bg) 88%, var(--surface-muted) 12%);
color: var(--text-primary);
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.compact-btn.danger-btn:hover {
border-color: rgba(239, 68, 68, 0.26);
color: #dc2626;
}
.compact-btn:hover {
border-color: rgba(25, 195, 125, 0.28);
color: var(--brand-primary-strong);
}
.setting-stack {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
.setting-row-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
width: 100%;
}
.setting-inline {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
}
.setting-inline.equal {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.setting-inline .number-input,
.setting-inline .field-select,
.setting-inline .ghost-btn,
.setting-inline .primary-btn {
width: 100%;
}
.setting-inline .primary-btn {
min-width: 132px;
}
.setting-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.drop-zone {
display: flex;
align-items: center;
justify-content: center;
min-height: 140px;
padding: 16px;
border-radius: 14px;
border: 1px dashed rgba(15, 23, 42, 0.16);
background: rgba(255,255,255,0.7);
color: var(--text-secondary);
text-align: center;
line-height: 1.6;
}
.drop-zone.dragover {
border-color: rgba(25, 195, 125, 0.4);
background: rgba(240, 253, 244, 0.85);
color: var(--brand-primary-strong);
}
.sr-file {
display: none;
}
.choice-row {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.choice-row.grid-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.choice-btn,
.style-dot,
.type-btn {
border: 1px solid rgba(15, 23, 42, 0.1);
background: #fff;
color: var(--text-primary);
border-radius: 12px;
cursor: pointer;
}
.choice-btn {
padding: 6px 10px;
font-size: 11px;
min-height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
transition: transform 0.14s ease, border-color 0.14s ease, box-shadow 0.14s ease, background 0.14s ease, color 0.14s ease;
}
.choice-btn span:last-child {
font-weight: 600;
letter-spacing: 0.01em;
}
.choice-btn[data-value="default"] {
background: linear-gradient(180deg, #ffffff, #f3f4f6);
color: #111827;
}
.choice-btn.active,
.type-btn.active {
background: linear-gradient(180deg, #8dc887, #72b46e);
color: #fff;
border-color: #6eaf6a;
}
.choice-btn:hover,
.style-dot:hover,
.type-btn:hover {
border-color: rgba(25, 195, 125, 0.45);
transform: translateY(-1px);
}
.choice-btn.active {
box-shadow: 0 8px 18px rgba(25, 195, 125, 0.2);
}
.toggle-row {
justify-content: space-between;
}
.style-row {
justify-content: space-between;
}
.style-dots {
display: flex;
gap: 8px;
}
.style-dot {
width: 26px;
height: 26px;
border-radius: 999px;
}
.style-dot.active {
outline: 3px solid rgba(25, 195, 125, 0.2);
border-color: var(--brand-primary);
}
.type-switch {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.type-btn {
padding: 6px 10px;
font-size: 11px;
}
.range-row .number-input,
.range-row button {
height: 40px;
border: 1px solid rgba(15, 23, 42, 0.1);
border-radius: 10px;
background: rgba(255,255,255,0.96);
color: var(--text-primary);
}
.range-row button {
width: 100%;
padding: 0 14px;
background: linear-gradient(180deg, #8dc887, #72b46e);
color: #fff;
border-color: #6eaf6a;
white-space: nowrap;
cursor: pointer;
box-shadow: 0 8px 18px rgba(95, 159, 92, 0.18);
font-size: 14px;
font-weight: 600;
}
.inline-field {
display: flex;
align-items: center;
gap: 6px;
}
.inline-field span {
color: var(--text-secondary);
font-size: 12px;
white-space: nowrap;
}
.switch-pair {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.switch-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 34px;
padding: 0 1px;
}
.theme-chip {
width: 12px;
height: 12px;
border-radius: 999px;
border: 1px solid rgba(15, 23, 42, 0.1);
flex-shrink: 0;
box-shadow: inset 0 1px 1px rgba(255,255,255,0.55);
}
@media (max-width: 640px) {
:host {
top: 12px;
right: 12px;
}
.panel {
width: min(320px, calc(100vw - 24px));
}
.field-row,
.choice-row.grid-2,
.switch-pair,
.search-row,
.question-row {
padding-right: 8px;
}
}
`;
// [END: CSS样式]
// ============================================================
// ==State== 状态层 - 全局状态管理
// ============================================================
// [START: 全局状态]
// 注意: 使用原有的全局变量作为状态管理
// conversationKey, loaded, activeIndex, refreshScheduled, geminiBootstrapStarted
// [END: 全局状态]
// ============================================================
// ==Utils== 工具层 - 纯函数工具
// ============================================================
// [START: 工具函数]
function detectSite() {
return 'chatgpt';
}
async function copyText(text) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
const area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', 'readonly');
area.style.position = 'fixed';
area.style.opacity = '0';
document.body.appendChild(area);
area.select();
area.setSelectionRange(0, area.value.length);
const success = document.execCommand('copy');
area.remove();
if (!success) throw new Error('copy failed');
return true;
}
function scrollToNode(target, block = 'start') {
if (!target) return;
if (!isChatGPTSharePage()) target.style.scrollMarginTop = '56px';
try {
target.scrollIntoView({ behavior: 'smooth', block });
} catch {
target.scrollIntoView();
}
}
function isChatGPTSharePage() {
return location.pathname.startsWith('/share/');
}
function getPositionStorageKey() {
return `${STORAGE_PREFIX}_position_${detectSite()}`;
}
function sanitizeText(text) {
return (text || '').replace(/\s+/g, ' ').trim();
}
function getRootNodes(root = document) {
const roots = [root];
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let current = walker.currentNode;
while (current) {
if (current.shadowRoot) {
roots.push(current.shadowRoot);
}
current = walker.nextNode();
}
return roots;
}
function queryAllDeep(selector, root = document) {
const results = [];
getRootNodes(root).forEach(searchRoot => {
results.push(...Array.from(searchRoot.querySelectorAll(selector)));
});
return Array.from(new Set(results));
}
function getClosestMessageWrapper(element) {
if (!element) return null;
return element.closest('[data-testid^="conversation-turn-"]')
|| element.closest('[data-testid]')
|| element.closest('article')
|| element.closest('section')
|| element.parentElement;
}
function getSiteTitle() {
return 'ChatGPT';
}
function getConversationKey() {
return `chatgpt::${location.pathname}::${location.search}`;
}
function queryChatGPTContainer() {
return document.querySelector('main')?.querySelector('.flex.flex-col.text-sm') || null;
}
async function ensureFullDOMRendered() {
const scrollContainer = queryScrollContainer();
if (!scrollContainer) return;
const originalOverflow = scrollContainer.style.overflow;
scrollContainer.style.overflow = 'hidden';
const originalScrollTop = scrollContainer.scrollTop;
const maxScroll = scrollContainer.scrollHeight;
const step = Math.min(800, scrollContainer.clientHeight * 2);
let pos = 0;
while (pos < maxScroll) {
scrollContainer.scrollTop = pos;
pos += step;
await new Promise(r => setTimeout(r, 50));
}
scrollContainer.scrollTop = originalScrollTop;
scrollContainer.style.overflow = originalOverflow;
}
function queryChatGPTQuestionElements() {
const directUsers = queryAllDeep('[data-message-author-role="user"]');
const wrapped = directUsers
.map(getClosestMessageWrapper)
.filter(Boolean);
return Array.from(new Set(wrapped)).sort((a, b) => {
if (a === b) return 0;
const pos = a.compareDocumentPosition(b);
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1;
return 0;
});
}
function queryQuestionElements() {
return queryChatGPTQuestionElements();
}
function queryScrollContainer() {
return queryChatGPTContainer()?.parentElement || document.scrollingElement || document.documentElement;
}
// [END: 工具函数]
// ============================================================
// ==Logic== 业务层 - 功能模块
// ============================================================
// [START: NavigatorApp 组件]
class NavigatorApp extends HTMLElement {
// AI对话导航器主组件 - 包含UI渲染、事件处理、数据管理等功能
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._isOpen = false;
this._suppressEyeClick = false;
this._allQuestions = [];
this._allElements = [];
this._allDetails = [];
this._allQuestionIds = [];
this._questions = [];
this._elements = [];
this._details = [];
this._questionIds = [];
this._expandedQuestions = new Set();
this._selectedQuestionIds = new Set();
this._searchKeyword = '';
this._searchResults = [];
this._searchIndex = -1;
}
connectedCallback() {
this.render();
this.loadPosition();
this.updateOpenState();
this.startTracking();
this.initDraggable();
}
loadPosition() {
try {
const pos = JSON.parse(localStorage.getItem(getPositionStorageKey()) || '{}');
if (pos.top) this.style.top = pos.top;
if (pos.right) this.style.right = pos.right;
if (pos.left) this.style.left = pos.left;
} catch {}
}
savePosition() {
localStorage.setItem(getPositionStorageKey(), JSON.stringify({
top: this.style.top,
right: this.style.right,
left: this.style.left
}));
}
anchorToRight(rect = this.getBoundingClientRect()) {
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0;
const anchoredRight = Math.max(0, viewportWidth - rect.right);
this.style.left = 'auto';
this.style.right = `${anchoredRight}px`;
this.style.top = `${rect.top}px`;
}
initDraggable() {
const header = this.shadowRoot.querySelector('.header');
if (!header) return;
let startX = 0;
let startY = 0;
let startRight = 0;
let startTop = 0;
let startWidth = 0;
let dragging = false;
const onMove = (event) => {
dragging = true;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
const nextLeft = (window.innerWidth || document.documentElement.clientWidth || 0) - startRight - startWidth + dx;
const nextRight = Math.max(0, (window.innerWidth || document.documentElement.clientWidth || 0) - (nextLeft + startWidth));
this.style.left = 'auto';
this.style.right = `${nextRight}px`;
this.style.top = `${startTop + dy}px`;
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
if (dragging) {
this._suppressEyeClick = true;
this.savePosition();
setTimeout(() => {
this._suppressEyeClick = false;
}, 0);
}
};
header.addEventListener('mousedown', (event) => {
const eyeButton = event.target.closest('.toggle-eye');
if (event.target.closest('.icon-btn') && !eyeButton) return;
if (!this._isOpen && !eyeButton) return;
dragging = false;
startX = event.clientX;
startY = event.clientY;
const rect = this.getBoundingClientRect();
startRight = Math.max(0, (window.innerWidth || document.documentElement.clientWidth || 0) - rect.right);
startTop = rect.top;
startWidth = rect.width;
this.style.left = 'auto';
this.style.right = `${startRight}px`;
this.style.top = `${rect.top}px`;
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
getVisibleQuestionIds() {
return this._questionIds.filter(Boolean);
}
toggleQuestionSelection(id, forceState = null) {
if (!id) return;
const shouldSelect = forceState === null ? !this._selectedQuestionIds.has(id) : !!forceState;
if (shouldSelect) this._selectedQuestionIds.add(id);
else this._selectedQuestionIds.delete(id);
this.updateList();
}
setVisibleQuestionSelection(checked) {
this.getVisibleQuestionIds().forEach((id) => {
if (checked) this._selectedQuestionIds.add(id);
else this._selectedQuestionIds.delete(id);
});
this.updateList();
}
clearQuestionSelection() {
this._selectedQuestionIds.clear();
this.updateList();
}
clearSearchHighlights() {
const normalizeTargets = new Set();
this._searchResults.forEach((node) => {
if (!(node instanceof HTMLElement) || !node.isConnected) return;
const parent = node.parentNode;
if (!parent) return;
parent.replaceChild(document.createTextNode(node.textContent || ''), node);
normalizeTargets.add(parent);
});
normalizeTargets.forEach((node) => {
if (typeof node.normalize === 'function') node.normalize();
});
this._searchResults = [];
this._searchIndex = -1;
}
updateSearchResultState() {
this._searchResults.forEach((node, index) => {
node.classList.toggle('current-search-hit', index === this._searchIndex);
this.applySearchHighlightStyle(node, index === this._searchIndex);
});
}
getSearchHighlightPalette() {
const rootStyle = getComputedStyle(document.documentElement);
return {
bg: rootStyle.getPropertyValue('--search-hit-bg').trim() || '#fff59d',
text: rootStyle.getPropertyValue('--search-hit-text').trim() || '#111827',
currentBg: rootStyle.getPropertyValue('--search-hit-current-bg').trim() || '#f7c948',
currentText: rootStyle.getPropertyValue('--search-hit-current-text').trim() || '#111827',
outline: rootStyle.getPropertyValue('--search-hit-outline').trim() || 'rgba(161, 98, 7, 0.18)',
shadow: rootStyle.getPropertyValue('--search-hit-shadow').trim() || 'rgba(245, 158, 11, 0.24)'
};
}
applySearchHighlightStyle(node, isCurrent = false) {
if (!(node instanceof HTMLElement)) return;
const palette = this.getSearchHighlightPalette();
node.style.display = 'inline';
node.style.padding = '0 1px';
node.style.borderRadius = '3px';
node.style.background = isCurrent ? palette.currentBg : palette.bg;
node.style.color = isCurrent ? palette.currentText : palette.text;
node.style.boxShadow = isCurrent
? `0 0 0 2px ${palette.shadow}`
: `inset 0 0 0 1px ${palette.outline}`;
node.style.boxDecorationBreak = 'clone';
node.style.webkitBoxDecorationBreak = 'clone';
}
runSearch(keyword) {
this.clearSearchHighlights();
this._searchKeyword = (keyword || '').trim().toLowerCase();
if (!this._searchKeyword) {
this.updateSearchStatus();
return;
}
const roots = this._details.flatMap((detail) => {
const nodes = [];
if (detail?.answerRoot) nodes.push(detail.answerRoot);
if (detail?.questionRoot) nodes.push(detail.questionRoot);
return nodes;
});
const seenRoots = new Set();
roots.forEach((root) => {
if (!root || seenRoots.has(root)) return;
seenRoots.add(root);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
const text = node.nodeValue || '';
if (!text.trim()) return NodeFilter.FILTER_REJECT;
const parent = node.parentElement;
if (!parent) return NodeFilter.FILTER_REJECT;
if (parent.closest('.search-hit')) return NodeFilter.FILTER_REJECT;
if (['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT', 'OPTION'].includes(parent.tagName)) {
return NodeFilter.FILTER_REJECT;
}
return text.toLowerCase().includes(this._searchKeyword)
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
}
});
const textNodes = [];
let currentNode = walker.nextNode();
while (currentNode) {
textNodes.push(currentNode);
currentNode = walker.nextNode();
}
textNodes.forEach((textNode) => {
const rawText = textNode.nodeValue || '';
const lowerText = rawText.toLowerCase();
const matchLength = this._searchKeyword.length;
let startIndex = 0;
let matchIndex = lowerText.indexOf(this._searchKeyword, startIndex);
if (matchIndex === -1 || !textNode.parentNode) return;
const fragment = document.createDocumentFragment();
while (matchIndex !== -1) {
if (matchIndex > startIndex) {
fragment.appendChild(document.createTextNode(rawText.slice(startIndex, matchIndex)));
}
const hit = document.createElement('span');
hit.className = 'search-hit';
hit.textContent = rawText.slice(matchIndex, matchIndex + matchLength);
this.applySearchHighlightStyle(hit, false);
fragment.appendChild(hit);
this._searchResults.push(hit);
startIndex = matchIndex + matchLength;
matchIndex = lowerText.indexOf(this._searchKeyword, startIndex);
}
if (startIndex < rawText.length) {
fragment.appendChild(document.createTextNode(rawText.slice(startIndex)));
}
textNode.parentNode.replaceChild(fragment, textNode);
});
});
if (this._searchResults.length) {
this._searchIndex = 0;
this.updateSearchResultState();
scrollToNode(this._searchResults[0], 'center');
}
this.updateSearchStatus();
}
stepSearch(step) {
if (!this._searchResults.length) return;
this._searchIndex = (this._searchIndex + step + this._searchResults.length) % this._searchResults.length;
this.updateSearchResultState();
scrollToNode(this._searchResults[this._searchIndex], 'center');
this.updateSearchStatus();
}
updateSearchStatus() {
if (!this.searchStatusEl) return;
if (!this._searchKeyword) {
this.searchStatusEl.textContent = '';
return;
}
if (!this._searchResults.length) {
this.searchStatusEl.textContent = '未找到结果';
return;
}
this.searchStatusEl.textContent = `${this._searchIndex + 1} / ${this._searchResults.length}`;
}
normalizeMarkdown(md) {
return (md || '')
.replace(/\r\n/g, '\n')
.replace(/\u00a0/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.replace(/[ \t]+\n/g, '\n')
.trim();
}
escapeMarkdownText(text) {
return (text || '').replace(/\\/g, '\\\\');
}
escapeCodeFence(text) {
return (text || '').replace(/```/g, '``\\`');
}
getCodeLanguage(preEl) {
const code = preEl.querySelector('code');
if (!code) return '';
const className = code.className || '';
const langMatch = className.match(/language-([a-zA-Z0-9_-]+)/);
return langMatch ? langMatch[1] : '';
}
getOrderedListPrefix(li, listEl) {
const start = parseInt(listEl.getAttribute('start') || '1', 10);
const items = Array.from(listEl.children).filter(child => child.tagName === 'LI');
const index = items.indexOf(li);
return `${start + Math.max(index, 0)}. `;
}
convertTableToMarkdown(tableEl) {
const rows = Array.from(tableEl.querySelectorAll('tr'))
.map(row => Array.from(row.children)
.filter(cell => ['TH', 'TD'].includes(cell.tagName))
.map(cell => this.nodeToMarkdown(cell).replace(/\n+/g, '<br>').trim()))
.filter(row => row.length > 0);
if (!rows.length) return '';
const header = rows[0];
const body = rows.slice(1);
const separator = header.map(() => '---');
const lines = [
`| ${header.join(' | ')} |`,
`| ${separator.join(' | ')} |`
];
body.forEach(row => {
const normalizedRow = header.map((_, index) => row[index] || '');
lines.push(`| ${normalizedRow.join(' | ')} |`);
});
return `${lines.join('\n')}\n\n`;
}
nodeToMarkdown(node, context = {}) {
if (!node) return '';
if (node.nodeType === Node.TEXT_NODE) {
return this.escapeMarkdownText(node.textContent || '');
}
if (node.nodeType !== Node.ELEMENT_NODE) return '';
const tag = node.tagName.toLowerCase();
if (['button', 'svg', 'path', 'noscript', 'style', 'script'].includes(tag)) return '';
if (tag === 'pre') {
const text = (node.innerText || '').trimEnd();
const lang = this.getCodeLanguage(node);
return text ? `\n\`\`\`${lang}\n${this.escapeCodeFence(text)}\n\`\`\`\n\n` : '';
}
if (tag === 'code') {
if (node.closest('pre')) return '';
const inlineCode = (node.textContent || '').replace(/`/g, '\\`');
return inlineCode ? `\`${inlineCode}\`` : '';
}
if (tag === 'br') return '\n';
if (tag === 'hr') return '\n---\n\n';
if (tag === 'img') {
const alt = node.getAttribute('alt') || 'image';
const src = node.getAttribute('src') || '';
return src ? `` : alt;
}
if (tag === 'a') {
const text = this.normalizeMarkdown(Array.from(node.childNodes).map(child => this.nodeToMarkdown(child, context)).join('')) || sanitizeText(node.textContent);
const href = node.getAttribute('href') || '';
return href ? `[${text}](${href})` : text;
}
if (/^h[1-6]$/.test(tag)) {
const level = parseInt(tag[1], 10);
const text = this.normalizeMarkdown(Array.from(node.childNodes).map(child => this.nodeToMarkdown(child, context)).join(''));
return text ? `${'#'.repeat(level)} ${text}\n\n` : '';
}
if (tag === 'blockquote') {
const text = this.normalizeMarkdown(Array.from(node.childNodes).map(child => this.nodeToMarkdown(child, context)).join(''));
if (!text) return '';
return text.split('\n').map(line => (line ? `> ${line}` : '>')).join('\n') + '\n\n';
}
if (tag === 'ul' || tag === 'ol') {
const items = Array.from(node.children).filter(child => child.tagName === 'LI');
const lines = items.map(li => {
const content = this.normalizeMarkdown(Array.from(li.childNodes).map(child => this.nodeToMarkdown(child, { inList: true })).join(''));
if (!content) return '';
const prefix = tag === 'ol' ? this.getOrderedListPrefix(li, node) : '- ';
return content.split('\n').map((line, index) => (index === 0 ? `${prefix}${line}` : ` ${line}`)).join('\n');
}).filter(Boolean);
return lines.length ? `${lines.join('\n')}\n\n` : '';
}
if (tag === 'table') {
return this.convertTableToMarkdown(node);
}
const childrenContent = Array.from(node.childNodes).map(child => this.nodeToMarkdown(child, context)).join('');
const normalized = this.normalizeMarkdown(childrenContent);
if (['p', 'div', 'section', 'article', 'li'].includes(tag)) {
if (!normalized) return '';
return context.inList && tag === 'li' ? normalized : `${normalized}\n\n`;
}
return normalized;
}
collectChatGPTMessages() {
const messageWrappers = Array.from(new Set(
queryAllDeep(CHATGPT_MESSAGE_SELECTOR).map(getClosestMessageWrapper).filter(Boolean)
)).sort((a, b) => {
if (a === b) return 0;
const pos = a.compareDocumentPosition(b);
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1;
return 0;
});
return messageWrappers.map((child, index) => {
const roleEl = child.querySelector(CHATGPT_MESSAGE_SELECTOR) || child;
if (!roleEl) return null;
const role = roleEl.getAttribute('data-message-author-role');
const contentRoot = role === 'assistant'
? (child.querySelector('.markdown, [data-message-id] .markdown, .prose') || roleEl)
: roleEl;
const markdown = this.normalizeMarkdown(this.nodeToMarkdown(contentRoot));
return markdown ? {
index: index + 1,
role: role === 'user' ? 'User' : 'ChatGPT',
markdown
} : null;
}).filter(Boolean);
}
collectConversationMessages() {
return this.collectChatGPTMessages();
}
buildSelectedExportMessages() {
const activeIds = this._selectedQuestionIds.size
? new Set(this._selectedQuestionIds)
: new Set(this._allQuestionIds);
const messages = [];
this._allQuestionIds.forEach((id, index) => {
if (!activeIds.has(id)) return;
const detail = this._allDetails[index] || {};
const questionRoot = detail.questionRoot || this._allElements[index];
const answerRoot = detail.answerRoot;
const questionMarkdown = this.normalizeMarkdown(this.nodeToMarkdown(questionRoot));
if (questionMarkdown) messages.push({ index: messages.length + 1, role: '问题', markdown: questionMarkdown });
const answerMarkdown = this.normalizeMarkdown(this.nodeToMarkdown(answerRoot));
if (answerMarkdown) messages.push({ index: messages.length + 1, role: '回答', markdown: answerMarkdown });
});
return messages;
}
getConversationExportMeta() {
const rawTitle = document.title
.replace(/ - ChatGPT$/i, '')
.replace(/ - Gemini$/i, '')
.replace(/[\\/:*?"<>|]/g, '_')
.trim();
return {
title: rawTitle || `${getSiteTitle()} Conversation`,
date: new Date().toLocaleString()
};
}
exportToMarkdown() {
const messages = this.buildSelectedExportMessages();
if (messages.length === 0) return null;
const { title, date } = this.getConversationExportMeta();
let mdContent = `# ${title}\n\n*导出时间: ${date}*\n\n---\n\n`;
messages.forEach(msg => {
mdContent += `## ${msg.index}. ${msg.role}\n\n${msg.markdown}\n\n---\n\n`;
});
return { title, content: mdContent };
}
exportConversation() {
const payload = this.exportToMarkdown();
if (!payload) {
alert(`未提取到有效的 ${getSiteTitle()} 对话内容,请确认页面已完整加载。`);
return;
}
this.showExportPreview(payload.content);
}
showExportPreview(mdContent) {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;z-index:2147483647;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;';
const dialog = document.createElement('div');
dialog.style.cssText = 'width:min(720px,90vw);max-height:85vh;display:flex;flex-direction:column;background:#fff;border-radius:16px;box-shadow:0 24px 64px rgba(0,0,0,0.25);overflow:hidden;';
const dialogHeader = document.createElement('div');
dialogHeader.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:14px 20px;border-bottom:1px solid rgba(0,0,0,0.08);flex-shrink:0;';
const dialogTitle = document.createElement('div');
dialogTitle.style.cssText = 'font-size:15px;font-weight:700;color:#1f2937;';
dialogTitle.textContent = '导出预览';
const btnRow = document.createElement('div');
btnRow.style.cssText = 'display:flex;gap:8px;';
const copyBtn = document.createElement('button');
copyBtn.textContent = '复制全文';
copyBtn.style.cssText = 'padding:6px 16px;border:1px solid rgba(15,23,42,0.1);border-radius:8px;background:linear-gradient(180deg,#8dc887,#72b46e);color:#fff;font-size:13px;font-weight:600;cursor:pointer;';
const downloadBtn = document.createElement('button');
downloadBtn.textContent = '立即下载';
downloadBtn.style.cssText = 'padding:6px 16px;border:1px solid rgba(15,23,42,0.1);border-radius:8px;background:linear-gradient(180deg,#60a5fa,#3b82f6);color:#fff;font-size:13px;font-weight:600;cursor:pointer;';
const closeBtn = document.createElement('button');
closeBtn.textContent = '✕';
closeBtn.style.cssText = 'width:32px;height:32px;border:1px solid rgba(15,23,42,0.08);border-radius:8px;background:#fff;color:#6b7280;font-size:16px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;';
btnRow.append(copyBtn, downloadBtn, closeBtn);
dialogHeader.append(dialogTitle, btnRow);
const pre = document.createElement('pre');
pre.style.cssText = 'margin:0;padding:20px;overflow:auto;flex:1;font-family:"Cascadia Mono","Consolas","SFMono-Regular",monospace;font-size:13px;line-height:1.0;color:#1f2937;white-space:pre-wrap;word-break:break-word;background:#fafbfc;user-select:text;';
pre.textContent = mdContent
.replace(/\n{3,}/g, '\n')
.replace(/^---$/gm, '───');
dialog.append(dialogHeader, pre);
overlay.appendChild(dialog);
this.shadowRoot.appendChild(overlay);
const close = () => { if (overlay.parentNode) overlay.remove(); };
closeBtn.onclick = close;
overlay.onclick = (e) => { if (e.target === overlay) close(); };
copyBtn.onclick = async () => {
try {
await copyText(mdContent);
copyBtn.textContent = '已复制 ✓';
setTimeout(() => { copyBtn.textContent = '复制全文'; }, 1500);
} catch {
copyBtn.textContent = '复制失败';
setTimeout(() => { copyBtn.textContent = '复制全文'; }, 1500);
}
};
downloadBtn.onclick = () => {
const meta = this.getConversationExportMeta();
const blob = new Blob([mdContent], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${meta.title}.md`;
a.click();
URL.revokeObjectURL(url);
};
}
set questions(data) {
this._allQuestions = data.questions;
this._allElements = data.elements;
this._allDetails = data.details || data.questions.map(() => ({ headings: [] }));
this._allQuestionIds = data.ids || data.questions.map((_, index) => `q_${index}`);
this._questions = this._allQuestions;
this._elements = this._allElements;
this._details = this._allDetails;
this._questionIds = this._allQuestionIds;
// 清理已选问题ID,只保留在新数据中仍然存在的ID(修复导出选中数据Bug)
const validIds = new Set(this._allQuestionIds);
this._selectedQuestionIds.forEach(id => {
if (!validIds.has(id)) {
this._selectedQuestionIds.delete(id);
}
});
this.updateList();
}
updateOpenState() {
if (this._isOpen) this.setAttribute('open', '');
else this.removeAttribute('open');
if (this.eyeBtnEl) {
this.eyeBtnEl.dataset.active = this._isOpen ? 'true' : 'false';
this.eyeBtnEl.title = this._isOpen ? '收起导航' : '展开导航';
this.eyeBtnEl.textContent = this._isOpen ? '👁️' : '👁️';
}
}
render() {
const style = document.createElement('style');
style.textContent = cssText;
const container = document.createElement('div');
const shell = document.createElement('div');
shell.className = 'app-shell';
const panel = document.createElement('div');
panel.className = 'panel';
const header = document.createElement('div');
header.className = 'header';
const titleBlock = document.createElement('div');
titleBlock.className = 'title-block';
const title = document.createElement('div');
title.className = 'title';
title.textContent = '问题导航';
const subtitle = document.createElement('div');
subtitle.className = 'subtitle';
subtitle.textContent = `${getSiteTitle()} 会话目录`;
const headerActions = document.createElement('div');
headerActions.className = 'header-actions';
const exportBtn = document.createElement('button');
exportBtn.className = 'icon-btn toggle-export';
exportBtn.type = 'button';
exportBtn.title = '导出 Markdown';
exportBtn.textContent = '↓';
const eyeBtn = document.createElement('button');
eyeBtn.className = 'icon-btn toggle-eye';
eyeBtn.type = 'button';
eyeBtn.title = '收起导航';
eyeBtn.textContent = '👁️';
eyeBtn.dataset.active = 'true';
const content = document.createElement('div');
content.className = 'content';
const listContainer = document.createElement('div');
listContainer.className = 'list-container';
const listEl = document.createElement('ul');
listEl.id = 'question-list';
const searchBar = document.createElement('div');
searchBar.className = 'search-bar';
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.className = 'text-input';
searchInput.placeholder = '搜索问题或回答';
const searchPrevBtn = document.createElement('button');
searchPrevBtn.type = 'button';
searchPrevBtn.className = 'icon-mini-btn';
searchPrevBtn.title = '上一个';
searchPrevBtn.textContent = '↑';
const searchNextBtn = document.createElement('button');
searchNextBtn.type = 'button';
searchNextBtn.className = 'icon-mini-btn';
searchNextBtn.title = '下一个';
searchNextBtn.textContent = '↓';
searchBar.append(searchInput, searchPrevBtn, searchNextBtn);
const questionToolbar = document.createElement('div');
questionToolbar.className = 'toolbar-row stack';
const questionToolbarSummary = document.createElement('div');
questionToolbarSummary.className = 'toolbar-summary';
const searchStatus = document.createElement('div');
searchStatus.className = 'helper-text';
searchStatus.textContent = '';
const questionSelectAll = document.createElement('label');
questionSelectAll.className = 'master-check';
const questionSelectAllInput = document.createElement('input');
questionSelectAllInput.type = 'checkbox';
questionSelectAllInput.className = 'select-check';
const questionSelectAllText = document.createElement('span');
questionSelectAllText.textContent = '全选';
questionSelectAll.append(questionSelectAllInput, questionSelectAllText);
const clearQuestionSelectionBtn = document.createElement('button');
clearQuestionSelectionBtn.type = 'button';
clearQuestionSelectionBtn.className = 'compact-btn';
clearQuestionSelectionBtn.textContent = '取消选择';
const questionToolbarHead = document.createElement('div');
questionToolbarHead.className = 'toolbar-head';
questionToolbarHead.append(questionSelectAll, clearQuestionSelectionBtn);
questionToolbarSummary.append(searchStatus);
questionToolbar.append(searchBar, questionToolbarHead, questionToolbarSummary);
const fadeBottom = document.createElement('div');
fadeBottom.className = 'fade-bottom';
titleBlock.append(title, subtitle);
listContainer.append(questionToolbar, listEl, fadeBottom);
content.append(listContainer);
headerActions.append(exportBtn, eyeBtn);
header.append(titleBlock, headerActions);
panel.append(header, content);
shell.append(panel);
container.appendChild(shell);
this.eyeBtnEl = eyeBtn;
this.searchInputEl = searchInput;
this.searchStatusEl = searchStatus;
this.questionSelectAllEl = questionSelectAllInput;
eyeBtn.onclick = (event) => {
if (this._suppressEyeClick) {
event.stopPropagation();
return;
}
if (this._isOpen) {
this.anchorToRight();
}
this._isOpen = !this._isOpen;
this.updateOpenState();
};
exportBtn.onclick = (event) => {
this.exportConversation();
};
questionSelectAllInput.onchange = () => this.setVisibleQuestionSelection(questionSelectAllInput.checked);
clearQuestionSelectionBtn.onclick = () => this.clearQuestionSelection();
searchInput.oninput = () => this.runSearch(searchInput.value);
searchPrevBtn.onclick = () => this.stepSearch(-1);
searchNextBtn.onclick = () => this.stepSearch(1);
listEl.onclick = (event) => {
const li = event.target.closest('li');
if (!li) return;
const idx = Number(li.dataset.index);
if (idx < 0) return;
const targetEl = this._elements[idx];
if (!targetEl) return;
if (!isChatGPTSharePage()) targetEl.style.scrollMarginTop = '56px';
targetEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
this.shadowRoot.append(style, container);
this.listEl = listEl;
this.updateOpenState();
}
updateList() {
if (!this.listEl) return;
this.listEl.replaceChildren();
const visibleIds = this.getVisibleQuestionIds();
if (this.questionSelectAllEl) {
this.questionSelectAllEl.checked = !!visibleIds.length && visibleIds.every((id) => this._selectedQuestionIds.has(id));
}
if (this._questions.length === 0) {
const placeholder = '暂未识别到问题';
const li = document.createElement('li');
li.dataset.index = '-1';
li.title = placeholder;
li.textContent = placeholder;
this.listEl.appendChild(li);
this.updateActiveItem();
return;
}
this._questions.forEach((question, index) => {
const li = document.createElement('li');
li.className = 'question-item';
li.dataset.index = String(index);
const row = document.createElement('div');
row.className = 'question-row';
row.title = question;
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'select-check';
checkbox.checked = this._selectedQuestionIds.has(this._questionIds[index]);
checkbox.onclick = (event) => {
event.stopPropagation();
this.toggleQuestionSelection(this._questionIds[index], checkbox.checked);
};
const text = document.createElement('div');
text.className = 'question-text';
text.textContent = `问题:${question}`;
const detail = this._details[index];
const hasHeadings = !!detail?.headings?.length || !!detail?.answerRoot;
const expandBtn = document.createElement('button');
expandBtn.type = 'button';
expandBtn.className = 'expand-btn';
expandBtn.textContent = hasHeadings ? (this._expandedQuestions.has(index) ? '▼' : '▶') : '·';
expandBtn.disabled = !hasHeadings;
const answerTree = document.createElement('div');
answerTree.className = 'answer-tree';
answerTree.style.display = this._expandedQuestions.has(index) ? 'flex' : 'none';
if (this._expandedQuestions.has(index) && hasHeadings) {
if (!detail.headings?.length && detail?.answerRoot) {
const child = document.createElement('div');
child.className = 'answer-node';
child.style.paddingLeft = '10px';
child.textContent = '回答';
child.onclick = (event) => {
event.stopPropagation();
scrollToNode(detail.answerRoot, 'start');
};
answerTree.appendChild(child);
}
detail.headings.forEach((item) => {
const child = document.createElement('div');
child.className = 'answer-node';
child.style.paddingLeft = `${10 + Math.max(item.level - 1, 0) * 14}px`;
child.textContent = `${item.level === 1 ? '回答:' : ''}${item.text}`;
child.onclick = (event) => {
event.stopPropagation();
scrollToNode(item.target, 'start');
};
answerTree.appendChild(child);
});
}
expandBtn.onclick = (event) => {
event.stopPropagation();
if (!hasHeadings) return;
if (this._expandedQuestions.has(index)) {
this._expandedQuestions.delete(index);
} else {
this._expandedQuestions.add(index);
}
this.updateList();
};
row.onclick = () => {
const targetEl = this._elements[index];
if (!targetEl) return;
scrollToNode(targetEl, 'start');
};
row.append(checkbox, text, expandBtn);
li.append(row, answerTree);
this.listEl.appendChild(li);
});
this.updateActiveItem();
}
updateActiveItem() {
if (!this.listEl) return;
const lis = this.listEl.querySelectorAll('.question-item');
lis.forEach((li, index) => {
if (index === activeIndex) li.classList.add('active');
else li.classList.remove('active');
});
}
startTracking() {
const scrollTarget = queryScrollContainer();
if (!scrollTarget) return;
scrollTarget.addEventListener('scroll', () => {
requestAnimationFrame(() => this.syncActiveIndex());
}, { passive: true });
}
syncActiveIndex() {
const newIndex = this._elements.findIndex(el => el.getBoundingClientRect().top >= 0);
if (newIndex !== -1 && newIndex !== activeIndex) {
activeIndex = newIndex;
this.updateActiveItem();
}
}
}
customElements.define('ai-conversation-navigator', NavigatorApp);
// [END: NavigatorApp 组件]
// ============================================================
// ==Main== 入口层 - 初始化与事件绑定
// ============================================================
// [START: 入口函数]
function extractQuestionText(element) {
if (!element) return '';
return sanitizeText(element.querySelector('[data-message-author-role="user"]')?.innerText || element.innerText);
}
function collectHeadingTargets(root) {
if (!root) return [];
const headingNodes = Array.from(root.querySelectorAll('h1, h2, h3, h4, h5, h6'))
.filter(node => sanitizeText(node.textContent).length > 0);
return headingNodes.map(node => ({
text: sanitizeText(node.textContent),
level: Number(node.tagName.slice(1)),
target: node
}));
}
function getChatGPTNavigatorEntries() {
const wrappers = Array.from(new Set(
queryAllDeep(CHATGPT_MESSAGE_SELECTOR).map(getClosestMessageWrapper).filter(Boolean)
)).sort((a, b) => {
if (a === b) return 0;
const pos = a.compareDocumentPosition(b);
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1;
return 0;
});
const entries = [];
wrappers.forEach((wrapper) => {
const roleEl = wrapper.querySelector(CHATGPT_MESSAGE_SELECTOR) || wrapper;
const role = roleEl?.getAttribute('data-message-author-role');
if (role !== 'user') return;
entries.push({
id: wrapper.dataset.messageId || wrapper.getAttribute('data-testid') || `chatgpt_${entries.length + 1}`,
question: extractQuestionText(wrapper),
element: wrapper,
detail: { headings: [], questionRoot: wrapper, answerRoot: null }
});
});
let entryIndex = -1;
wrappers.forEach((wrapper) => {
const roleEl = wrapper.querySelector(CHATGPT_MESSAGE_SELECTOR) || wrapper;
const role = roleEl?.getAttribute('data-message-author-role');
if (role === 'user') {
entryIndex += 1;
return;
}
if (role === 'assistant' && entries[entryIndex]) {
const contentRoot = wrapper.querySelector('.markdown, [data-message-id] .markdown, .prose') || wrapper;
entries[entryIndex].detail.headings = collectHeadingTargets(contentRoot);
entries[entryIndex].detail.answerRoot = contentRoot;
}
});
return entries.filter(item => item.question);
}
function collectNavigatorEntries() {
return getChatGPTNavigatorEntries();
}
function ensureNavigatorApp() {
if (!document.body) return null;
let app = document.getElementById(DOM_MARK);
if (!app) {
app = document.createElement('ai-conversation-navigator');
app.id = DOM_MARK;
document.body.appendChild(app);
}
return app;
}
async function updateNavigator() {
if (!loaded) {
await ensureFullDOMRendered();
}
const entries = collectNavigatorEntries();
const elements = entries.map(item => item.element).filter(Boolean);
let app = document.getElementById(DOM_MARK);
if (elements.length > 0) {
app = app || ensureNavigatorApp();
if (!app) return;
const filteredEntries = entries.filter(item => item.question && item.element);
app.questions = {
ids: filteredEntries.map(item => item.id),
questions: filteredEntries.map(item => item.question),
elements: filteredEntries.map(item => item.element),
details: filteredEntries.map(item => item.detail || { headings: [] })
};
} else if (app) {
app.remove();
}
}
const refreshNavigator = async () => {
const latestKey = getConversationKey();
if (conversationKey !== latestKey) {
conversationKey = latestKey;
loaded = false;
activeIndex = null;
const app = document.getElementById(DOM_MARK);
if (app) app.remove();
}
const elements = queryQuestionElements();
if (elements.length > 0) {
await updateNavigator();
loaded = true;
} else {
const app = document.getElementById(DOM_MARK);
if (app) app.remove();
loaded = false;
}
};
const scheduleRefreshNavigator = () => {
if (refreshScheduled) return;
refreshScheduled = true;
requestAnimationFrame(async () => {
refreshScheduled = false;
await refreshNavigator();
});
};
function startChatGPTBootstrap() {
setInterval(scheduleRefreshNavigator, 800);
const observer = new MutationObserver(() => {
scheduleRefreshNavigator();
});
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
}
startChatGPTBootstrap();
// [END: 入口函数]
})();