AI 对话导航、提示词库、问题答案导出、阅读主题与搜索定位增强脚本。
// ==UserScript==
// @name AI Nav
// @version 5.0.2
// @license GPL-3.0-or-later
// @author 凌致
// @description AI 对话导航、提示词库、问题答案导出、阅读主题与搜索定位增强脚本。
// @match https://chat.openai.com/**
// @match https://chatgpt.com/**
// @match https://gemini.google.com/*
// @match https://gemini.google.com/app
// @match https://gemini.google.com/app/*
// @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 READER_CONFIG_KEY = `${STORAGE_PREFIX}_reader_config`;
const READER_STYLE_ID = `${STORAGE_PREFIX}_reader_style`;
const PROMPTS_STORAGE_KEY = `${STORAGE_PREFIX}_prompt_library`;
const PROMPT_LIBRARY_SCHEMA_VERSION = 2;
const CHATGPT_MESSAGE_SELECTOR = '[data-message-author-role]';
const GEMINI_USER_SELECTOR = [
'user-query-content',
'user-query',
'[data-test-id="user-query"]',
'[class*="user-query"]',
'[data-test-id*="user-query"]'
].join(',');
const GEMINI_ASSISTANT_SELECTOR = [
'model-response',
'message-content.model-response-text',
'.response-container-with-gpi',
'[data-test-id="model-response"]',
'[class*="model-response"]',
'[data-test-id*="model-response"]'
].join(',');
let conversationKey = null;
let loaded = false;
let activeIndex = null;
let refreshScheduled = false;
let geminiBootstrapStarted = false;
// [END: 配置常量]
const defaultReaderConfig = {
theme: 'default',
fontType: 'yahei',
fontSize: 18,
maxWidth: 900,
hideFooter: true,
cleanMode: false,
publicStyle: false,
publicColor: 'yellow',
publicType: 'half'
};
const fontStacks = {
yahei: '"Microsoft YaHei", "PingFang SC", sans-serif',
songti: '"Songti SC", "STSong", "Noto Serif CJK SC", serif',
heiti: '"SimHei", "Source Han Sans SC", sans-serif',
kaiti: '"KaiTi", "STKaiti", "LXGW WenKai Screen Web", serif',
fangsong: '"FangSong", "STFangsong", "Noto Serif CJK SC", serif',
rounded: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif',
lishu: '"LiSu", "STLiti", "KaiTi", serif',
youyuan: '"YouYuan", "Microsoft YaHei", sans-serif',
mono: '"Cascadia Mono", "Consolas", "SFMono-Regular", monospace'
};
const themes = {
default:{ bg: '', text: '', accentBg: '', accentText: '', inputBg: '', sidebarText: '' },
white: { bg: '#ffffff', text: '#333333', accentBg: '#f7f7f7', accentText: '#333333', inputBg: 'rgba(255,255,255,0.85)', sidebarText: '#000000' },
yellow: { bg: '#f6f1e7', text: '#5b4636', accentBg: '#ffffff', accentText: '#4a3b2f', inputBg: 'rgba(255,255,255,0.7)', sidebarText: '#000000' },
green: { bg: '#cce8cf', text: '#222222', accentBg: '#ffffff', accentText: '#1f3322', inputBg: 'rgba(255,255,255,0.7)', sidebarText: '#000000' },
sepia: { bg: '#f2eadf', text: '#4d3e33', accentBg: '#fffaf4', accentText: '#4d3e33', inputBg: 'rgba(255,250,244,0.88)', sidebarText: '#3f352d' },
gray: { bg: '#eceff3', text: '#243041', accentBg: '#ffffff', accentText: '#243041', inputBg: 'rgba(255,255,255,0.88)', sidebarText: '#1f2937' },
dark: { bg: '#1a1a1a', text: '#d1d5db', accentBg: '#2d2d2d', accentText: '#f3f4f6', inputBg: 'rgba(42,42,42,0.8)', sidebarText: '#ffffff' }
};
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;
}
.settings-panel {
display: none;
padding: 12px 14px 14px;
border-top: 1px solid rgba(15, 23, 42, 0.06);
background: rgba(248, 250, 252, 0.72);
}
.settings-panel.show {
display: block;
}
.settings-panel label {
display: block;
margin-bottom: 6px;
color: var(--text-secondary);
font-size: 12px;
}
.input-group {
display: flex;
gap: 8px;
align-items: center;
}
.settings-panel input {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
border: 1px solid rgba(15, 23, 42, 0.12);
border-radius: 10px;
background: #fff;
color: var(--text-primary);
}
.action-group {
margin-top: 10px;
}
.settings-panel button {
width: 100%;
padding: 9px 12px;
background: var(--brand-primary);
color: #fff;
border: none;
border-radius: 10px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
}
.settings-panel button:hover {
background: var(--brand-primary-strong);
}
.settings-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.32);
backdrop-filter: blur(3px);
display: none;
align-items: center;
justify-content: center;
padding: 20px;
z-index: 10;
}
.settings-overlay.show {
display: flex;
}
.settings-overlay.priority-overlay {
z-index: 20;
}
.settings-modal {
width: min(760px, calc(100vw - 28px));
max-height: min(82vh, 860px);
overflow-y: auto;
background: linear-gradient(180deg, color-mix(in srgb, var(--panel-bg) 98%, white 2%), color-mix(in srgb, var(--panel-bg) 90%, var(--surface-muted) 10%));
border: 1px solid color-mix(in srgb, var(--panel-border) 82%, rgba(255, 255, 255, 0.45) 18%);
border-radius: 22px;
box-shadow: 0 28px 72px rgba(15, 23, 42, 0.16);
padding: 16px;
}
.prompt-modal {
width: min(860px, calc(100vw - 28px));
background: linear-gradient(180deg, color-mix(in srgb, var(--panel-bg) 98%, white 2%), color-mix(in srgb, var(--panel-bg) 88%, var(--surface-muted) 12%));
border: 1px solid color-mix(in srgb, var(--line-strong) 72%, rgba(25, 195, 125, 0.18) 28%);
box-shadow: 0 28px 72px rgba(15, 23, 42, 0.22);
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
padding: 2px 2px 10px;
border-bottom: 1px solid rgba(15, 23, 42, 0.05);
}
.modal-head-copy {
display: flex;
flex-direction: column;
gap: 1px;
}
.modal-title {
font-size: 16px;
font-weight: 700;
letter-spacing: -0.01em;
}
.modal-stack {
display: flex;
flex-direction: column;
gap: 8px;
}
.prompt-workspace {
display: grid;
grid-template-columns: 168px minmax(0, 1fr);
gap: 10px;
min-height: min(76vh, 760px);
}
.prompt-pane-nav {
display: flex;
flex-direction: column;
gap: 8px;
}
.prompt-pane-btn {
display: flex;
flex-direction: column;
gap: 3px;
width: 100%;
padding: 12px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 14px;
background: rgba(255,255,255,0.92);
color: var(--text-primary);
text-align: left;
cursor: pointer;
transition: border-color 0.14s ease, background 0.14s ease, box-shadow 0.14s ease, transform 0.14s ease;
}
.prompt-pane-btn strong {
font-size: 13px;
line-height: 1.3;
}
.prompt-pane-btn span {
font-size: 11px;
color: var(--text-secondary);
line-height: 1.4;
}
.prompt-pane-btn:hover {
border-color: rgba(25, 195, 125, 0.28);
transform: translateY(-1px);
}
.prompt-pane-btn.active {
border-color: rgba(25, 195, 125, 0.34);
background: linear-gradient(180deg, rgba(220,252,231,0.98), rgba(240,253,244,0.92));
box-shadow: 0 12px 26px rgba(25, 195, 125, 0.12);
}
.prompt-pane-content {
min-width: 0;
min-height: 0;
}
.prompt-pane {
display: none;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.prompt-pane.active {
display: flex;
height: 100%;
}
.prompt-browse-pane {
min-height: 0;
}
.prompt-list-card {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.prompt-list-tools {
flex-shrink: 0;
}
.modal-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 12px;
width: 100%;
}
.setting-card.span-2 {
grid-column: 1 / -1;
}
.setting-card {
background: linear-gradient(180deg, color-mix(in srgb, var(--panel-bg) 92%, white 8%), color-mix(in srgb, var(--surface-muted) 90%, white 10%));
border: 1px solid color-mix(in srgb, var(--line-strong) 74%, rgba(255,255,255,0.22) 26%);
border-radius: 14px;
padding: 14px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.035);
min-width: 0;
}
.setting-card label,
.setting-title {
display: block;
margin-bottom: 7px;
color: var(--text-primary);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.01em;
}
.field-block {
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;
}
.number-input {
width: 100% !important;
min-width: 0;
height: 42px;
box-sizing: border-box;
padding: 8px 12px;
border: 1px solid rgba(15, 23, 42, 0.1);
border-radius: 10px;
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-size: 14px;
}
.text-input,
.text-area {
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);
}
.prompt-list {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-height: 420px;
max-height: none;
overflow-y: auto;
padding-right: 2px;
}
.prompt-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.prompt-tab-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.prompt-tab-btn {
min-height: 36px;
padding: 0 12px;
border: 1px solid var(--line-strong);
border-radius: 999px;
background: color-mix(in srgb, var(--panel-bg) 88%, var(--surface-muted) 12%);
color: var(--text-secondary);
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.prompt-tab-btn.active {
border-color: rgba(25, 195, 125, 0.28);
background: linear-gradient(180deg, rgba(220,252,231,0.98), rgba(240,253,244,0.92));
color: var(--brand-primary-strong);
box-shadow: 0 8px 18px rgba(25, 195, 125, 0.12);
}
.prompt-item {
padding: 9px 10px;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--line-strong) 78%, rgba(25, 195, 125, 0.12) 22%);
background: color-mix(in srgb, var(--panel-bg) 78%, var(--surface-muted) 22%);
transition: border-color 0.14s ease, box-shadow 0.14s ease, transform 0.14s ease;
}
.prompt-item:hover {
border-color: rgba(25, 195, 125, 0.22);
box-shadow: 0 10px 18px rgba(15, 23, 42, 0.06);
transform: translateY(-1px);
}
.prompt-item.selected {
border-color: rgba(25, 195, 125, 0.35);
box-shadow: 0 0 0 1px rgba(25, 195, 125, 0.12), 0 10px 18px rgba(15, 23, 42, 0.06);
background: rgba(240, 253, 244, 0.9);
}
.prompt-item.expanded {
box-shadow: 0 0 0 1px rgba(25, 195, 125, 0.1), 0 14px 26px rgba(15, 23, 42, 0.07);
}
.prompt-item-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.prompt-head-main {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.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);
}
.prompt-item-head.sticky {
position: sticky;
top: 0;
z-index: 1;
padding-bottom: 8px;
background: color-mix(in srgb, var(--panel-bg) 96%, transparent 4%);
}
.prompt-item-title {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
cursor: pointer;
line-height: 1.4;
}
.prompt-item-meta {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-top: 6px;
}
.prompt-item-actions {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
}
.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;
}
.prompt-item-content {
margin-top: 5px;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.prompt-item-body {
display: none;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(15, 23, 42, 0.06);
}
.prompt-item.expanded .prompt-item-body {
display: block;
}
.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;
}
.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;
}
.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);
}
.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;
}
.icon-action-btn {
width: 32px;
height: 32px;
border: 1px solid var(--line-strong);
border-radius: 10px;
background: color-mix(in srgb, var(--panel-bg) 86%, white 14%);
color: var(--text-primary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 14px;
font-weight: 700;
}
.icon-action-btn:hover {
border-color: rgba(25, 195, 125, 0.28);
color: var(--brand-primary-strong);
}
.icon-action-btn.danger:hover {
border-color: rgba(239, 68, 68, 0.26);
color: #dc2626;
}
.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;
}
.prompt-empty {
padding: 14px 12px;
border-radius: 12px;
border: 1px dashed rgba(15, 23, 42, 0.12);
color: var(--text-secondary);
text-align: center;
font-size: 12px;
background: rgba(255,255,255,0.58);
}
.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));
}
.settings-modal {
width: min(100vw - 20px, 520px);
padding: 10px;
}
.field-row,
.modal-grid,
.choice-row.grid-2,
.switch-pair,
.search-row,
.prompt-workspace {
grid-template-columns: 1fr;
}
.question-row {
padding-right: 8px;
}
}
`;
// [END: CSS样式]
// ============================================================
// ==State== 状态层 - 全局状态管理
// ============================================================
// [START: 全局状态]
// 注意: 使用原有的全局变量作为状态管理
// conversationKey, loaded, activeIndex, refreshScheduled, geminiBootstrapStarted
// [END: 全局状态]
// ============================================================
// ==Utils== 工具层 - 纯函数工具
// ============================================================
// [START: 工具函数]
function detectSite() {
if (location.hostname === 'gemini.google.com') return 'gemini';
return 'chatgpt';
}
function generatePromptId() {
return `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
function getPromptSignature(item) {
return [
sanitizeText(item?.title || '').toLowerCase(),
sanitizeText(item?.group || '').toLowerCase(),
sanitizeText(item?.content || '').toLowerCase()
].join('::');
}
function normalizePromptItem(item) {
if (!item || typeof item.title !== 'string' || typeof item.content !== 'string') return null;
const title = item.title.trim();
const content = item.content.trim();
if (!title || !content) return null;
return {
id: typeof item.id === 'string' && item.id.trim() ? item.id : generatePromptId(),
title,
content,
group: typeof item.group === 'string' ? item.group.trim() : '',
updatedAt: Number.isFinite(Number(item.updatedAt)) ? Number(item.updatedAt) : Date.now()
};
}
function normalizePromptLibraryPayload(data) {
const payload = Array.isArray(data)
? { version: 1, prompts: data }
: (data && typeof data === 'object' ? data : { version: PROMPT_LIBRARY_SCHEMA_VERSION, prompts: [] });
const prompts = Array.isArray(payload.prompts) ? payload.prompts : [];
const normalized = prompts.map(normalizePromptItem).filter(Boolean);
const deduped = [];
const seen = new Set();
normalized.forEach((item) => {
const signature = getPromptSignature(item);
if (seen.has(signature)) return;
seen.add(signature);
deduped.push(item);
});
return {
version: Number.isFinite(Number(payload.version)) ? Number(payload.version) : 1,
prompts: deduped
};
}
function loadPromptLibraryPayload() {
try {
const raw = localStorage.getItem(PROMPTS_STORAGE_KEY);
if (!raw) {
return { version: PROMPT_LIBRARY_SCHEMA_VERSION, prompts: [], migrated: false };
}
const normalized = normalizePromptLibraryPayload(JSON.parse(raw));
const migrated = !raw.includes(`"version":${PROMPT_LIBRARY_SCHEMA_VERSION}`) && !raw.includes(`"version": ${PROMPT_LIBRARY_SCHEMA_VERSION}`);
if (migrated) {
savePromptLibrary(normalized.prompts);
}
return {
version: normalized.version,
prompts: normalized.prompts,
migrated
};
} catch {
return { version: PROMPT_LIBRARY_SCHEMA_VERSION, prompts: [], migrated: false };
}
}
function loadPromptLibrary() {
return loadPromptLibraryPayload().prompts;
}
function savePromptLibrary(data) {
const normalized = normalizePromptLibraryPayload({
version: PROMPT_LIBRARY_SCHEMA_VERSION,
prompts: Array.isArray(data) ? data : []
});
localStorage.setItem(PROMPTS_STORAGE_KEY, JSON.stringify({
version: PROMPT_LIBRARY_SCHEMA_VERSION,
prompts: normalized.prompts
}));
}
function serializePromptLibraryToText(items) {
return (items || []).map(item => [
`### ${item.title}`,
`group: ${item.group || '未标记'}`,
'',
item.content || ''
].join('\n')).join('\n\n---\n\n');
}
function parsePromptLibraryFromText(text) {
return (text || '')
.split(/\n\s*---\s*\n/g)
.map(chunk => chunk.trim())
.filter(Boolean)
.map(chunk => {
const lines = chunk.split('\n');
const titleLine = lines.shift() || '';
const title = titleLine.replace(/^###\s*/, '').trim();
let group = '';
if (lines[0] && /^group\s*:/i.test(lines[0])) {
group = lines.shift().replace(/^group\s*:/i, '').trim();
}
const content = lines.join('\n').trim();
if (!title || !content) return null;
return {
id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
title,
group: group === '未标记' ? '' : group,
content,
updatedAt: Date.now()
};
})
.filter(Boolean);
}
function triggerTextDownload(filename, text, mimeType = 'text/plain;charset=utf-8') {
const blob = new Blob([text], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.rel = 'noopener';
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
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 detectSite() === 'chatgpt' && location.pathname.startsWith('/share/');
}
function getDepthStorageKey() {
return `${STORAGE_PREFIX}_depth_${detectSite()}`;
}
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 queryGeminiRoot() {
return document.querySelector('#chat-history')
|| document.querySelector('[data-test-id="chat-history-container"]')
|| document.querySelector('chat-window-content')
|| document.querySelector('chat-window')
|| document.querySelector('main')
|| null;
}
function queryGeminiConversationContainers() {
const root = queryGeminiRoot();
if (!root) return [];
return Array.from(root.querySelectorAll('.conversation-container'));
}
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 detectSite() === 'gemini' ? 'Gemini' : 'ChatGPT';
}
function getConversationKey() {
return `${detectSite()}::${location.pathname}::${location.search}`;
}
function queryChatGPTContainer() {
return document.querySelector('main')?.querySelector('.flex.flex-col.text-sm') || null;
}
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 getGeminiMessageNodes() {
const root = queryGeminiRoot();
if (!root) return [];
const nodes = Array.from(root.querySelectorAll(`${GEMINI_USER_SELECTOR}, ${GEMINI_ASSISTANT_SELECTOR}`));
const filtered = nodes.filter(node => {
if (!(node instanceof HTMLElement)) return false;
const text = sanitizeText(node.innerText);
if (!text) return false;
const nestedSameType = node.parentElement?.closest(node.matches(GEMINI_USER_SELECTOR) ? GEMINI_USER_SELECTOR : GEMINI_ASSISTANT_SELECTOR);
return !nestedSameType || nestedSameType === node;
});
const unique = [];
filtered.forEach(node => {
if (!unique.some(existing => existing.contains(node) || node.contains(existing))) {
unique.push(node);
}
});
return unique.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 queryGeminiQuestionElements() {
const conversationContainers = queryGeminiConversationContainers();
if (conversationContainers.length) {
return conversationContainers.filter(container => container.querySelector('user-query-content'));
}
const root = queryGeminiRoot();
const contentNodes = root ? Array.from(root.querySelectorAll('user-query-content')) : [];
if (contentNodes.length) {
return contentNodes.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 queryTextNodes = root ? Array.from(root.querySelectorAll('.query-text.gds-body-l, .query-text')) : [];
if (queryTextNodes.length) {
return queryTextNodes.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 getGeminiMessageNodes().filter(node => node.matches(GEMINI_USER_SELECTOR));
}
function queryQuestionElements() {
return detectSite() === 'gemini'
? queryGeminiQuestionElements()
: queryChatGPTQuestionElements();
}
function queryScrollContainer() {
if (detectSite() === 'gemini') {
return document.querySelector('#chat-history')
|| document.querySelector('[data-test-id="chat-history-container"]')
|| document.querySelector('infinite-scroller.chat-history')
|| document.querySelector('chat-window-content')
|| document.querySelector('chat-window')
|| document.querySelector('main')
|| document.scrollingElement
|| document.documentElement;
}
return queryChatGPTContainer()?.parentElement || document.scrollingElement || document.documentElement;
}
function loadReaderConfig() {
try {
const stored = JSON.parse(localStorage.getItem(READER_CONFIG_KEY) || '{}');
return { ...defaultReaderConfig, ...stored };
} catch {
return { ...defaultReaderConfig };
}
}
function saveReaderConfig(config) {
localStorage.setItem(READER_CONFIG_KEY, JSON.stringify(config));
}
function applyReaderConfig(config) {
const root = document.documentElement;
const body = document.body;
if (!root || !body) return;
const site = detectSite();
const theme = themes[config.theme] || themes.yellow;
root.style.setProperty('--w-bg', theme.bg || '');
root.style.setProperty('--w-text', theme.text || '');
root.style.setProperty('--w-accent-bg', theme.accentBg || '');
root.style.setProperty('--w-accent-text', theme.accentText || '');
root.style.setProperty('--w-input-bg', theme.inputBg || '');
root.style.setProperty('--w-sidebar-text', theme.sidebarText || '');
root.style.setProperty('--w-font', fontStacks[config.fontType] || fontStacks.serif);
root.style.setProperty('--w-footer-display', config.hideFooter ? 'none' : 'block');
body.setAttribute('data-public-style', String(config.publicStyle));
body.setAttribute('data-pub-color', config.publicColor);
body.setAttribute('data-pub-type', config.publicType);
body.setAttribute('data-theme', config.theme);
body.setAttribute('data-clean-mode', String(config.cleanMode));
let styleEl = document.getElementById(READER_STYLE_ID);
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = READER_STYLE_ID;
document.head.appendChild(styleEl);
}
const geminiThemeCss = config.theme === 'default' ? '' : `
:root, body, .theme-host, :where(.theme-host) {
--bard-color-synthetic--chat-window-surface: var(--w-bg) !important;
--gem-sys-color--surface: var(--w-bg) !important;
--gem-sys-color--surface-variant: var(--w-bg) !important;
--gem-sys-color--surface-container: var(--w-bg) !important;
--gem-sys-color--surface-container-high: var(--w-bg) !important;
--gem-sys-color--surface-container-low: var(--w-bg) !important;
background-color: var(--w-bg) !important;
color: var(--w-text) !important;
}
main, [role="main"] {
background-color: var(--w-bg) !important;
color: var(--w-text) !important;
}
gemini-app, main, infinite-scroller,
.conversation-container, .response-container, .inner-container,
.scroll-container, .input-area-container, .mat-drawer-container,
mat-sidenav, .mat-drawer, .mat-drawer-inner-container,
.chat-history, .explore-gems-container, conversations-list, bot-list,
.overflow-container, mat-action-list, mat-nav-list,
.conversation-items-container, side-nav-action-button,
bard-sidenav, input-container,
.input-area-container, .bottom-container, .composer-container, .input-wrapper {
background: transparent !important;
background-color: transparent !important;
box-shadow: none !important;
filter: none !important;
}
.input-gradient, input-container.input-gradient {
background: transparent !important;
pointer-events: auto !important;
}
.top-gradient-container, .scroll-container::after, .scroll-container::before {
display: none !important;
}
.input-area {
background-color: var(--w-input-bg) !important;
border: 1px solid rgba(0,0,0,0.08) !important;
box-shadow: 0 4px 12px rgba(0,0,0,0.03) !important;
}
.text-input-field, .ql-editor, .ql-container {
background: transparent !important;
border: none !important;
}
.user-query-bubble-with-background, .user-query-container .query-content {
background-color: var(--w-accent-bg) !important;
color: var(--w-accent-text) !important;
border-radius: 16px !important;
box-shadow: 0 2px 8px rgba(0,0,0,0.04) !important;
border: 1px solid rgba(0,0,0,0.03) !important;
}
code, .code-container, pre {
background-color: var(--w-accent-bg) !important;
color: var(--w-text) !important;
border-radius: 12px !important;
border: 1px solid rgba(0,0,0,0.05) !important;
box-shadow: 0 2px 6px rgba(0,0,0,0.03) !important;
}
`;
const cleanModeCss = config.cleanMode
? (site === 'chatgpt'
? `
nav, aside, header:has(a[href="/"]), [data-testid="left-sidebar"], [data-testid="nav"], [class*="sidebar"], [class*="SideBar"],
[class*="top-bar"], [class*="TopBar"], [class*="composer-toolbar"], [class*="chat-history"], [class*="project-switcher"] {
display: none !important;
}
main#main, #main, #thread, .composer-parent {
margin-left: auto !important;
margin-right: auto !important;
}
`
: `
bard-sidenav, side-navigation, .side-nav, .sidenav-container, .app-header, header, .top-nav, .gmat-mdc-dialog-surface + header {
display: none !important;
}
main, [role="main"] {
margin: 0 auto !important;
}
`)
: '';
styleEl.textContent = `
body, p, li, h1, h2, h3, div, span, button, input, textarea {
font-family: var(--w-font) !important;
}
${site === 'gemini' ? geminiThemeCss : ''}
${cleanModeCss}
main p, .model-response-text p, .markdown p, [data-message-author-role="assistant"] .markdown p, [data-message-author-role="user"] {
font-size: ${config.fontSize}px !important;
line-height: 1.8 !important;
color: var(--w-text) !important;
}
${site === 'gemini'
? `.conversation-container, .response-container, .inner-container, .input-area-container, main [data-message-author-role], main article, main section, main .markdown, main .prose {
max-width: ${config.maxWidth}px !important;
}`
: ''}
hallucination-disclaimer, .hallucination-disclaimer, .footer-container {
display: var(--w-footer-display) !important;
}
body[data-public-style="true"] .model-response-text h1,
body[data-public-style="true"] .model-response-text h2,
body[data-public-style="true"] .markdown h1,
body[data-public-style="true"] .markdown h2 {
border-left: 5px solid var(--w-pub-accent, #fbc02d) !important;
background: linear-gradient(to right, rgba(0,0,0,0.03), transparent) !important;
padding: 10px 15px !important;
border-radius: 0 8px 8px 0 !important;
}
body[data-public-style="true"] .model-response-text strong,
body[data-public-style="true"] .model-response-text b,
body[data-public-style="true"] .markdown strong,
body[data-public-style="true"] .markdown b {
padding: 0 3px !important;
border-radius: 4px !important;
${config.publicType === 'full'
? 'background-color: var(--w-pub-high, rgba(255,235,59,0.6)) !important;'
: 'background: linear-gradient(to bottom, transparent 55%, var(--w-pub-high, rgba(255,235,59,0.6)) 0) !important;'}
}
`;
const searchUsesDarkStyle = config.theme === 'dark';
root.style.setProperty('--search-hit-bg', searchUsesDarkStyle ? '#fde047' : '#fff59d');
root.style.setProperty('--search-hit-text', '#111827');
root.style.setProperty('--search-hit-current-bg', searchUsesDarkStyle ? '#f59e0b' : '#f7c948');
root.style.setProperty('--search-hit-current-text', '#111827');
root.style.setProperty('--search-hit-outline', searchUsesDarkStyle ? 'rgba(250, 204, 21, 0.42)' : 'rgba(161, 98, 7, 0.18)');
root.style.setProperty('--search-hit-shadow', searchUsesDarkStyle ? 'rgba(245, 158, 11, 0.34)' : 'rgba(245, 158, 11, 0.24)');
const pubColors = {
yellow: ['rgba(255, 235, 59, 0.6)', '#fbc02d'],
blue: ['rgba(144, 202, 249, 0.6)', '#1976d2'],
pink: ['rgba(244, 143, 177, 0.6)', '#d81b60'],
green: ['rgba(165, 214, 167, 0.6)', '#388e3c'],
orange: ['rgba(251, 146, 60, 0.5)', '#ea580c'],
purple: ['rgba(167, 139, 250, 0.5)', '#7c3aed'],
red: ['rgba(252, 165, 165, 0.5)', '#dc2626'],
cyan: ['rgba(103, 232, 249, 0.5)', '#0891b2'],
lime: ['rgba(190, 242, 100, 0.55)', '#65a30d'],
rose: ['rgba(253, 164, 175, 0.5)', '#e11d48']
};
const [high, accent] = pubColors[config.publicColor] || pubColors.yellow;
root.style.setProperty('--w-pub-high', high);
root.style.setProperty('--w-pub-accent', accent);
}
// [END: 工具函数]
// ============================================================
// ==Logic== 业务层 - 功能模块
// ============================================================
// [START: NavigatorApp 组件]
class NavigatorApp extends HTMLElement {
// AI对话导航器主组件 - 包含UI渲染、事件处理、数据管理等功能
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._isOpen = true;
this._suppressEyeClick = false;
this._allQuestions = [];
this._allElements = [];
this._allDetails = [];
this._allQuestionIds = [];
this._questions = [];
this._elements = [];
this._details = [];
this._questionIds = [];
this._depth = 0;
this._readerConfig = loadReaderConfig();
this._expandedQuestions = new Set();
this._selectedQuestionIds = new Set();
this._selectedPromptIds = new Set();
this._expandedPromptIds = new Set();
this._stickyPromptIds = new Set();
this._activePromptPane = 'create';
this._activePromptTag = 'all';
this._editingPromptId = null;
this._promptFeedbackTimer = null;
this._searchKeyword = '';
this._searchResults = [];
this._searchIndex = -1;
}
connectedCallback() {
this.render();
this.loadSettings();
this.loadPosition();
applyReaderConfig(this._readerConfig);
this.updateOpenState();
this.startTracking();
this.initDraggable();
}
loadSettings() {
const savedDepth = localStorage.getItem(getDepthStorageKey());
this._depth = savedDepth ? parseInt(savedDepth, 10) : 0;
const input = this.shadowRoot.querySelector('#depth-input');
if (input) input.value = this._depth;
if (this._allQuestions.length > 0) this.applyDepth();
}
saveSettings() {
localStorage.setItem(getDepthStorageKey(), String(this._depth));
}
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);
});
}
saveReaderSettings() {
saveReaderConfig(this._readerConfig);
applyReaderConfig(this._readerConfig);
this.updateReaderUI();
}
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);
}
collectGeminiMessages() {
const conversationContainers = queryGeminiConversationContainers();
if (conversationContainers.length) {
const messages = [];
conversationContainers.forEach(container => {
const userNode = container.querySelector('user-query-content');
const assistantNode = container.querySelector('model-response');
if (userNode) {
const userContent = userNode.querySelector('.query-text, .user-query-bubble-with-background, .query-content') || userNode;
const userMarkdown = this.normalizeMarkdown(this.nodeToMarkdown(userContent));
if (userMarkdown) {
messages.push({
index: messages.length + 1,
role: 'User',
markdown: userMarkdown
});
}
}
if (assistantNode) {
const assistantContent = assistantNode.querySelector('.markdown.markdown-main-panel, message-content .markdown, structured-content-container, .model-response-text, .response-content') || assistantNode;
const assistantMarkdown = this.normalizeMarkdown(this.nodeToMarkdown(assistantContent));
if (assistantMarkdown) {
messages.push({
index: messages.length + 1,
role: 'Gemini',
markdown: assistantMarkdown
});
}
}
});
return messages;
}
const messages = [];
const root = queryGeminiRoot();
const users = root ? Array.from(root.querySelectorAll('user-query-content')) : [];
const assistants = root ? Array.from(root.querySelectorAll('model-response')) : [];
const maxLength = Math.max(users.length, assistants.length);
if (maxLength > 0) {
for (let index = 0; index < maxLength; index += 1) {
const userNode = users[index];
const assistantNode = assistants[index];
if (userNode) {
const userMarkdown = this.normalizeMarkdown(this.nodeToMarkdown(userNode));
if (userMarkdown) {
messages.push({
index: messages.length + 1,
role: 'User',
markdown: userMarkdown
});
}
}
if (assistantNode) {
const assistantContent = assistantNode.querySelector('message-content.model-response-text, .markdown, .model-response-text, .response-container-with-gpi') || assistantNode;
const assistantMarkdown = this.normalizeMarkdown(this.nodeToMarkdown(assistantContent));
if (assistantMarkdown) {
messages.push({
index: messages.length + 1,
role: 'Gemini',
markdown: assistantMarkdown
});
}
}
}
return messages;
}
return getGeminiMessageNodes().map((node, index) => {
const role = node.matches(GEMINI_USER_SELECTOR) ? 'User' : 'Gemini';
const contentRoot = role === 'User'
? (node.querySelector('.query-text, .user-query-bubble-with-background') || node)
: (node.querySelector('message-content.model-response-text, .markdown, .model-response-text, .response-container-with-gpi') || node);
const markdown = this.normalizeMarkdown(this.nodeToMarkdown(contentRoot));
return markdown ? { index: index + 1, role, markdown } : null;
}).filter(Boolean);
}
collectConversationMessages() {
return detectSite() === 'gemini'
? this.collectGeminiMessages()
: 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 };
}
exportToTxt() {
const messages = this.buildSelectedExportMessages();
if (messages.length === 0) return null;
const { title, date } = this.getConversationExportMeta();
const content = [
title,
`导出时间: ${date}`,
'========================',
...messages.flatMap(msg => [
`${msg.index}. ${msg.role}`,
msg.markdown
.replace(/^#+\s*/gm, '')
.replace(/\[(.*?)\]\((.*?)\)/g, '$1 ($2)')
.replace(/[*_`>#-]/g, '')
.trim(),
'------------------------'
])
].join('\n\n');
return { title, content };
}
exportConversation(format = 'md') {
const payload = format === 'txt' ? this.exportToTxt() : this.exportToMarkdown();
if (!payload) {
alert(`未提取到有效的 ${getSiteTitle()} 对话内容,请确认页面已完整加载。`);
return;
}
triggerTextDownload(
`${payload.title}.${format === 'txt' ? 'txt' : 'md'}`,
payload.content,
format === 'txt' ? 'text/plain;charset=utf-8' : 'text/markdown;charset=utf-8'
);
this.exportOverlayEl?.classList.remove('show');
}
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.applyDepth();
// 清理已选问题ID,只保留在新数据中仍然存在的ID(修复导出选中数据Bug)
const validIds = new Set(this._allQuestionIds);
this._selectedQuestionIds.forEach(id => {
if (!validIds.has(id)) {
this._selectedQuestionIds.delete(id);
}
});
}
applyDepth() {
const depth = this._depth;
if (depth > 0) {
this._questions = this._allQuestions.slice(-depth);
this._elements = this._allElements.slice(-depth);
this._details = this._allDetails.slice(-depth);
this._questionIds = this._allQuestionIds.slice(-depth);
} else {
this._questions = this._allQuestions;
this._elements = this._allElements;
this._details = this._allDetails;
this._questionIds = this._allQuestionIds;
}
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 promptBtn = document.createElement('button');
promptBtn.className = 'icon-btn toggle-prompt';
promptBtn.type = 'button';
promptBtn.title = '提示词';
promptBtn.textContent = '📝';
const exportOverlay = document.createElement('div');
exportOverlay.className = 'settings-overlay';
const exportModal = document.createElement('div');
exportModal.className = 'settings-modal';
const exportModalHead = document.createElement('div');
exportModalHead.className = 'modal-head';
const exportModalHeadCopy = document.createElement('div');
exportModalHeadCopy.className = 'modal-head-copy';
const exportModalTitle = document.createElement('div');
exportModalTitle.className = 'modal-title';
exportModalTitle.textContent = '导出格式';
const closeExportModalBtn = document.createElement('button');
closeExportModalBtn.className = 'icon-btn';
closeExportModalBtn.type = 'button';
closeExportModalBtn.title = '关闭导出';
closeExportModalBtn.textContent = '✕';
const exportStack = document.createElement('div');
exportStack.className = 'modal-stack';
const exportCard = createCard('选择格式');
const exportButtonRow = document.createElement('div');
exportButtonRow.className = 'button-row';
const exportMdBtn = document.createElement('button');
exportMdBtn.type = 'button';
exportMdBtn.className = 'primary-btn';
exportMdBtn.textContent = '导出 Markdown';
const exportTxtBtn = document.createElement('button');
exportTxtBtn.type = 'button';
exportTxtBtn.className = 'ghost-btn';
exportTxtBtn.textContent = '导出 TXT';
const settingsBtn = document.createElement('button');
settingsBtn.className = 'icon-btn toggle-settings';
settingsBtn.type = 'button';
settingsBtn.title = '设置';
settingsBtn.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 questionSelectionCount = document.createElement('div');
questionSelectionCount.className = 'editor-badge';
questionSelectionCount.textContent = '已选 0 题';
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(questionSelectionCount, searchStatus);
questionToolbar.append(searchBar, questionToolbarHead, questionToolbarSummary);
const fadeBottom = document.createElement('div');
fadeBottom.className = 'fade-bottom';
const settingsPanel = document.createElement('div');
settingsPanel.className = 'settings-panel';
const settingsLabel = document.createElement('label');
settingsLabel.textContent = '显示最近对话轮数(0 为全部)';
const inputGroup = document.createElement('div');
inputGroup.className = 'input-group';
const depthInput = document.createElement('input');
depthInput.id = 'depth-input';
depthInput.type = 'number';
depthInput.min = '0';
depthInput.step = '1';
depthInput.className = 'number-input';
const actionGroup = document.createElement('div');
actionGroup.className = 'action-group';
const saveBtn = document.createElement('button');
saveBtn.id = 'save-refresh-btn';
saveBtn.type = 'button';
saveBtn.textContent = '保存并刷新';
const overlay = document.createElement('div');
overlay.className = 'settings-overlay';
const promptOverlay = document.createElement('div');
promptOverlay.className = 'settings-overlay';
const modal = document.createElement('div');
modal.className = 'settings-modal';
const promptModal = document.createElement('div');
promptModal.className = 'settings-modal prompt-modal';
const modalHead = document.createElement('div');
modalHead.className = 'modal-head';
const promptModalHead = document.createElement('div');
promptModalHead.className = 'modal-head';
const modalHeadCopy = document.createElement('div');
modalHeadCopy.className = 'modal-head-copy';
const promptModalHeadCopy = document.createElement('div');
promptModalHeadCopy.className = 'modal-head-copy';
const modalTitle = document.createElement('div');
modalTitle.className = 'modal-title';
modalTitle.textContent = '阅读设置';
const promptModalTitle = document.createElement('div');
promptModalTitle.className = 'modal-title';
promptModalTitle.textContent = '提示词';
const closeModalBtn = document.createElement('button');
closeModalBtn.className = 'icon-btn';
closeModalBtn.type = 'button';
closeModalBtn.title = '关闭设置';
closeModalBtn.textContent = '✕';
const closePromptModalBtn = document.createElement('button');
closePromptModalBtn.className = 'icon-btn';
closePromptModalBtn.type = 'button';
closePromptModalBtn.title = '关闭提示词';
closePromptModalBtn.textContent = '✕';
const modalGrid = document.createElement('div');
modalGrid.className = 'modal-grid';
const promptWorkspace = document.createElement('div');
promptWorkspace.className = 'prompt-workspace';
const promptPaneNav = document.createElement('div');
promptPaneNav.className = 'prompt-pane-nav';
const promptCreatePaneBtn = document.createElement('button');
promptCreatePaneBtn.type = 'button';
promptCreatePaneBtn.className = 'prompt-pane-btn active';
promptCreatePaneBtn.dataset.pane = 'create';
const promptCreatePaneTitle = document.createElement('strong');
promptCreatePaneTitle.textContent = '新增提示词';
const promptCreatePaneDesc = document.createElement('span');
promptCreatePaneDesc.textContent = '新建或覆盖保存提示词';
promptCreatePaneBtn.append(promptCreatePaneTitle, promptCreatePaneDesc);
const promptBrowsePaneBtn = document.createElement('button');
promptBrowsePaneBtn.type = 'button';
promptBrowsePaneBtn.className = 'prompt-pane-btn';
promptBrowsePaneBtn.dataset.pane = 'browse';
const promptBrowsePaneTitle = document.createElement('strong');
promptBrowsePaneTitle.textContent = '已保存提示词';
const promptBrowsePaneDesc = document.createElement('span');
promptBrowsePaneDesc.textContent = '搜索、展开、复制和管理';
promptBrowsePaneBtn.append(promptBrowsePaneTitle, promptBrowsePaneDesc);
const promptPaneContent = document.createElement('div');
promptPaneContent.className = 'prompt-pane-content';
const promptCreatePane = document.createElement('div');
promptCreatePane.className = 'prompt-pane active';
promptCreatePane.dataset.pane = 'create';
const promptBrowsePane = document.createElement('div');
promptBrowsePane.className = 'prompt-pane prompt-browse-pane';
promptBrowsePane.dataset.pane = 'browse';
function createCard(labelText) {
const card = document.createElement('div');
card.className = 'setting-card';
const label = document.createElement('div');
label.className = 'setting-title';
label.textContent = labelText;
card.appendChild(label);
return { card, label };
}
const promptFormCard = createCard('新增提示词');
const promptTitleField = document.createElement('div');
promptTitleField.className = 'field-block';
const promptTitleLabel = document.createElement('label');
promptTitleLabel.className = 'field-label';
promptTitleLabel.textContent = '提示词标题';
const promptTitleInput = document.createElement('input');
promptTitleInput.type = 'text';
promptTitleInput.className = 'text-input';
promptTitleInput.placeholder = '例如:论文润色';
promptTitleField.append(promptTitleLabel, promptTitleInput);
const promptGroupField = document.createElement('div');
promptGroupField.className = 'field-block';
const promptGroupLabel = document.createElement('label');
promptGroupLabel.className = 'field-label';
promptGroupLabel.textContent = '提示词标签';
const promptGroupInput = document.createElement('input');
promptGroupInput.type = 'text';
promptGroupInput.className = 'text-input';
promptGroupInput.placeholder = '例如:写作';
promptGroupInput.setAttribute('list', 'prompt-tag-options');
const promptTagOptions = document.createElement('datalist');
promptTagOptions.id = 'prompt-tag-options';
promptGroupField.append(promptGroupLabel, promptGroupInput);
const promptContentField = document.createElement('div');
promptContentField.className = 'field-block';
const promptContentLabel = document.createElement('label');
promptContentLabel.className = 'field-label';
promptContentLabel.textContent = '提示词内容';
const promptContentInput = document.createElement('textarea');
promptContentInput.className = 'text-area';
promptContentInput.placeholder = '输入要保存的提示词内容';
promptContentField.append(promptContentLabel, promptContentInput);
const promptStatusLine = document.createElement('div');
promptStatusLine.className = 'status-line';
promptStatusLine.textContent = '支持新增、覆盖保存和导入自动去重。';
const promptEditorMeta = document.createElement('div');
promptEditorMeta.className = 'editor-meta';
const promptEditorBadge = document.createElement('div');
promptEditorBadge.className = 'editor-badge';
promptEditorBadge.textContent = '当前模式:新增';
const promptCancelEditBtn = document.createElement('button');
promptCancelEditBtn.type = 'button';
promptCancelEditBtn.className = 'ghost-btn';
promptCancelEditBtn.textContent = '取消编辑';
promptCancelEditBtn.style.display = 'none';
const promptSaveBtn = document.createElement('button');
promptSaveBtn.type = 'button';
promptSaveBtn.className = 'primary-btn';
promptSaveBtn.textContent = '新增并保存';
const promptClearBtn = document.createElement('button');
promptClearBtn.type = 'button';
promptClearBtn.className = 'ghost-btn';
promptClearBtn.textContent = '一键清空';
const promptActionRow = document.createElement('div');
promptActionRow.className = 'button-row';
const promptTransferOverlay = document.createElement('div');
promptTransferOverlay.className = 'settings-overlay';
const promptTransferModal = document.createElement('div');
promptTransferModal.className = 'settings-modal prompt-modal';
const promptTransferHead = document.createElement('div');
promptTransferHead.className = 'modal-head';
const promptTransferHeadCopy = document.createElement('div');
promptTransferHeadCopy.className = 'modal-head-copy';
const promptTransferTitle = document.createElement('div');
promptTransferTitle.className = 'modal-title';
promptTransferTitle.textContent = '提示词导入导出';
const closePromptTransferBtn = document.createElement('button');
closePromptTransferBtn.className = 'icon-btn';
closePromptTransferBtn.type = 'button';
closePromptTransferBtn.title = '关闭导入导出';
closePromptTransferBtn.textContent = '✕';
const promptSearchWrap = document.createElement('div');
promptSearchWrap.className = 'search-row';
const promptSearchField = document.createElement('div');
promptSearchField.className = 'field-block';
const promptSearchLabel = document.createElement('label');
promptSearchLabel.className = 'field-label';
promptSearchLabel.textContent = '搜索提示词';
const promptSearchInput = document.createElement('input');
promptSearchInput.type = 'text';
promptSearchInput.className = 'text-input';
promptSearchInput.placeholder = '按标题、标签或内容搜索';
promptSearchField.append(promptSearchLabel, promptSearchInput);
const promptExportFileBtn = document.createElement('button');
promptExportFileBtn.type = 'button';
promptExportFileBtn.className = 'icon-mini-btn';
promptExportFileBtn.title = '导出提示词(未选则导出全部)';
promptExportFileBtn.textContent = '📥';
const promptImportFileBtn = document.createElement('button');
promptImportFileBtn.type = 'button';
promptImportFileBtn.className = 'icon-mini-btn';
promptImportFileBtn.title = '导入提示词文件';
promptImportFileBtn.textContent = '📤';
const promptFileInput = document.createElement('input');
promptFileInput.type = 'file';
promptFileInput.accept = '.json';
promptFileInput.className = 'sr-file';
const promptTransferField = document.createElement('div');
promptTransferField.className = 'drop-zone';
promptTransferField.textContent = '将提示词导出文件拖到这里导入,或点击下方按钮选择文件';
const promptTransferActions = document.createElement('div');
promptTransferActions.className = 'button-row';
const promptTransferHint = document.createElement('div');
promptTransferHint.className = 'helper-text';
promptTransferHint.textContent = '导入 JSON 文件,支持旧版数组和 { version, prompts } 格式。';
const promptChooseFileBtn = document.createElement('button');
promptChooseFileBtn.type = 'button';
promptChooseFileBtn.className = 'primary-btn';
promptChooseFileBtn.textContent = '选择文件';
const promptListCard = createCard('已保存提示词');
promptListCard.card.classList.add('prompt-list-card');
const promptListTools = document.createElement('div');
promptListTools.className = 'modal-stack prompt-list-tools';
const promptToolbar = document.createElement('div');
promptToolbar.className = 'selection-row';
const promptSelectionCount = document.createElement('div');
promptSelectionCount.className = 'editor-badge';
promptSelectionCount.textContent = '已选 0 条';
const promptSelectAll = document.createElement('label');
promptSelectAll.className = 'master-check checkbox-only';
promptSelectAll.title = '全选';
promptSelectAll.setAttribute('aria-label', '全选');
const promptSelectAllInput = document.createElement('input');
promptSelectAllInput.type = 'checkbox';
promptSelectAllInput.className = 'select-check';
const promptSelectAllText = document.createElement('span');
promptSelectAllText.textContent = '全选';
promptSelectAll.append(promptSelectAllInput, promptSelectAllText);
const promptClearSelectionBtn = document.createElement('button');
promptClearSelectionBtn.type = 'button';
promptClearSelectionBtn.className = 'compact-btn';
promptClearSelectionBtn.textContent = '取消选择';
const promptBatchDeleteBtn = document.createElement('button');
promptBatchDeleteBtn.type = 'button';
promptBatchDeleteBtn.className = 'mini-btn danger';
promptBatchDeleteBtn.textContent = '批量删除';
const promptListHint = document.createElement('div');
promptListHint.className = 'helper-text';
promptListHint.textContent = '未选择时默认导出全部。';
const promptTabBar = document.createElement('div');
promptTabBar.className = 'prompt-tab-bar';
const promptList = document.createElement('div');
promptList.className = 'prompt-list';
const cleanCard = createCard('净化阅读');
const cleanButton = document.createElement('button');
cleanButton.type = 'button';
cleanButton.className = 'ghost-btn';
const themeCard = createCard('背景主题');
themeCard.card.classList.add('span-2');
const themeChoices = document.createElement('div');
themeChoices.className = 'setting-field';
const fontSizeCard = createCard('字体大小');
const fontSizeRow = document.createElement('div');
fontSizeRow.className = 'setting-inline';
const fontSizeUnit = document.createElement('span');
fontSizeUnit.textContent = 'px';
const fontSizeInput = document.createElement('input');
fontSizeInput.type = 'number';
fontSizeInput.className = 'number-input';
const widthCard = createCard('阅读宽度');
const widthRow = document.createElement('div');
widthRow.className = 'setting-inline';
const widthUnit = document.createElement('span');
widthUnit.textContent = 'px';
const widthInput = document.createElement('input');
widthInput.type = 'number';
widthInput.className = 'number-input';
const fontCard = createCard('字体风格');
const fontChoices = document.createElement('div');
fontChoices.className = 'setting-field';
const depthCard = createCard('显示对话轮数');
const depthRow = document.createElement('div');
depthRow.className = 'setting-inline';
const switchCard = createCard('开关选项');
switchCard.card.classList.add('span-2');
const switchPair = document.createElement('div');
switchPair.className = 'switch-pair';
const footerRow = document.createElement('div');
footerRow.className = 'switch-item';
const footerTitle = document.createElement('span');
footerTitle.textContent = '隐藏底部免责声明';
const footerCheck = document.createElement('input');
footerCheck.type = 'checkbox';
const publicRow = document.createElement('div');
publicRow.className = 'switch-item';
const publicTitle = document.createElement('span');
publicTitle.textContent = '公众号排版风格';
const publicCheck = document.createElement('input');
publicCheck.type = 'checkbox';
const publicStyleCard = createCard('公众号高亮样式');
publicStyleCard.card.classList.add('span-2');
const publicStyleRow = document.createElement('div');
publicStyleRow.className = 'style-row';
const styleDots = document.createElement('div');
styleDots.className = 'style-dots';
const typeSwitch = document.createElement('div');
typeSwitch.className = 'type-switch';
const layoutStack = document.createElement('div');
layoutStack.className = 'setting-stack';
const rowFonts = document.createElement('div');
rowFonts.className = 'setting-row-2';
const rowWidthDepth = document.createElement('div');
rowWidthDepth.className = 'setting-row-2';
const rowModeClean = document.createElement('div');
rowModeClean.className = 'setting-stack';
titleBlock.append(title, subtitle);
listContainer.append(questionToolbar, listEl, fadeBottom);
inputGroup.appendChild(depthInput);
actionGroup.appendChild(saveBtn);
settingsPanel.append(settingsLabel, inputGroup, actionGroup);
content.append(listContainer, settingsPanel);
fontSizeRow.append(fontSizeInput, fontSizeUnit);
widthRow.append(widthInput, widthUnit);
depthRow.append(depthInput, saveBtn);
footerRow.append(footerTitle, footerCheck);
publicRow.append(publicTitle, publicCheck);
switchPair.append(footerRow, publicRow);
themeCard.card.appendChild(themeChoices);
fontSizeCard.card.appendChild(fontSizeRow);
widthCard.card.appendChild(widthRow);
fontCard.card.appendChild(fontChoices);
depthCard.card.appendChild(depthRow);
switchCard.card.appendChild(switchPair);
publicStyleCard.card.append(publicStyleRow);
publicStyleRow.append(styleDots, typeSwitch);
promptGroupField.appendChild(promptTagOptions);
promptActionRow.append(promptSaveBtn, promptClearBtn);
promptEditorMeta.append(promptEditorBadge, promptCancelEditBtn);
promptFormCard.card.append(promptTitleField, promptGroupField, promptContentField, promptStatusLine, promptEditorMeta, promptActionRow);
promptTransferActions.append(promptChooseFileBtn);
promptBatchDeleteBtn.className = 'compact-btn danger-btn';
promptToolbar.append(promptSelectAll, promptClearSelectionBtn, promptBatchDeleteBtn, promptSelectionCount);
promptSearchWrap.append(promptSearchField, promptExportFileBtn, promptImportFileBtn);
promptListTools.append(promptSearchWrap, promptTabBar, promptToolbar, promptListHint);
promptListCard.card.append(promptListTools, promptList);
promptCreatePane.append(promptFormCard.card);
promptBrowsePane.append(promptListCard.card);
promptPaneNav.append(promptCreatePaneBtn, promptBrowsePaneBtn);
promptPaneContent.append(promptCreatePane, promptBrowsePane);
promptWorkspace.append(promptPaneNav, promptPaneContent);
headerActions.append(exportBtn, promptBtn, settingsBtn, eyeBtn);
modalHeadCopy.append(modalTitle);
modalHead.append(modalHeadCopy, closeModalBtn);
promptModalHeadCopy.append(promptModalTitle);
promptModalHead.append(promptModalHeadCopy, closePromptModalBtn);
cleanCard.card.append(cleanButton);
rowFonts.append(fontSizeCard.card, fontCard.card);
rowWidthDepth.append(widthCard.card, depthCard.card);
cleanCard.card.classList.add('span-2');
rowModeClean.append(cleanCard.card);
layoutStack.append(themeCard.card, rowFonts, rowWidthDepth, rowModeClean, switchCard.card, publicStyleCard.card);
modalGrid.append(layoutStack);
modal.append(modalHead, modalGrid);
exportButtonRow.append(exportMdBtn, exportTxtBtn);
exportCard.card.append(exportButtonRow);
exportStack.append(exportCard.card);
exportModalHeadCopy.append(exportModalTitle);
exportModalHead.append(exportModalHeadCopy, closeExportModalBtn);
exportModal.append(exportModalHead, exportStack);
promptTransferHeadCopy.append(promptTransferTitle);
promptTransferHead.append(promptTransferHeadCopy, closePromptTransferBtn);
promptTransferModal.append(promptTransferHead, promptTransferField, promptTransferHint, promptTransferActions, promptFileInput);
exportOverlay.appendChild(exportModal);
promptModal.append(promptModalHead, promptWorkspace);
overlay.appendChild(modal);
promptOverlay.appendChild(promptModal);
promptTransferOverlay.appendChild(promptTransferModal);
header.append(titleBlock, headerActions);
panel.append(header, content);
shell.append(panel);
container.appendChild(shell);
container.appendChild(exportOverlay);
container.appendChild(overlay);
container.appendChild(promptOverlay);
container.appendChild(promptTransferOverlay);
this.exportOverlayEl = exportOverlay;
this.overlayEl = overlay;
this.promptOverlayEl = promptOverlay;
this.promptTransferOverlayEl = promptTransferOverlay;
this.depthInputEl = depthInput;
this.fontSizeInputEl = fontSizeInput;
this.widthInputEl = widthInput;
this.footerCheckEl = footerCheck;
this.publicCheckEl = publicCheck;
this.themeChoicesEl = themeChoices;
this.fontChoicesEl = fontChoices;
this.styleDotsEl = styleDots;
this.typeSwitchEl = typeSwitch;
this.publicStyleCardEl = publicStyleCard.card;
this.eyeBtnEl = eyeBtn;
this.promptTitleInputEl = promptTitleInput;
this.promptGroupInputEl = promptGroupInput;
this.promptTagOptionsEl = promptTagOptions;
this.promptContentInputEl = promptContentInput;
this.promptStatusLineEl = promptStatusLine;
this.promptEditorBadgeEl = promptEditorBadge;
this.promptCancelEditBtnEl = promptCancelEditBtn;
this.promptSearchInputEl = promptSearchInput;
this.promptFileInputEl = promptFileInput;
this.promptListEl = promptList;
this.promptTabBarEl = promptTabBar;
this.promptSelectionCountEl = promptSelectionCount;
this.promptListHintEl = promptListHint;
this.promptSelectAllInputEl = promptSelectAllInput;
this.cleanButtonEl = cleanButton;
this.promptSaveBtnEl = promptSaveBtn;
this.promptCreatePaneBtnEl = promptCreatePaneBtn;
this.promptBrowsePaneBtnEl = promptBrowsePaneBtn;
this.promptCreatePaneEl = promptCreatePane;
this.promptBrowsePaneEl = promptBrowsePane;
this.searchInputEl = searchInput;
this.searchStatusEl = searchStatus;
this.questionSelectAllEl = questionSelectAllInput;
this.questionSelectionCountEl = questionSelectionCount;
eyeBtn.onclick = (event) => {
if (this._suppressEyeClick) {
event.stopPropagation();
return;
}
if (this._isOpen) {
this.anchorToRight();
}
this._isOpen = !this._isOpen;
if (!this._isOpen) {
settingsPanel.classList.remove('show');
exportOverlay.classList.remove('show');
overlay.classList.remove('show');
promptOverlay.classList.remove('show');
promptTransferOverlay.classList.remove('show');
}
this.updateOpenState();
event.stopPropagation();
};
depthInput.oninput = (event) => {
const val = parseInt(event.target.value, 10);
if (!Number.isNaN(val) && val >= 0) {
this._depth = val;
}
};
saveBtn.onclick = () => {
this.saveSettings();
location.reload();
};
exportBtn.onclick = (event) => {
exportOverlay.classList.add('show');
event.stopPropagation();
};
promptBtn.onclick = (event) => {
promptOverlay.classList.add('show');
this.renderPromptLibrary();
event.stopPropagation();
};
settingsBtn.onclick = (event) => {
overlay.classList.add('show');
event.stopPropagation();
};
closeExportModalBtn.onclick = () => exportOverlay.classList.remove('show');
closeModalBtn.onclick = () => overlay.classList.remove('show');
closePromptModalBtn.onclick = () => promptOverlay.classList.remove('show');
closePromptTransferBtn.onclick = () => promptTransferOverlay.classList.remove('show');
exportOverlay.onclick = (event) => {
if (event.target === exportOverlay) exportOverlay.classList.remove('show');
};
overlay.onclick = (event) => {
if (event.target === overlay) overlay.classList.remove('show');
};
promptOverlay.onclick = (event) => {
if (event.target === promptOverlay) promptOverlay.classList.remove('show');
};
promptTransferOverlay.onclick = (event) => {
if (event.target === promptTransferOverlay) promptTransferOverlay.classList.remove('show');
};
promptCreatePaneBtn.onclick = () => this.switchPromptPane('create');
promptBrowsePaneBtn.onclick = () => this.switchPromptPane('browse');
exportMdBtn.onclick = () => this.exportConversation('md');
exportTxtBtn.onclick = () => this.exportConversation('txt');
promptSaveBtn.onclick = () => {
this.savePromptEntry();
};
promptClearBtn.onclick = () => this.clearPromptEditor();
promptCancelEditBtn.onclick = () => this.clearPromptEditor();
promptSearchInput.oninput = () => this.renderPromptLibrary();
promptExportFileBtn.onclick = () => this.exportPromptLibraryFile();
promptImportFileBtn.onclick = () => promptTransferOverlay.classList.add('show');
promptChooseFileBtn.onclick = () => promptFileInput.click();
promptSelectAllInput.onchange = () => this.setVisiblePromptSelection(promptSelectAllInput.checked);
promptClearSelectionBtn.onclick = () => this.clearPromptSelection();
promptBatchDeleteBtn.onclick = () => this.removeSelectedPromptEntries();
promptFileInput.onchange = (event) => this.importPromptLibraryFile(event.target.files?.[0]);
promptTransferField.ondragover = (event) => {
event.preventDefault();
promptTransferField.classList.add('dragover');
};
promptTransferField.ondragleave = () => promptTransferField.classList.remove('dragover');
promptTransferField.ondrop = (event) => {
event.preventDefault();
promptTransferField.classList.remove('dragover');
this.importPromptLibraryFile(event.dataTransfer?.files?.[0]);
};
cleanButton.onclick = () => {
this._readerConfig.cleanMode = !this._readerConfig.cleanMode;
this.saveReaderSettings();
};
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.buildReaderControls();
this.updateReaderUI();
this.renderPromptLibrary();
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.questionSelectionCountEl) {
this.questionSelectionCountEl.textContent = `已选 ${this._selectedQuestionIds.size} 题`;
}
if (this._questions.length === 0) {
const placeholder = detectSite() === 'gemini'
? 'Gemini 对话加载中...'
: '暂未识别到问题';
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();
}
buildReaderControls() {
const themeOptions = [
['default', '默认 · 跟随页面'],
['white', '白纸 · 纯白背景'],
['yellow', '米黄 · 护眼暖色'],
['green', '青绿 · 柔和阅读'],
['sepia', '复古 · 偏暖纸感'],
['gray', '浅灰 · 低对比'],
['dark', '暗夜 · 深色阅读']
];
const fontOptions = [
['yahei', '微软雅黑'],
['songti', '宋体'],
['heiti', '黑体'],
['kaiti', '楷体'],
['fangsong', '仿宋'],
['rounded', '圆体'],
['lishu', '隶书'],
['youyuan', '幼圆'],
['mono', '等宽']
];
const styleColors = [
['yellow', '#fdd835'],
['blue', '#64b5f6'],
['pink', '#f06292'],
['green', '#81c784'],
['orange', '#fb923c'],
['purple', '#a78bfa'],
['red', '#f87171'],
['cyan', '#22d3ee'],
['lime', '#a3e635'],
['rose', '#fb7185']
];
const pubTypes = [
['half', '半高亮'],
['full', '全高亮']
];
this.themeChoicesEl.replaceChildren();
this.fontChoicesEl.replaceChildren();
this.styleDotsEl.replaceChildren();
this.typeSwitchEl.replaceChildren();
const themeSelect = document.createElement('select');
themeSelect.className = 'field-select';
themeOptions.forEach(([value, label]) => {
const option = document.createElement('option');
option.value = value;
option.textContent = label;
themeSelect.appendChild(option);
});
themeSelect.onchange = () => {
this._readerConfig.theme = themeSelect.value;
this.saveReaderSettings();
};
this.themeChoicesEl.appendChild(themeSelect);
this.themeSelectEl = themeSelect;
const fontSelect = document.createElement('select');
fontSelect.className = 'field-select';
fontOptions.forEach(([value, label]) => {
const option = document.createElement('option');
option.value = value;
option.textContent = label;
fontSelect.appendChild(option);
});
fontSelect.onchange = () => {
this._readerConfig.fontType = fontSelect.value;
this.saveReaderSettings();
};
this.fontChoicesEl.appendChild(fontSelect);
this.fontSelectEl = fontSelect;
styleColors.forEach(([value, color]) => {
const dot = document.createElement('button');
dot.type = 'button';
dot.className = 'style-dot';
dot.dataset.value = value;
dot.style.backgroundColor = color;
dot.title = value;
dot.onclick = () => {
this._readerConfig.publicColor = value;
this.saveReaderSettings();
};
this.styleDotsEl.appendChild(dot);
});
pubTypes.forEach(([value, label]) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'type-btn';
btn.dataset.value = value;
btn.textContent = label;
btn.onclick = () => {
this._readerConfig.publicType = value;
this.saveReaderSettings();
};
this.typeSwitchEl.appendChild(btn);
});
this.fontSizeInputEl.oninput = () => {
const value = parseInt(this.fontSizeInputEl.value, 10);
if (!Number.isNaN(value) && value > 0) {
this._readerConfig.fontSize = value;
this.saveReaderSettings();
}
};
this.widthInputEl.oninput = () => {
const value = parseInt(this.widthInputEl.value, 10);
if (!Number.isNaN(value) && value > 0) {
this._readerConfig.maxWidth = value;
this.saveReaderSettings();
}
};
this.depthInputEl.oninput = (event) => {
const val = parseInt(event.target.value, 10);
if (!Number.isNaN(val) && val >= 0) {
this._depth = val;
this.updateReaderUI();
}
};
this.footerCheckEl.onchange = () => {
this._readerConfig.hideFooter = this.footerCheckEl.checked;
this.saveReaderSettings();
};
this.publicCheckEl.onchange = () => {
this._readerConfig.publicStyle = this.publicCheckEl.checked;
this.saveReaderSettings();
};
this.updateReaderUI();
}
updateReaderUI() {
if (!this.themeChoicesEl) return;
if (this.themeSelectEl) this.themeSelectEl.value = this._readerConfig.theme;
if (this.fontSelectEl) this.fontSelectEl.value = this._readerConfig.fontType;
this.styleDotsEl.querySelectorAll('.style-dot').forEach((btn) => btn.classList.toggle('active', btn.dataset.value === this._readerConfig.publicColor));
this.typeSwitchEl.querySelectorAll('.type-btn').forEach((btn) => btn.classList.toggle('active', btn.dataset.value === this._readerConfig.publicType));
this.fontSizeInputEl.value = String(this._readerConfig.fontSize);
this.widthInputEl.value = String(this._readerConfig.maxWidth);
this.depthInputEl.value = String(this._depth);
this.footerCheckEl.checked = !!this._readerConfig.hideFooter;
this.publicCheckEl.checked = !!this._readerConfig.publicStyle;
this.publicStyleCardEl.style.display = this._readerConfig.publicStyle ? 'block' : 'none';
if (this.cleanButtonEl) {
this.cleanButtonEl.textContent = this._readerConfig.cleanMode ? '关闭净化阅读' : '开启净化阅读';
}
}
updatePromptTagOptions() {
if (!this.promptTagOptionsEl) return;
this.promptTagOptionsEl.replaceChildren();
const tags = Array.from(new Set(loadPromptLibrary().map(item => (item.group || '').trim()).filter(Boolean))).sort();
tags.forEach((tag) => {
const option = document.createElement('option');
option.value = tag;
this.promptTagOptionsEl.appendChild(option);
});
}
getVisiblePromptEntries() {
const prompts = loadPromptLibrary();
const activeTag = this._activePromptTag || 'all';
const keyword = (this.promptSearchInputEl?.value || '').trim().toLowerCase();
const scopedPrompts = activeTag === 'all'
? prompts
: prompts.filter((item) => (item.group || '未标记') === activeTag);
return !keyword
? scopedPrompts
: scopedPrompts.filter(item => [item.title, item.group, item.content].join('\n').toLowerCase().includes(keyword));
}
getPromptTabItems(prompts = loadPromptLibrary()) {
const tags = Array.from(new Set(prompts.map(item => (item.group || '').trim() || '未标记'))).sort((a, b) => {
if (a === '未标记') return -1;
if (b === '未标记') return 1;
return a.localeCompare(b, 'zh-Hans-CN');
});
return ['all', ...tags];
}
renderPromptTabs(prompts = loadPromptLibrary()) {
if (!this.promptTabBarEl) return;
const tabs = this.getPromptTabItems(prompts);
if (!tabs.includes(this._activePromptTag)) {
this._activePromptTag = 'all';
}
this.promptTabBarEl.replaceChildren();
tabs.forEach((tabValue) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'prompt-tab-btn';
btn.textContent = tabValue === 'all' ? '全部' : tabValue;
btn.classList.toggle('active', tabValue === this._activePromptTag);
btn.onclick = () => {
this._activePromptTag = tabValue;
this.renderPromptLibrary();
};
this.promptTabBarEl.appendChild(btn);
});
}
prunePromptSelection(prompts = loadPromptLibrary()) {
const validIds = new Set(prompts.map(item => item.id));
Array.from(this._selectedPromptIds).forEach((id) => {
if (!validIds.has(id)) this._selectedPromptIds.delete(id);
});
}
setPromptFeedback(message, tone = 'default', persist = false) {
if (!this.promptStatusLineEl) return;
this.promptStatusLineEl.textContent = message;
if (tone === 'default') this.promptStatusLineEl.removeAttribute('data-tone');
else this.promptStatusLineEl.setAttribute('data-tone', tone);
if (this._promptFeedbackTimer) {
clearTimeout(this._promptFeedbackTimer);
this._promptFeedbackTimer = null;
}
if (!persist) {
this._promptFeedbackTimer = setTimeout(() => {
this.setPromptFeedback(this._editingPromptId ? '正在编辑提示词,可直接覆盖保存。' : '支持新增、覆盖保存和导入自动去重。', 'default', true);
}, 2400);
}
}
updatePromptEditorState() {
if (this.promptEditorBadgeEl) {
this.promptEditorBadgeEl.textContent = this._editingPromptId ? '当前模式:编辑' : '当前模式:新增';
}
if (this.promptSaveBtnEl) {
this.promptSaveBtnEl.textContent = this._editingPromptId ? '覆盖保存' : '新增保存';
}
if (this.promptCancelEditBtnEl) {
this.promptCancelEditBtnEl.style.display = this._editingPromptId ? '' : 'none';
}
}
updatePromptSelectionUI(visiblePrompts = this.getVisiblePromptEntries()) {
if (this.promptSelectionCountEl) {
this.promptSelectionCountEl.textContent = `已选 ${this._selectedPromptIds.size} 条`;
}
if (this.promptSelectAllInputEl) {
this.promptSelectAllInputEl.checked = !!visiblePrompts.length && visiblePrompts.every((item) => this._selectedPromptIds.has(item.id));
}
if (this.promptListHintEl) {
this.promptListHintEl.textContent = visiblePrompts.length
? `当前 ${visiblePrompts.length} 条,可全选。`
: '没有匹配结果。';
}
}
updatePromptPaneUI() {
const isCreate = this._activePromptPane === 'create';
this.promptCreatePaneBtnEl?.classList.toggle('active', isCreate);
this.promptBrowsePaneBtnEl?.classList.toggle('active', !isCreate);
this.promptCreatePaneEl?.classList.toggle('active', isCreate);
this.promptBrowsePaneEl?.classList.toggle('active', !isCreate);
}
switchPromptPane(pane) {
this._activePromptPane = pane === 'browse' ? 'browse' : 'create';
this.updatePromptPaneUI();
}
togglePromptExpanded(id) {
if (!id) return;
if (this._expandedPromptIds.has(id)) {
this._expandedPromptIds.delete(id);
this._stickyPromptIds.delete(id);
} else {
this._expandedPromptIds.add(id);
}
this.renderPromptLibrary();
}
togglePromptSticky(id) {
if (!id) return;
if (!this._expandedPromptIds.has(id)) {
this._expandedPromptIds.add(id);
}
if (this._stickyPromptIds.has(id)) {
this._stickyPromptIds.delete(id);
} else {
this._stickyPromptIds.add(id);
}
this.renderPromptLibrary();
}
renderPromptLibrary() {
if (!this.promptListEl) return;
this.updatePromptTagOptions();
const prompts = loadPromptLibrary();
this.renderPromptTabs(prompts);
this.prunePromptSelection(prompts);
const validIds = new Set(prompts.map(item => item.id));
Array.from(this._expandedPromptIds).forEach((id) => {
if (!validIds.has(id)) this._expandedPromptIds.delete(id);
});
Array.from(this._stickyPromptIds).forEach((id) => {
if (!validIds.has(id)) this._stickyPromptIds.delete(id);
});
const visiblePrompts = this.getVisiblePromptEntries();
this.promptListEl.replaceChildren();
this.updatePromptEditorState();
this.updatePromptSelectionUI(visiblePrompts);
this.updatePromptPaneUI();
if (!visiblePrompts.length) {
const empty = document.createElement('div');
empty.className = 'prompt-empty';
empty.textContent = '没有可显示的提示词。';
this.promptListEl.appendChild(empty);
return;
}
visiblePrompts.slice().reverse().forEach((item) => {
const card = document.createElement('div');
card.className = 'prompt-item';
card.dataset.id = item.id;
const head = document.createElement('div');
head.className = 'prompt-item-head';
const expanded = this._expandedPromptIds.has(item.id);
const sticky = this._stickyPromptIds.has(item.id);
if (expanded) card.classList.add('expanded');
if (sticky) head.classList.add('sticky');
const title = document.createElement('div');
title.className = 'prompt-item-title';
title.textContent = item.title;
title.onclick = (event) => {
event.stopPropagation();
this.togglePromptExpanded(item.id);
};
const headMain = document.createElement('div');
headMain.className = 'prompt-head-main';
const selectCheck = document.createElement('input');
selectCheck.type = 'checkbox';
selectCheck.className = 'select-check';
selectCheck.checked = this._selectedPromptIds.has(item.id);
selectCheck.onclick = (event) => {
event.stopPropagation();
this.togglePromptSelection(item.id);
};
headMain.append(selectCheck, title);
const actions = document.createElement('div');
actions.className = 'prompt-item-actions';
const copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'icon-action-btn';
copyBtn.title = '复制';
copyBtn.textContent = '📋';
copyBtn.onclick = async (event) => {
event.stopPropagation();
await this.copyPromptEntry(item, copyBtn);
};
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'icon-action-btn danger';
deleteBtn.title = '删除';
deleteBtn.textContent = '🗑️';
deleteBtn.onclick = (event) => {
event.stopPropagation();
this.removePromptEntry(item.id);
};
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'icon-action-btn';
editBtn.title = '编辑';
editBtn.textContent = '✏️';
editBtn.onclick = (event) => {
event.stopPropagation();
this.fillPromptEntry(item);
this.switchPromptPane('create');
};
const tag = document.createElement('div');
tag.className = 'tag-chip';
tag.textContent = item.group || '未标记';
const meta = document.createElement('div');
meta.className = 'prompt-item-meta';
const body = document.createElement('div');
body.className = 'prompt-item-body';
const content = document.createElement('div');
content.className = 'prompt-item-content';
content.textContent = item.content;
const stickyBtn = document.createElement('button');
stickyBtn.type = 'button';
stickyBtn.className = 'mini-btn';
stickyBtn.textContent = sticky ? '取消固定' : '固定标题';
stickyBtn.onclick = (event) => {
event.stopPropagation();
this.togglePromptSticky(item.id);
};
if (this._selectedPromptIds.has(item.id)) {
card.classList.add('selected');
}
actions.append(editBtn, copyBtn, deleteBtn);
head.append(headMain, actions);
meta.append(tag);
if (expanded) meta.append(stickyBtn);
body.append(content);
card.append(head, meta, body);
this.promptListEl.appendChild(card);
});
}
savePromptEntry() {
const title = this.promptTitleInputEl?.value.trim();
const group = this.promptGroupInputEl?.value.trim();
const content = this.promptContentInputEl?.value.trim();
if (!title || !content) {
this.setPromptFeedback('标题和内容不能为空。', 'warning');
return;
}
const prompts = loadPromptLibrary();
const currentId = this._editingPromptId;
const signature = getPromptSignature({ title, group, content });
const duplicate = prompts.find(item => item.id !== currentId && getPromptSignature(item) === signature);
if (duplicate) {
this.setPromptFeedback(`已存在相同内容的提示词:${duplicate.title}`, 'warning');
return;
}
if (currentId) {
const target = prompts.find(item => item.id === currentId);
if (!target) {
this._editingPromptId = null;
this.updatePromptEditorState();
this.setPromptFeedback('原提示词不存在,请重新选择后再编辑。', 'warning');
return;
}
target.title = title;
target.group = group;
target.content = content;
target.updatedAt = Date.now();
savePromptLibrary(prompts);
this.clearPromptEditor(true);
this.setPromptFeedback(`已覆盖保存:${title}`, 'success');
this.renderPromptLibrary();
return;
}
prompts.push({
id: generatePromptId(),
title,
group,
content,
updatedAt: Date.now()
});
savePromptLibrary(prompts);
this.clearPromptEditor(true);
this.setPromptFeedback(`已新增提示词:${title}`, 'success');
this.renderPromptLibrary();
}
clearPromptEditor(keepFeedback = false) {
this.promptTitleInputEl.value = '';
this.promptGroupInputEl.value = '';
this.promptContentInputEl.value = '';
this._editingPromptId = null;
this.updatePromptEditorState();
if (!keepFeedback) {
this.setPromptFeedback('已清空编辑区。', 'default');
}
}
fillPromptEntry(item) {
if (!item) return;
this._editingPromptId = item.id;
this.promptTitleInputEl.value = item.title;
this.promptGroupInputEl.value = item.group || '';
this.promptContentInputEl.value = item.content;
this.updatePromptEditorState();
this.setPromptFeedback(`已载入编辑:${item.title}`, 'default', true);
this.switchPromptPane('create');
}
removePromptEntry(id) {
const current = loadPromptLibrary();
const target = current.find(item => item.id === id);
if (!target) return;
savePromptLibrary(current.filter(item => item.id !== id));
this._selectedPromptIds.delete(id);
this._expandedPromptIds.delete(id);
this._stickyPromptIds.delete(id);
if (this._editingPromptId === id) this.clearPromptEditor(true);
this.setPromptFeedback(`已删除提示词:${target.title}`, 'success');
this.renderPromptLibrary();
}
togglePromptSelection(id) {
if (this._selectedPromptIds.has(id)) this._selectedPromptIds.delete(id);
else this._selectedPromptIds.add(id);
this.renderPromptLibrary();
}
setVisiblePromptSelection(checked) {
const visiblePrompts = this.getVisiblePromptEntries();
if (!visiblePrompts.length) {
this.setPromptFeedback('当前没有可选择的提示词。', 'warning');
return;
}
visiblePrompts.forEach((item) => {
if (checked) this._selectedPromptIds.add(item.id);
else this._selectedPromptIds.delete(item.id);
});
this.setPromptFeedback(checked ? `已选中 ${visiblePrompts.length} 条当前结果。` : '已取消当前结果的选择。', 'success');
this.renderPromptLibrary();
}
clearPromptSelection() {
if (!this._selectedPromptIds.size) {
this.setPromptFeedback('当前没有已选提示词。', 'warning');
return;
}
this._selectedPromptIds.clear();
this.setPromptFeedback('已清空所有选择。', 'success');
this.renderPromptLibrary();
}
removeSelectedPromptEntries() {
if (!this._selectedPromptIds.size) {
this.setPromptFeedback('当前没有可选择的提示词。', 'warning');
return;
}
const prompts = loadPromptLibrary();
const removedCount = prompts.filter(item => this._selectedPromptIds.has(item.id)).length;
savePromptLibrary(prompts.filter(item => !this._selectedPromptIds.has(item.id)));
Array.from(this._selectedPromptIds).forEach((id) => {
this._expandedPromptIds.delete(id);
this._stickyPromptIds.delete(id);
});
this._selectedPromptIds.clear();
if (this._editingPromptId) this.clearPromptEditor(true);
this.setPromptFeedback(`已批量删除 ${removedCount} 条提示词。`, 'success');
this.renderPromptLibrary();
}
async copyPromptEntry(item, buttonEl) {
if (!item) return;
const text = `${item.title}\n\n${item.content}`;
try {
await copyText(text);
if (buttonEl) {
const old = buttonEl.textContent;
buttonEl.textContent = '已复制';
setTimeout(() => {
buttonEl.textContent = old;
}, 1200);
}
this.setPromptFeedback(`已复制提示词:${item.title}`, 'success');
} catch {
this.setPromptFeedback('复制失败,请检查浏览器剪贴板权限。', 'danger');
}
}
exportPromptLibraryFile() {
const all = loadPromptLibrary();
const selected = this._selectedPromptIds.size
? all.filter(item => this._selectedPromptIds.has(item.id))
: all;
if (!selected.length) {
this.setPromptFeedback('当前没有可导出的提示词。', 'warning');
return;
}
const payload = {
version: PROMPT_LIBRARY_SCHEMA_VERSION,
exportedAt: Date.now(),
prompts: selected
};
triggerTextDownload(`prompt-library-${new Date().toISOString().slice(0,10)}.json`, JSON.stringify(payload, null, 2), 'application/json;charset=utf-8');
this.setPromptFeedback(`已导出 ${selected.length} 条提示词。`, 'success');
}
async importPromptLibraryFile(file) {
if (!file) {
this.setPromptFeedback('未选择导入文件。', 'warning');
return;
}
if (!/\.json$/i.test(file.name)) {
this.setPromptFeedback('导入失败:请选择 JSON 文件。', 'danger');
if (this.promptFileInputEl) this.promptFileInputEl.value = '';
return;
}
try {
const text = await file.text();
const payload = normalizePromptLibraryPayload(JSON.parse(text));
if (!payload.prompts.length) {
this.setPromptFeedback('正在编辑提示词,可直接覆盖保存。', 'danger');
return;
}
if (payload.version > PROMPT_LIBRARY_SCHEMA_VERSION) {
this.setPromptFeedback(`导入文件版本 ${payload.version} 高于当前支持版本 ${PROMPT_LIBRARY_SCHEMA_VERSION}。`, 'warning');
return;
}
const existing = loadPromptLibrary();
const seen = new Set(existing.map(getPromptSignature));
const merged = existing.slice();
let importedCount = 0;
let duplicateCount = 0;
payload.prompts.forEach((item) => {
const normalized = normalizePromptItem({ ...item, id: generatePromptId(), updatedAt: Date.now() });
if (!normalized) return;
const signature = getPromptSignature(normalized);
if (seen.has(signature)) {
duplicateCount += 1;
return;
}
seen.add(signature);
merged.push(normalized);
importedCount += 1;
});
if (!importedCount) {
this.setPromptFeedback(`导入完成,但 ${duplicateCount} 条均为重复内容,已自动跳过。`, 'warning');
return;
}
savePromptLibrary(merged);
this.promptTransferOverlayEl?.classList.remove('show');
this.setPromptFeedback(`导入成功:新增 ${importedCount} 条,跳过重复 ${duplicateCount} 条。`, 'success');
} catch {
this.setPromptFeedback('导入失败:文件内容不是有效的 JSON。', 'danger');
}
if (this.promptFileInputEl) this.promptFileInputEl.value = '';
this.renderPromptLibrary();
}
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 '';
if (detectSite() === 'gemini') {
const content = element.matches('.conversation-container')
? (element.querySelector('user-query-content .query-text, user-query-content .user-query-bubble-with-background, user-query-content .query-content') || element.querySelector('user-query-content') || element)
: element.matches('.query-text, .query-text.gds-body-l')
? element
: element.matches('user-query-content')
? element
: (element.querySelector('user-query-content, .query-text, .user-query-bubble-with-background') || element);
return sanitizeText(content.innerText || content.textContent);
}
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 getGeminiNavigatorEntries() {
const containers = queryGeminiConversationContainers();
if (containers.length) {
return containers.map((container, index) => {
const userNode = container.querySelector('user-query-content') || container;
const assistantNode = container.querySelector('model-response');
const assistantContent = assistantNode?.querySelector('.markdown.markdown-main-panel, message-content .markdown, structured-content-container, .model-response-text, .response-content') || assistantNode;
return {
id: container.getAttribute('data-conversation-id') || `gemini_${index + 1}`,
question: extractQuestionText(userNode),
element: container,
detail: { headings: collectHeadingTargets(assistantContent), questionRoot: userNode, answerRoot: assistantContent }
};
}).filter(item => item.question);
}
const root = queryGeminiRoot();
const users = root ? Array.from(root.querySelectorAll('user-query-content')) : [];
const assistants = root ? Array.from(root.querySelectorAll('model-response')) : [];
return users.map((userNode, index) => {
const answerRoot = assistants[index]?.querySelector('message-content.model-response-text, .markdown, .model-response-text, .response-container-with-gpi') || assistants[index];
return {
id: userNode.getAttribute('data-message-id') || `gemini_${index + 1}`,
question: extractQuestionText(userNode),
element: userNode,
detail: {
headings: collectHeadingTargets(answerRoot),
questionRoot: userNode,
answerRoot
}
};
}).filter(item => item.question);
}
function collectNavigatorEntries() {
return detectSite() === 'gemini'
? getGeminiNavigatorEntries()
: getChatGPTNavigatorEntries();
}
function resetDisplay(elements) {
elements.forEach(element => {
if (element.style.display === 'none') {
element.style.display = '';
}
});
}
function applyChatGPTFilter(depth) {
const allChildren = Array.from(new Set(
queryAllDeep(CHATGPT_MESSAGE_SELECTOR).map(getClosestMessageWrapper).filter(Boolean)
));
if (!allChildren.length) return;
resetDisplay(allChildren);
if (depth <= 0) return;
const questionEls = queryChatGPTQuestionElements();
if (questionEls.length <= depth) return;
const firstToKeep = questionEls[questionEls.length - depth];
const firstToKeepIndex = allChildren.indexOf(firstToKeep);
for (let index = 0; index < firstToKeepIndex; index += 1) {
allChildren[index].style.display = 'none';
}
}
function applyGeminiFilter() {
// Gemini's application shell lazily mounts and reuses conversation nodes.
// Hiding those nodes during initial render can leave the page stuck loading,
// so Gemini keeps the full DOM visible and only trims the navigator list.
const conversationContainers = queryGeminiConversationContainers();
if (conversationContainers.length) {
resetDisplay(conversationContainers);
return;
}
const root = queryGeminiRoot();
const userNodes = root ? Array.from(root.querySelectorAll('user-query-content')) : [];
const assistantNodes = root ? Array.from(root.querySelectorAll('model-response')) : [];
const pairedNodes = [];
if (userNodes.length || assistantNodes.length) {
const maxLength = Math.max(userNodes.length, assistantNodes.length);
for (let index = 0; index < maxLength; index += 1) {
if (userNodes[index]) pairedNodes.push(userNodes[index]);
if (assistantNodes[index]) pairedNodes.push(assistantNodes[index]);
}
}
const messageNodes = pairedNodes.length ? pairedNodes : getGeminiMessageNodes();
resetDisplay(messageNodes);
}
function applyMainChatFilter() {
if (detectSite() === 'gemini') {
applyGeminiFilter();
} else {
const savedDepth = parseInt(localStorage.getItem(getDepthStorageKey()) || '0', 10);
applyChatGPTFilter(savedDepth);
}
}
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;
}
function updateNavigator() {
applyMainChatFilter();
const entries = collectNavigatorEntries();
const elements = entries.map(item => item.element).filter(Boolean);
let app = document.getElementById(DOM_MARK);
const hasGeminiRoot = detectSite() === 'gemini' && !!queryGeminiRoot();
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 (detectSite() === 'gemini' || hasGeminiRoot) {
app = app || ensureNavigatorApp();
if (!app) return;
app.questions = { ids: [], questions: [], elements: [], details: [] };
} else if (app) {
app.remove();
}
}
const refreshNavigator = () => {
const latestKey = getConversationKey();
if (conversationKey !== latestKey) {
conversationKey = latestKey;
loaded = false;
activeIndex = null;
const app = document.getElementById(DOM_MARK);
if (app) app.remove();
}
if (detectSite() === 'gemini') {
ensureNavigatorApp();
}
const elements = queryQuestionElements();
if (elements.length > 0) {
applyMainChatFilter();
updateNavigator();
loaded = true;
} else if (detectSite() === 'gemini') {
updateNavigator();
loaded = false;
} else {
const app = document.getElementById(DOM_MARK);
if (app) app.remove();
loaded = false;
}
};
const scheduleRefreshNavigator = () => {
if (refreshScheduled) return;
refreshScheduled = true;
requestAnimationFrame(() => {
refreshScheduled = false;
refreshNavigator();
});
};
function startChatGPTBootstrap() {
setInterval(scheduleRefreshNavigator, 800);
const observer = new MutationObserver(() => {
scheduleRefreshNavigator();
});
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
}
function startGeminiBootstrap() {
if (geminiBootstrapStarted || detectSite() !== 'gemini') return;
geminiBootstrapStarted = true;
const bootstrap = () => {
if (!document.body) return;
ensureNavigatorApp();
scheduleRefreshNavigator();
};
const startObserver = () => {
const target = document.querySelector('main') || document.body || document.documentElement;
if (!target) return false;
const observer = new MutationObserver(() => {
scheduleRefreshNavigator();
});
observer.observe(target, {
childList: true,
subtree: true,
characterData: true
});
return true;
};
setTimeout(bootstrap, 300);
setTimeout(bootstrap, 1500);
setTimeout(bootstrap, 3000);
setTimeout(() => {
bootstrap();
startObserver();
}, 1500);
window.addEventListener('load', bootstrap, { once: true });
document.addEventListener('readystatechange', bootstrap, { passive: true });
const probe = setInterval(() => {
bootstrap();
if (startObserver()) {
clearInterval(probe);
}
}, 1000);
setTimeout(() => clearInterval(probe), 10000);
}
if (detectSite() === 'gemini') {
startGeminiBootstrap();
} else {
startChatGPTBootstrap();
}
// [END: 入口函数]
})();