From 3fcda5d3cf0dfb692a14932ad64bfa347bf4bded Mon Sep 17 00:00:00 2001 From: KanameMadoka520 <2441883200@qq.com> Date: Fri, 17 Apr 2026 05:11:28 +0800 Subject: [PATCH 01/27] feat: add Perplexity support --- package.json | 2 + src/adapters/base.ts | 7 + src/adapters/index.ts | 2 + src/adapters/perplexity.ts | 1069 +++++++++++++++++++++++ src/background.ts | 32 +- src/constants/defaults.ts | 8 + src/contents/main.ts | 4 +- src/contents/monitor-entry.ts | 2 + src/contents/scroll-lock-main.ts | 2 + src/contents/ui-entry.tsx | 3 + src/core/conversation/manager.ts | 37 +- src/core/usage-counter-manager.ts | 10 +- src/tabs/options/pages/FeaturesPage.tsx | 1 + src/utils/storage.ts | 6 + vite.userscript.config.ts | 2 + 15 files changed, 1171 insertions(+), 16 deletions(-) create mode 100644 src/adapters/perplexity.ts diff --git a/package.json b/package.json index 87bc13aad..a32013173 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,8 @@ "https://ima.qq.com/*", "https://chat.deepseek.com/*", "https://www.kimi.com/*", + "https://www.perplexity.ai/*", + "https://perplexity.ai/*", "https://chatglm.cn/*", "https://chat.qwen.ai/*", "https://www.qianwen.com/*", diff --git a/src/adapters/base.ts b/src/adapters/base.ts index 4d1058611..d41f8091d 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -582,6 +582,13 @@ export abstract class SiteAdapter { * 精准查找提交按钮 * 当站点存在多个相邻 icon button,且仅靠通用选择器/距离判断容易误判时使用 */ + getUsageCounterMountAnchor( + _editor: HTMLElement, + _submitButton: HTMLElement | null, + ): HTMLElement | null { + return null + } + findSubmitButton(_editor: HTMLElement | null): HTMLElement | null { return null } diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 47607dbd6..7fd70cc88 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -16,6 +16,7 @@ import { GeminiEnterpriseAdapter } from "./gemini-enterprise" import { GrokAdapter } from "./grok" import { ImaAdapter } from "./ima" import { KimiAdapter } from "./kimi" +import { PerplexityAdapter } from "./perplexity" import { QianwenAdapter } from "./qianwen" import { QwenAiAdapter } from "./qwen-studio" import { YuanbaoAdapter } from "./yuanbao" @@ -32,6 +33,7 @@ const adapters: SiteAdapter[] = [ new DeepSeekAdapter(), new DoubaoAdapter(), new ImaAdapter(), + new PerplexityAdapter(), new ChatGLMAdapter(), new KimiAdapter(), new QwenAiAdapter(), diff --git a/src/adapters/perplexity.ts b/src/adapters/perplexity.ts new file mode 100644 index 000000000..4134991f7 --- /dev/null +++ b/src/adapters/perplexity.ts @@ -0,0 +1,1069 @@ +/** + * Perplexity 适配器(www.perplexity.ai) + * + * 设计目标: + * - 优先依赖相对稳定的 id / data-testid / 路由结构 + * - 输入框与消息区域使用多重选择器兜底,兼容首页与线程页 + * - 会话列表优先从侧边栏 DOM 读取,必要时用线程 API 预拉取做缓存 + */ +import { SITE_IDS } from "~constants" +import { DOMToolkit } from "~utils/dom-toolkit" +import { htmlToMarkdown } from "~utils/exporter" + +import { + SiteAdapter, + type ConversationInfo, + type ConversationObserverConfig, + type ExportConfig, + type NetworkMonitorConfig, + type OutlineItem, +} from "./base" + +const HOSTNAMES = new Set(["www.perplexity.ai", "perplexity.ai"]) +const THREAD_PATH_PATTERN = /^\/(?:search|page)\/([^/?#]+)(?:\/|$)/i +const NEW_THREAD_PATH_PATTERN = /^\/search\/new(?:\/|$)/i + +const TEXTAREA_SELECTORS = [ + "#ask-input", + 'div[contenteditable="true"][role="textbox"]:not([id])', + 'div[contenteditable="true"][g_editable="true"]', + 'div[contenteditable="true"][data-lexical-editor="true"]', + 'div[contenteditable="true"][role="textbox"]', +] + +const SUBMIT_BUTTON_SELECTOR = 'button[data-testid="submit-button"], button[type="submit"]' +const STOP_BUTTON_SELECTOR = + 'button[data-testid="stop-button"], button[aria-label*="Stop"], button[aria-label*="stop"]' +const NEW_CHAT_BUTTON_SELECTORS = [ + 'a[href="/"]', + 'a[href="/search/new"]', + 'button[data-testid="new-thread-button"]', +] + +const USER_QUERY_SELECTOR = [ + ".group\\/query", + "[role='tabpanel'] .group.relative.flex.items-end.mb-xs", +].join(", ") + +const USER_QUERY_CONTENT_SELECTOR = [ + ".group\\/query .whitespace-pre-wrap", + ".group\\/query [dir='auto']", + ".group\\/query p", + ".group\\/query [data-lexical-text='true']", + "[role='tabpanel'] .group.relative.flex.items-end.mb-xs .whitespace-pre-wrap", + "[role='tabpanel'] .group.relative.flex.items-end.mb-xs [dir='auto']", + "[role='tabpanel'] .group.relative.flex.items-end.mb-xs p", + "[role='tabpanel'] .group.relative.flex.items-end.mb-xs [data-lexical-text='true']", +].join(", ") + +const ASSISTANT_MESSAGE_SELECTOR = [ + "div[id*='markdown-content-']", + "div[id*='Markdown-Content-']", +].join(", ") + +const RESPONSE_CONTAINER_SELECTOR = [ + "[role='tabpanel']", + "main [class*='overflow-y-auto']", + "main [class*='overflow-auto']", + "main", +].join(", ") + +const SIDEBAR_LINK_SELECTOR = "a[href^='/search/'], a[href^='/page/']" +const SIDEBAR_SCROLL_CONTAINER_SELECTORS = [".group\\/sidebar", "aside", "nav"] +const THREAD_TITLE_SELECTOR = + ".h-headerHeight.fixed.z-10 .cursor-pointer.transition.duration-300.hover\\:opacity-70, input[placeholder='Untitled']" + +const QUERY_EDIT_BUTTON_SELECTOR = 'button[data-testid="edit-query-button"]' +const THREAD_LIST_ENDPOINT = + "https://www.perplexity.ai/rest/thread/list_ask_threads?version=2.18&source=default" +const THREAD_LIST_PAGE_SIZE = 100 +const THREAD_LIST_MAX_PAGES = 5 +const THREAD_LIST_CACHE_TTL_MS = 5 * 60 * 1000 + +const EXPORT_ROLE_ATTR = "data-gh-perplexity-export-role" +const EXPORT_USER_SELECTOR = `[${EXPORT_ROLE_ATTR}="user"]` +const EXPORT_ASSISTANT_SELECTOR = `[${EXPORT_ROLE_ATTR}="assistant"]` + +interface PerplexityThreadListEntry { + slug?: unknown + title?: unknown +} + +export class PerplexityAdapter extends SiteAdapter { + private threadListCache: ConversationInfo[] = [] + private threadListCacheExpiresAt = 0 + + match(): boolean { + return HOSTNAMES.has(window.location.hostname) + } + + getSiteId(): string { + return SITE_IDS.PERPLEXITY + } + + getName(): string { + return "Perplexity" + } + + getThemeColors(): { primary: string; secondary: string } { + return { primary: "#1fb8cd", secondary: "#1597aa" } + } + + getSessionId(): string { + const match = window.location.pathname.match(THREAD_PATH_PATTERN) + const slug = match?.[1]?.trim() || "" + return slug === "new" ? "" : slug + } + + isNewConversation(): boolean { + const path = window.location.pathname.replace(/\/+$/, "") || "/" + return path === "/" || NEW_THREAD_PATH_PATTERN.test(path) || !THREAD_PATH_PATTERN.test(path) + } + + getNewTabUrl(): string { + return "https://www.perplexity.ai/" + } + + getSessionName(): string | null { + const title = document.title.trim() + if (!title) return null + + const cleaned = title + .replace(/\s*[-|]\s*Perplexity$/i, "") + .replace(/\s*[-|]\s*Perplexity AI$/i, "") + .trim() + + if (!cleaned || /^perplexity(?:\s+ai)?$/i.test(cleaned)) { + return null + } + + return cleaned + } + + getConversationTitle(): string | null { + const titleElement = document.querySelector(THREAD_TITLE_SELECTOR) + const title = + titleElement instanceof HTMLInputElement + ? titleElement.value.trim() + : titleElement?.textContent?.trim() || "" + + return title || this.getSessionName() + } + + getTextareaSelectors(): string[] { + return [...TEXTAREA_SELECTORS] + } + + isValidTextarea(element: HTMLElement): boolean { + if (!super.isValidTextarea(element)) return false + + if (element.id === "ask-input") return true + if (element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement) return true + + return ( + element.isContentEditable && + (element.getAttribute("role") === "textbox" || + element.getAttribute("g_editable") === "true" || + element.closest("form") !== null) + ) + } + + insertPrompt(content: string): boolean { + const editor = this.getTextareaElement() + if (!editor || !editor.isConnected) return false + + editor.focus() + + if (editor instanceof HTMLTextAreaElement || editor instanceof HTMLInputElement) { + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set + if (setter) { + setter.call(editor, content) + } else { + editor.value = content + } + editor.dispatchEvent( + new InputEvent("input", { bubbles: true, composed: true, data: content }), + ) + editor.dispatchEvent(new Event("change", { bubbles: true })) + if (editor instanceof HTMLTextAreaElement) { + editor.setSelectionRange(content.length, content.length) + } + return true + } + + const richEditor = editor as HTMLElement + this.selectEditorContents(richEditor) + + try { + document.execCommand("delete", false) + } catch { + // ignore legacy API failure and continue with fallbacks + } + + try { + if (document.execCommand("insertText", false, content)) { + this.dispatchEditorInputEvents(richEditor, content, "insertText") + this.placeCaretAtEnd(richEditor) + return true + } + } catch { + // fallback below + } + + if (this.dispatchPasteEvent(richEditor, content)) { + this.placeCaretAtEnd(richEditor) + return true + } + + richEditor.textContent = content + this.dispatchEditorInputEvents(richEditor, content, "insertText") + this.placeCaretAtEnd(richEditor) + return true + } + + clearTextarea(): void { + const editor = this.getTextareaElement() + if (!editor || !editor.isConnected) return + + editor.focus() + + if (editor instanceof HTMLTextAreaElement || editor instanceof HTMLInputElement) { + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set + if (setter) { + setter.call(editor, "") + } else { + editor.value = "" + } + editor.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true, data: "" })) + editor.dispatchEvent(new Event("change", { bubbles: true })) + if (editor instanceof HTMLTextAreaElement) { + editor.setSelectionRange(0, 0) + } + return + } + + const richEditor = editor as HTMLElement + this.selectEditorContents(richEditor) + + try { + document.execCommand("delete", false) + } catch { + // fallback below + } + + richEditor.textContent = "" + this.dispatchEditorInputEvents(richEditor, "", "deleteContentBackward") + this.placeCaretAtEnd(richEditor) + } + + getSubmitButtonSelectors(): string[] { + return [`${SUBMIT_BUTTON_SELECTOR}:not([disabled])`] + } + + findSubmitButton(editor: HTMLElement | null): HTMLElement | null { + const scopes = [ + editor?.closest("form"), + editor?.parentElement, + editor?.closest(".grow.block"), + document.body, + ].filter(Boolean) as ParentNode[] + + for (const scope of scopes) { + const candidates = scope.querySelectorAll(SUBMIT_BUTTON_SELECTOR) + for (const candidate of Array.from(candidates)) { + const button = candidate as HTMLElement + if (!this.isVisibleElement(button) || this.isDisabledActionButton(button)) continue + return button + } + } + + return super.findSubmitButton(editor) + } + + getUsageCounterMountAnchor( + editor: HTMLElement, + submitButton: HTMLElement | null, + ): HTMLElement | null { + const candidates = [ + editor.closest(".grow.block"), + submitButton?.closest(".grow.block") || null, + DOMToolkit.closestComposed(editor, ".grow.block"), + submitButton ? DOMToolkit.closestComposed(submitButton, ".grow.block") : null, + editor.closest("[role='tabpanel']"), + submitButton?.closest("[role='tabpanel']") || null, + ].filter(Boolean) as HTMLElement[] + + for (const candidate of candidates) { + const safeAnchor = this.promoteUsageCounterAnchor(candidate) + if (safeAnchor?.parentElement) { + return safeAnchor + } + } + + return null + } + + getStopButtonSelectors(): string[] { + return [STOP_BUTTON_SELECTOR] + } + + isGenerating(): boolean { + const stopButton = this.findVisibleElementBySelectors(this.getStopButtonSelectors()) + return Boolean(stopButton) + } + + getNewChatButtonSelectors(): string[] { + return [...NEW_CHAT_BUTTON_SELECTORS] + } + + getScrollContainer(): HTMLElement | null { + const selectors = RESPONSE_CONTAINER_SELECTOR.split(",").map((selector) => selector.trim()) + + for (const selector of selectors) { + const candidates = document.querySelectorAll(selector) + for (const candidate of Array.from(candidates)) { + const element = candidate as HTMLElement + if (element.scrollHeight > element.clientHeight + 40) { + return element + } + } + } + + return document.scrollingElement as HTMLElement | null + } + + getSidebarScrollContainer(): Element | null { + for (const selector of SIDEBAR_SCROLL_CONTAINER_SELECTORS) { + const candidate = document.querySelector(selector) + if (!(candidate instanceof HTMLElement)) continue + + const scrollable = this.findScrollableParent(candidate) + if (scrollable) return scrollable + return candidate + } + + return document.scrollingElement + } + + getConversationList(): ConversationInfo[] { + const domList = this.collectConversationListFromDom() + const cachedList = this.getCachedThreadList() + const currentConversation = this.getCurrentConversationInfo() + const currentList = currentConversation ? [currentConversation] : [] + + if (domList.length === 0 && cachedList.length === 0) return currentList + if (domList.length === 0) return this.mergeConversationInfos(cachedList, currentList) + if (cachedList.length === 0) return this.mergeConversationInfos(domList, currentList) + + return this.mergeConversationInfos(cachedList, domList, currentList) + } + + getConversationObserverConfig(): ConversationObserverConfig | null { + return { + selector: SIDEBAR_LINK_SELECTOR, + shadow: false, + extractInfo: (element) => this.extractConversationInfo(element), + getTitleElement: (element) => element.querySelector("span, div[dir='auto']") || element, + } + } + + async loadAllConversations(): Promise { + try { + const apiThreads = await this.fetchThreadsViaApi() + if (apiThreads.length > 0) { + this.cacheThreadList(apiThreads) + } + } catch (error) { + console.warn("[PerplexityAdapter] Failed to preload thread list:", error) + } + } + + navigateToConversation(id: string, url?: string): boolean { + return super.navigateToConversation(id, url || `https://www.perplexity.ai/search/${id}`) + } + + getResponseContainerSelector(): string { + return RESPONSE_CONTAINER_SELECTOR + } + + getChatContentSelectors(): string[] { + return [USER_QUERY_SELECTOR, ASSISTANT_MESSAGE_SELECTOR] + } + + getUserQuerySelector(): string | null { + return USER_QUERY_SELECTOR + } + + extractUserQueryText(element: Element): string { + const source = (this.findUserContentRoot(element) || element).cloneNode(true) as HTMLElement + source + .querySelectorAll( + ".gh-user-query-markdown, button, [role='button'], svg, [aria-hidden='true'], [data-testid]", + ) + .forEach((node) => node.remove()) + + return this.extractTextWithLineBreaks(source).trim() + } + + extractUserQueryMarkdown(element: Element): string { + return this.extractUserQueryText(element) + } + + extractUserQueryExportContent(element: Element): string { + return this.extractUserQueryMarkdown(element) + } + + replaceUserQueryContent(element: Element, html: string): boolean { + const contentRoot = this.findUserContentRoot(element) + if (!contentRoot) return false + if (element.querySelector(".gh-user-query-markdown")) return false + + const rendered = document.createElement("div") + rendered.className = + `${contentRoot.className || ""} gh-user-query-markdown gh-markdown-preview`.trim() + rendered.innerHTML = html + + const inlineStyle = contentRoot.getAttribute("style") + if (inlineStyle) { + rendered.setAttribute("style", inlineStyle) + } + + contentRoot.classList.add("gh-user-query-raw") + contentRoot.style.display = "none" + contentRoot.after(rendered) + return true + } + + extractAssistantResponseText(element: Element): string { + const clone = element.cloneNode(true) as HTMLElement + clone + .querySelectorAll( + "button, [role='button'], svg, [aria-hidden='true'], .gh-user-query-markdown, [data-testid='copy-code-button']", + ) + .forEach((node) => node.remove()) + + const markdown = htmlToMarkdown(clone) || this.extractTextWithLineBreaks(clone) + return markdown.trim() + } + + getAssistantMermaidSupportMode(): "native" | "fallback" | "unsupported" { + return "fallback" + } + + getExportConfig(): ExportConfig { + return { + userQuerySelector: EXPORT_USER_SELECTOR, + assistantResponseSelector: EXPORT_ASSISTANT_SELECTOR, + turnSelector: null, + useShadowDOM: false, + } + } + + async prepareConversationExport(): Promise { + this.clearExportMarkers() + const container = + document.querySelector("[role='tabpanel']") || document.querySelector("main") || document.body + this.markExportMessages(container) + return null + } + + async restoreConversationAfterExport(): Promise { + this.clearExportMarkers() + } + + extractOutline(maxLevel = 6, includeUserQueries = false, showWordCount = false): OutlineItem[] { + const outline: OutlineItem[] = [] + const container = + document.querySelector("[role='tabpanel']") || + document.querySelector(this.getResponseContainerSelector()) + if (!container) return outline + + const userQuerySelector = this.getUserQuerySelector() + if (!userQuerySelector) return outline + + const headingSelectors = Array.from({ length: maxLevel }, (_, index) => `h${index + 1}`).join( + ", ", + ) + const rawUserQueries = Array.from(container.querySelectorAll(userQuerySelector)) + const userQueries = this.collectTopLevelBlocks(rawUserQueries).filter( + (element) => !this.shouldSkipOutlineElement(element), + ) + const userQuerySet = new Set(userQueries) + + const allElements = Array.from( + container.querySelectorAll(`${userQuerySelector}, ${headingSelectors}`), + ).filter((element) => { + if (element.matches(userQuerySelector)) { + return userQuerySet.has(element) + } + + return ( + !this.shouldSkipOutlineElement(element) && + element.closest(ASSISTANT_MESSAGE_SELECTOR) !== null + ) + }) + + allElements.forEach((element, index) => { + const isUserQuery = element.matches(userQuerySelector) + const tagName = element.tagName.toLowerCase() + + if (isUserQuery) { + if (!includeUserQueries) return + + const fullText = this.extractUserQueryText(element) + if (!fullText) return + + const item: OutlineItem = { + level: 0, + text: this.truncateText(fullText, 80), + element, + isUserQuery: true, + isTruncated: fullText.length > 80, + } + + if (showWordCount) { + const nextUserQuery = + allElements + .slice(index + 1) + .find((candidate) => candidate.matches(userQuerySelector)) || null + item.wordCount = this.calculateAssistantWordCountBetween( + container, + element, + nextUserQuery, + ) + } + + outline.push(item) + return + } + + if (!/^h[1-6]$/.test(tagName)) return + + const level = parseInt(tagName.charAt(1), 10) + if (Number.isNaN(level) || level > maxLevel) return + + const text = element.textContent?.trim() || "" + if (!text) return + + const item: OutlineItem = { + level, + text, + element, + } + + if (showWordCount) { + let nextBoundary: Element | null = null + for (let i = index + 1; i < allElements.length; i += 1) { + const candidate = allElements[i] + if (candidate.matches(userQuerySelector)) { + nextBoundary = candidate + break + } + + const candidateTagName = candidate.tagName.toLowerCase() + if (/^h[1-6]$/.test(candidateTagName)) { + const candidateLevel = parseInt(candidateTagName.charAt(1), 10) + if (!Number.isNaN(candidateLevel) && candidateLevel <= level) { + nextBoundary = candidate + break + } + } + } + + item.wordCount = this.calculateRangeWordCount( + element, + nextBoundary, + element.closest(ASSISTANT_MESSAGE_SELECTOR) || container, + ) + } + + outline.push(item) + }) + + return outline + } + + getNetworkMonitorConfig(): NetworkMonitorConfig | null { + return { + urlPatterns: ["/rest/sse/perplexity_ask"], + silenceThreshold: 2500, + } + } + + getWidthSelectors(): Array<{ selector: string; property: string }> { + return [ + { selector: "[role='tabpanel'] .mx-auto", property: "maxWidth" }, + { selector: "main .mx-auto", property: "maxWidth" }, + ] + } + + getUserQueryWidthSelectors(): Array<{ selector: string; property: string }> { + return [{ selector: USER_QUERY_SELECTOR, property: "maxWidth" }] + } + + private selectEditorContents(editor: HTMLElement): void { + const selection = window.getSelection() + if (!selection) return + + const range = document.createRange() + range.selectNodeContents(editor) + selection.removeAllRanges() + selection.addRange(range) + } + + private placeCaretAtEnd(editor: HTMLElement): void { + const selection = window.getSelection() + if (!selection) return + + const range = document.createRange() + range.selectNodeContents(editor) + range.collapse(false) + selection.removeAllRanges() + selection.addRange(range) + } + + private dispatchEditorInputEvents( + editor: HTMLElement, + data: string, + inputType: InputEvent["inputType"], + ): void { + try { + editor.dispatchEvent( + new InputEvent("beforeinput", { + bubbles: true, + composed: true, + data, + inputType, + }), + ) + } catch { + // ignore browsers that reject synthetic beforeinput + } + + editor.dispatchEvent( + new InputEvent("input", { + bubbles: true, + composed: true, + data, + inputType, + }), + ) + editor.dispatchEvent(new Event("change", { bubbles: true })) + } + + private dispatchPasteEvent(editor: HTMLElement, content: string): boolean { + if (typeof DataTransfer === "undefined" || typeof ClipboardEvent === "undefined") { + return false + } + + try { + const dataTransfer = new DataTransfer() + dataTransfer.setData("text/plain", content) + + return editor.dispatchEvent( + new ClipboardEvent("paste", { + bubbles: true, + cancelable: true, + composed: true, + clipboardData: dataTransfer, + }), + ) + } catch { + return false + } + } + + private findScrollableParent(element: Element | null): HTMLElement | null { + let current = element instanceof HTMLElement ? element : element?.parentElement || null + + while (current && current !== document.body) { + if (current.scrollHeight > current.clientHeight + 20) { + return current + } + current = current.parentElement + } + + return null + } + + private parseThreadSlugFromUrl(url: string): string { + try { + const parsed = new URL(url, window.location.origin) + const match = parsed.pathname.match(THREAD_PATH_PATTERN) + const slug = match?.[1]?.trim() || "" + return slug === "new" ? "" : slug + } catch { + return "" + } + } + + private extractConversationInfo(element: Element): ConversationInfo | null { + const anchor = element.closest("a") as HTMLAnchorElement | null + if (!anchor) return null + + const slug = this.parseThreadSlugFromUrl(anchor.href || anchor.getAttribute("href") || "") + if (!slug) return null + + const title = this.extractConversationTitle(anchor, slug) + + return { + id: slug, + title, + url: new URL(anchor.getAttribute("href") || anchor.href, window.location.origin).toString(), + isActive: slug === this.getSessionId(), + } + } + + private collectConversationListFromDom(): ConversationInfo[] { + const root = + (this.getSidebarScrollContainer() as ParentNode | null) || + document.querySelector(".group\\/sidebar") || + document.querySelector("aside") || + document + const links = root.querySelectorAll(SIDEBAR_LINK_SELECTOR) + const result = new Map() + + links.forEach((link) => { + const info = this.extractConversationInfo(link) + if (!info) return + result.set(info.id, info) + }) + + return Array.from(result.values()) + } + + private getCachedThreadList(): ConversationInfo[] { + if (Date.now() > this.threadListCacheExpiresAt) { + return [] + } + return [...this.threadListCache] + } + + private cacheThreadList(list: ConversationInfo[]): void { + this.threadListCache = list + this.threadListCacheExpiresAt = Date.now() + THREAD_LIST_CACHE_TTL_MS + } + + private mergeConversationInfos(...lists: ConversationInfo[][]): ConversationInfo[] { + const merged = new Map() + + for (const list of lists) { + for (const item of list) { + const existing = merged.get(item.id) + const preferredTitle = this.pickPreferredConversationTitle( + existing?.title, + item.title, + item.id, + ) + merged.set(item.id, { + ...existing, + ...item, + title: preferredTitle, + url: item.url || existing?.url || `https://www.perplexity.ai/search/${item.id}`, + isActive: Boolean(existing?.isActive || item.isActive), + }) + } + } + + return Array.from(merged.values()) + } + + private async fetchThreadsViaApi(): Promise { + const results: ConversationInfo[] = [] + const seen = new Set() + const currentSessionId = this.getSessionId() + + for (let page = 0; page < THREAD_LIST_MAX_PAGES; page += 1) { + const response = await fetch(THREAD_LIST_ENDPOINT, { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + limit: THREAD_LIST_PAGE_SIZE, + offset: page * THREAD_LIST_PAGE_SIZE, + search_term: "", + with_temporary_threads: false, + }), + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + + const data = JSON.parse(await response.text()) as PerplexityThreadListEntry[] + if (!Array.isArray(data) || data.length === 0) break + + for (const entry of data) { + const slug = typeof entry?.slug === "string" ? entry.slug.trim() : "" + if (!slug || slug === "new" || seen.has(slug)) continue + + seen.add(slug) + results.push({ + id: slug, + title: this.normalizeConversationTitle( + typeof entry?.title === "string" ? entry.title : "", + slug, + ), + url: `https://www.perplexity.ai/search/${slug}`, + isActive: slug === currentSessionId, + }) + } + + if (data.length < THREAD_LIST_PAGE_SIZE) { + break + } + + await new Promise((resolve) => setTimeout(resolve, 150)) + } + + return results + } + + private findUserContentRoot(element: Element): HTMLElement | null { + const candidates = Array.from(element.querySelectorAll(USER_QUERY_CONTENT_SELECTOR)).filter( + (candidate) => candidate instanceof HTMLElement, + ) as HTMLElement[] + + let best: HTMLElement | null = null + let bestScore = 0 + + for (const candidate of candidates) { + const root = + candidate.matches("[data-lexical-text='true']") && candidate.parentElement + ? candidate.parentElement + : candidate + const text = (root.innerText || root.textContent || "").trim() + if (!text) continue + + if (text.length > bestScore) { + best = root as HTMLElement + bestScore = text.length + } + } + + return best + } + + private clearExportMarkers(): void { + document + .querySelectorAll(`[${EXPORT_ROLE_ATTR}]`) + .forEach((node) => node.removeAttribute(EXPORT_ROLE_ATTR)) + } + + private markExportMessages(container: Element): void { + const users = this.collectTopLevelBlocks( + Array.from(container.querySelectorAll(USER_QUERY_SELECTOR)).filter( + (element) => !this.shouldSkipExportElement(element), + ), + ) + const assistants = this.collectTopLevelBlocks( + Array.from(container.querySelectorAll(ASSISTANT_MESSAGE_SELECTOR)).filter( + (element) => + !this.shouldSkipExportElement(element) && !element.closest(USER_QUERY_SELECTOR), + ), + ) + + users.forEach((element) => element.setAttribute(EXPORT_ROLE_ATTR, "user")) + assistants.forEach((element) => element.setAttribute(EXPORT_ROLE_ATTR, "assistant")) + } + + private shouldSkipExportElement(element: Element): boolean { + return ( + element.closest(".gh-root") !== null || + element.closest(".gh-user-query-markdown") !== null || + element.matches(QUERY_EDIT_BUTTON_SELECTOR) + ) + } + + private shouldSkipOutlineElement(element: Element): boolean { + return ( + element.closest(".gh-root") !== null || + element.closest(".gh-user-query-markdown") !== null || + element.closest(USER_QUERY_SELECTOR) !== null + ) + } + + private collectTopLevelBlocks(elements: T[]): T[] { + return elements.filter( + (element) => !elements.some((other) => other !== element && other.contains(element)), + ) + } + + private calculateAssistantWordCountBetween( + container: Element, + startElement: Element, + nextUserQuery: Element | null, + ): number { + let total = 0 + const assistants = Array.from(container.querySelectorAll(ASSISTANT_MESSAGE_SELECTOR)) + + for (const assistant of assistants) { + const afterStart = Boolean( + startElement.compareDocumentPosition(assistant) & Node.DOCUMENT_POSITION_FOLLOWING, + ) + if (!afterStart) continue + + if (nextUserQuery) { + const beforeEnd = Boolean( + nextUserQuery.compareDocumentPosition(assistant) & Node.DOCUMENT_POSITION_PRECEDING, + ) + if (!beforeEnd) continue + } + + total += this.extractAssistantResponseText(assistant).length + } + + return total + } + + private promoteUsageCounterAnchor(candidate: HTMLElement | null): HTMLElement | null { + let current = candidate + + for (let depth = 0; current && depth < 4; depth += 1) { + if (!current.parentElement) return null + + const parent = current.parentElement + const parentStyle = window.getComputedStyle(parent) + const isRowFlex = + parentStyle.display.includes("flex") && !parentStyle.flexDirection.startsWith("column") + + if (!isRowFlex) { + return current + } + + current = parent + } + + return candidate + } + + private extractConversationTitle(anchor: HTMLAnchorElement, slug: string): string { + const candidates: Array<{ text: string; score: number }> = [] + const row = + anchor.closest("li") || + anchor.closest("[role='listitem']") || + anchor.closest(".group") || + anchor.parentElement + + const directCandidates = [ + anchor.getAttribute("title"), + anchor.getAttribute("aria-label"), + row instanceof HTMLElement ? row.getAttribute("title") : null, + row instanceof HTMLElement ? row.getAttribute("aria-label") : null, + anchor.textContent, + row?.textContent || null, + ] + + directCandidates.forEach((value, index) => { + const normalized = this.normalizeConversationTitle(value || "", slug) + if (!normalized) return + candidates.push({ + text: normalized, + score: this.scoreConversationTitleCandidate(normalized, slug) + (6 - index), + }) + }) + + const scopedElements = [anchor, row].filter(Boolean) as Element[] + const scopedSelectors = ["[data-testid*='title']", "[dir='auto']", ".truncate", "span", "div"] + + for (const scope of scopedElements) { + for (const selector of scopedSelectors) { + scope.querySelectorAll(selector).forEach((candidate) => { + const normalized = this.normalizeConversationTitle(candidate.textContent || "", slug) + if (!normalized) return + candidates.push({ + text: normalized, + score: this.scoreConversationTitleCandidate(normalized, slug), + }) + }) + } + } + + candidates.sort((left, right) => right.score - left.score) + return candidates[0]?.text || slug + } + + private normalizeConversationTitle(rawTitle: string, slug: string): string { + const normalized = rawTitle.replace(/\s+/g, " ").trim() + if (!normalized) return "" + if (normalized === "/" || normalized === "..." || normalized === "···") return "" + if (/^\(\d+\)$/.test(normalized)) return "" + if (normalized === slug) return slug + return normalized + } + + private pickPreferredConversationTitle( + existingTitle: string | undefined, + incomingTitle: string | undefined, + slug: string, + ): string { + const existing = this.normalizeConversationTitle(existingTitle || "", slug) + const incoming = this.normalizeConversationTitle(incomingTitle || "", slug) + + if (!incoming) return existing || slug + if (!existing) return incoming || slug + if (existing === incoming) return incoming + + const existingIsSlug = this.isLikelySlugTitle(existing, slug) + const incomingIsSlug = this.isLikelySlugTitle(incoming, slug) + + if (existingIsSlug && !incomingIsSlug) return incoming + if (!existingIsSlug && incomingIsSlug) return existing + + return incoming + } + + private scoreConversationTitleCandidate(title: string, slug: string): number { + let score = 0 + + if (!this.isLikelySlugTitle(title, slug)) score += 100 + if (/[\u3400-\u9fff]/.test(title)) score += 40 + if (/\s/.test(title)) score += 15 + if (title.length >= 6) score += Math.min(title.length, 60) + if (title.length > 120) score -= 20 + if (/^(answer|share|thread|search)$/i.test(title)) score -= 80 + + return score + } + + private isLikelySlugTitle(title: string, slug?: string): boolean { + const normalized = title.trim().toLowerCase() + const normalizedSlug = slug?.trim().toLowerCase() || "" + + if (!normalized) return true + if (/[\u3400-\u9fff]/.test(normalized)) return false + if (normalizedSlug && normalized === normalizedSlug) return true + if (normalized.includes(" ") && !/^[a-z0-9][a-z0-9 -]*$/i.test(normalized)) return false + + return /^[a-z0-9]+(?:-[a-z0-9]+){2,}(?:-[a-z0-9_-]{4,})?$/i.test(normalized) + } + + private truncateText(text: string, maxLength: number): string { + return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text + } + + private isVisibleElement(element: HTMLElement | null): boolean { + if (!element) return false + if (element.offsetParent !== null) return true + + const style = window.getComputedStyle(element) + if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") { + return false + } + + const rect = element.getBoundingClientRect() + return rect.width > 0 && rect.height > 0 + } + + private isDisabledActionButton(element: HTMLElement | null): boolean { + if (!element) return true + return ( + element.hasAttribute("disabled") || + element.getAttribute("aria-disabled") === "true" || + element.classList.contains("disabled") + ) + } +} diff --git a/src/background.ts b/src/background.ts index d499d4fbd..18844c770 100644 --- a/src/background.ts +++ b/src/background.ts @@ -44,6 +44,8 @@ const OPHEL_TARGET_URLS = [ "https://www.doubao.com/*", "https://ima.qq.com/*", "https://chat.deepseek.com/*", + "https://www.perplexity.ai/*", + "https://perplexity.ai/*", "https://chatglm.cn/*", "https://chat.qwen.ai/*", "https://yuanbao.tencent.com/*", @@ -66,14 +68,21 @@ function decodeNotifId(notifId: string): { tabId: number; windowId: number } | n } // 点击通知时激活对应标签页并聚焦窗口 -chrome.notifications.onClicked.addListener((notifId) => { - const tab = decodeNotifId(notifId) - if (tab) { - chrome.tabs.update(tab.tabId, { active: true }).catch(() => {}) - chrome.windows.update(tab.windowId, { focused: true }).catch(() => {}) - } - chrome.notifications.clear(notifId) -}) +function getNotificationsApi(): typeof chrome.notifications | null { + return typeof chrome.notifications === "undefined" ? null : chrome.notifications +} + +const notificationsApi = getNotificationsApi() +if (notificationsApi) { + notificationsApi.onClicked.addListener((notifId) => { + const tab = decodeNotifId(notifId) + if (tab) { + chrome.tabs.update(tab.tabId, { active: true }).catch(() => {}) + chrome.windows.update(tab.windowId, { focused: true }).catch(() => {}) + } + notificationsApi.clear(notifId) + }) +} async function queryOphelTabs() { return chrome.tabs.query({ url: OPHEL_TARGET_URLS }) @@ -226,6 +235,11 @@ async function setupDynamicRules() { chrome.runtime.onMessage.addListener((message: ExtensionMessage, sender, sendResponse) => { switch (message.type) { case MSG_SHOW_NOTIFICATION: { + const notificationsApi = getNotificationsApi() + if (!notificationsApi) { + sendResponse({ success: false, error: "notifications_api_unavailable" }) + break + } // 将 tabId/windowId 编码进 notifId,SW 重启后点击通知仍可正确跳转 const tabId = sender.tab?.id const windowId = sender.tab?.windowId @@ -234,7 +248,7 @@ chrome.runtime.onMessage.addListener((message: ExtensionMessage, sender, sendRes ? encodeNotifId(tabId, windowId) : `ophel|${crypto.randomUUID()}` - chrome.notifications.create( + notificationsApi.create( notifId, { type: "basic", diff --git a/src/constants/defaults.ts b/src/constants/defaults.ts index 5a689f579..3364141a7 100644 --- a/src/constants/defaults.ts +++ b/src/constants/defaults.ts @@ -93,6 +93,7 @@ export const SITE_IDS = { IMA: "ima", DEEPSEEK: "deepseek", KIMI: "kimi", + PERPLEXITY: "perplexity", QIANWEN: "qianwen", QWENAI: "qwenai", YUANBAO: "yuanbao", @@ -171,6 +172,13 @@ export const SUPPORTED_AI_PLATFORMS: SupportedAiPlatform[] = [ url: "https://www.kimi.com", icon: "🌙", }, + { + id: SITE_IDS.PERPLEXITY, + name: "Perplexity", + pattern: /(?:www\.)?perplexity\.ai/, + url: "https://www.perplexity.ai", + icon: "P", + }, { id: SITE_IDS.ZAI, name: "Z.ai", diff --git a/src/contents/main.ts b/src/contents/main.ts index fd0484789..b16a88657 100644 --- a/src/contents/main.ts +++ b/src/contents/main.ts @@ -428,6 +428,8 @@ export const config: PlasmoCSConfig = { "https://ima.qq.com/*", "https://chat.deepseek.com/*", "https://www.kimi.com/*", + "https://www.perplexity.ai/*", + "https://perplexity.ai/*", "https://chatglm.cn/*", "https://chat.qwen.ai/*", "https://www.qianwen.com/*", @@ -445,8 +447,6 @@ if (!window.ophelInitialized) { const adapter = getAdapter() if (adapter) { - console.warn(`[Ophel] Loaded ${adapter.getName()} adapter on:`, window.location.hostname) - // 初始化适配器 adapter.afterPropertiesSet({}) diff --git a/src/contents/monitor-entry.ts b/src/contents/monitor-entry.ts index 5f5ee6f0f..754c410fc 100644 --- a/src/contents/monitor-entry.ts +++ b/src/contents/monitor-entry.ts @@ -15,6 +15,8 @@ export const config: PlasmoCSConfig = { "https://ima.qq.com/*", "https://chat.deepseek.com/*", "https://www.kimi.com/*", + "https://www.perplexity.ai/*", + "https://perplexity.ai/*", "https://chatglm.cn/*", "https://chat.qwen.ai/*", "https://yuanbao.tencent.com/*", diff --git a/src/contents/scroll-lock-main.ts b/src/contents/scroll-lock-main.ts index 3a0c853b5..f837006df 100644 --- a/src/contents/scroll-lock-main.ts +++ b/src/contents/scroll-lock-main.ts @@ -21,6 +21,8 @@ export const config: PlasmoCSConfig = { "https://ima.qq.com/*", "https://chat.deepseek.com/*", "https://www.kimi.com/*", + "https://www.perplexity.ai/*", + "https://perplexity.ai/*", "https://chatglm.cn/*", "https://chat.qwen.ai/*", "https://yuanbao.tencent.com/*", diff --git a/src/contents/ui-entry.tsx b/src/contents/ui-entry.tsx index d41899a74..f1a16a1d4 100644 --- a/src/contents/ui-entry.tsx +++ b/src/contents/ui-entry.tsx @@ -19,6 +19,8 @@ export const config: PlasmoCSConfig = { "https://ima.qq.com/*", "https://chat.deepseek.com/*", "https://www.kimi.com/*", + "https://www.perplexity.ai/*", + "https://perplexity.ai/*", "https://chatglm.cn/*", "https://chat.qwen.ai/*", "https://www.qianwen.com/*", @@ -58,6 +60,7 @@ export const mountShadowHost: PlasmoMountShadowHost = ({ hostname.includes("grok.com") || hostname.includes("claude.ai") || hostname.includes("deepseek.com") || + hostname.includes("perplexity.ai") || hostname.includes("yuanbao.tencent.com") const doMount = () => { diff --git a/src/core/conversation/manager.ts b/src/core/conversation/manager.ts index 13c83acf2..d9b4183fa 100644 --- a/src/core/conversation/manager.ts +++ b/src/core/conversation/manager.ts @@ -436,7 +436,7 @@ export class ConversationManager { let needsUpdate = false const updates: Partial = {} - if (info.title && info.title !== existing.title) { + if (this.shouldUpdateConversationTitle(existing.title, info.title, existing.siteId)) { updates.title = info.title needsUpdate = true } @@ -493,7 +493,7 @@ export class ConversationManager { this.monitorConversationTitle(el as HTMLElement, info.id) } else { // 检测标题变更 - if (info.title && info.title !== existing.title) { + if (this.shouldUpdateConversationTitle(existing.title, info.title, existing.siteId)) { getConversationsStore().updateConversation(info.id, { title: info.title }) this.notifyDataChange() } @@ -536,7 +536,7 @@ export class ConversationManager { let needsUpdate = false const updates: Partial = {} - if (currentInfo.title && currentInfo.title !== existing.title) { + if (this.shouldUpdateConversationTitle(existing.title, currentInfo.title, existing.siteId)) { updates.title = currentInfo.title needsUpdate = true } @@ -822,7 +822,7 @@ export class ConversationManager { const updates: Partial = {} let needsUpdate = false - if (existing.title !== item.title) { + if (this.shouldUpdateConversationTitle(existing.title, item.title, existing.siteId)) { updates.title = item.title needsUpdate = true } @@ -920,6 +920,35 @@ export class ConversationManager { return date.toLocaleDateString() } + private shouldUpdateConversationTitle( + existingTitle: string | undefined, + incomingTitle: string | undefined, + siteId?: string, + ): boolean { + const current = existingTitle?.trim() || "" + const next = incomingTitle?.trim() || "" + + if (!next || next === current) return false + if (!current) return true + + if ((siteId || this.siteAdapter.getSiteId()) === SITE_IDS.PERPLEXITY) { + const currentIsSlug = this.isPerplexitySlugTitle(current) + const nextIsSlug = this.isPerplexitySlugTitle(next) + + if (!currentIsSlug && nextIsSlug) return false + if (currentIsSlug && !nextIsSlug) return true + } + + return true + } + + private isPerplexitySlugTitle(title: string): boolean { + const normalized = title.trim().toLowerCase() + if (!normalized) return true + if (/[\u3400-\u9fff]/.test(normalized)) return false + return /^[a-z0-9]+(?:-[a-z0-9]+){2,}(?:-[a-z0-9_-]{4,})?$/i.test(normalized) + } + private resolveConversationForExport(convId: string): Conversation | null { const existing = this.conversations[convId] const currentInfo = this.siteAdapter.getCurrentConversationInfo() diff --git a/src/core/usage-counter-manager.ts b/src/core/usage-counter-manager.ts index 018471dde..3e9750ace 100644 --- a/src/core/usage-counter-manager.ts +++ b/src/core/usage-counter-manager.ts @@ -837,6 +837,11 @@ export class UsageCounterManager { const submitButton = this.adapter.findSubmitButton(editor) || this.findSubmitButtonBySelectors(editor) + const customAnchor = this.adapter.getUsageCounterMountAnchor(editor, submitButton) + if (customAnchor?.parentElement) { + return customAnchor + } + const strongCandidates = [ DOMToolkit.closestComposed(editor, "form"), submitButton ? DOMToolkit.closestComposed(submitButton, "form") : null, @@ -1232,7 +1237,10 @@ export class UsageCounterManager { let nodes: Element[] = [] try { - nodes = (DOMToolkit.query(selectors, { all: true, shadow: true }) as Element[]) || [] + // DOMToolkit.query 在 all=true 且传入 selector 数组时,会返回“首个命中的 selector 结果”, + // 而不是多个 selector 的并集。这里显式合并为一个复合 selector,确保用户消息和 AI 输出都能被统计。 + const combinedSelector = selectors.join(", ") + nodes = (DOMToolkit.query(combinedSelector, { all: true, shadow: true }) as Element[]) || [] } catch { nodes = [] } diff --git a/src/tabs/options/pages/FeaturesPage.tsx b/src/tabs/options/pages/FeaturesPage.tsx index d930611f9..e4fd10e3d 100644 --- a/src/tabs/options/pages/FeaturesPage.tsx +++ b/src/tabs/options/pages/FeaturesPage.tsx @@ -104,6 +104,7 @@ const UsageHistoryChart: React.FC<{ siteId: string }> = ({ siteId }) => { { id: SITE_IDS.IMA, label: "ima" }, { id: SITE_IDS.CHATGLM, label: "ChatGLM" }, { id: SITE_IDS.KIMI, label: "Kimi" }, + { id: SITE_IDS.PERPLEXITY, label: "Perplexity" }, { id: SITE_IDS.QIANWEN, label: "Qianwen" }, { id: SITE_IDS.QWENAI, label: "Qwen Studio" }, { id: SITE_IDS.ZAI, label: "Z.ai" }, diff --git a/src/utils/storage.ts b/src/utils/storage.ts index 0eed2b93e..a4568a431 100644 --- a/src/utils/storage.ts +++ b/src/utils/storage.ts @@ -45,6 +45,7 @@ export type SiteId = | "doubao" | "ima" | "deepseek" + | "perplexity" | "yuanbao" | "zai" | "_default" @@ -404,6 +405,7 @@ export const DEFAULT_SETTINGS: Settings = { doubao: { ...DEFAULT_SITE_THEME }, ima: { ...DEFAULT_SITE_THEME }, deepseek: { ...DEFAULT_SITE_THEME }, + perplexity: { ...DEFAULT_SITE_THEME }, yuanbao: { ...DEFAULT_SITE_THEME }, zai: { ...DEFAULT_SITE_THEME }, _default: { ...DEFAULT_SITE_THEME }, @@ -419,6 +421,7 @@ export const DEFAULT_SETTINGS: Settings = { doubao: { ...DEFAULT_PAGE_WIDTH }, ima: { ...DEFAULT_PAGE_WIDTH }, deepseek: { ...DEFAULT_PAGE_WIDTH }, + perplexity: { ...DEFAULT_PAGE_WIDTH }, yuanbao: { ...DEFAULT_PAGE_WIDTH }, zai: { ...DEFAULT_PAGE_WIDTH }, _default: { ...DEFAULT_PAGE_WIDTH }, @@ -430,6 +433,7 @@ export const DEFAULT_SETTINGS: Settings = { doubao: { ...DEFAULT_USER_QUERY_WIDTH }, ima: { ...DEFAULT_USER_QUERY_WIDTH }, deepseek: { ...DEFAULT_USER_QUERY_WIDTH }, + perplexity: { ...DEFAULT_USER_QUERY_WIDTH }, yuanbao: { ...DEFAULT_USER_QUERY_WIDTH }, zai: { ...DEFAULT_USER_QUERY_WIDTH }, _default: { ...DEFAULT_USER_QUERY_WIDTH }, @@ -441,6 +445,7 @@ export const DEFAULT_SETTINGS: Settings = { doubao: { ...DEFAULT_ZEN_MODE }, ima: { ...DEFAULT_ZEN_MODE }, deepseek: { ...DEFAULT_ZEN_MODE }, + perplexity: { ...DEFAULT_ZEN_MODE }, yuanbao: { ...DEFAULT_ZEN_MODE }, zai: { ...DEFAULT_ZEN_MODE }, _default: { ...DEFAULT_ZEN_MODE }, @@ -452,6 +457,7 @@ export const DEFAULT_SETTINGS: Settings = { doubao: { ...DEFAULT_CLEAN_MODE }, ima: { ...DEFAULT_CLEAN_MODE }, deepseek: { ...DEFAULT_CLEAN_MODE }, + perplexity: { ...DEFAULT_CLEAN_MODE }, yuanbao: { ...DEFAULT_CLEAN_MODE }, zai: { ...DEFAULT_CLEAN_MODE }, _default: { ...DEFAULT_CLEAN_MODE }, diff --git a/vite.userscript.config.ts b/vite.userscript.config.ts index df86cec75..f54b04e97 100644 --- a/vite.userscript.config.ts +++ b/vite.userscript.config.ts @@ -300,6 +300,8 @@ export default defineConfig({ "https://ima.qq.com/*", "https://chat.deepseek.com/*", "https://www.kimi.com/*", + "https://www.perplexity.ai/*", + "https://perplexity.ai/*", "https://chatglm.cn/*", "https://chat.qwen.ai/*", "https://www.qianwen.com/*", From c0e716cd46e05f569e8a83c8791d09618806ef2c Mon Sep 17 00:00:00 2001 From: KanameMadoka520 <2441883200@qq.com> Date: Fri, 17 Apr 2026 07:42:56 +0800 Subject: [PATCH 02/27] chore: drop userscript-only Perplexity config --- vite.userscript.config.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/vite.userscript.config.ts b/vite.userscript.config.ts index f54b04e97..df86cec75 100644 --- a/vite.userscript.config.ts +++ b/vite.userscript.config.ts @@ -300,8 +300,6 @@ export default defineConfig({ "https://ima.qq.com/*", "https://chat.deepseek.com/*", "https://www.kimi.com/*", - "https://www.perplexity.ai/*", - "https://perplexity.ai/*", "https://chatglm.cn/*", "https://chat.qwen.ai/*", "https://www.qianwen.com/*", From 2ee27fdaea8d5ee77f5da28ae15b0a41d09ea919 Mon Sep 17 00:00:00 2001 From: KanameMadoka520 <2441883200@qq.com> Date: Fri, 17 Apr 2026 09:10:02 +0800 Subject: [PATCH 03/27] fix: address Perplexity review feedback --- src/adapters/base.ts | 8 ++++ src/adapters/perplexity.ts | 60 +++++++++++++++--------- src/core/usage-counter-manager.ts | 77 +++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 21 deletions(-) diff --git a/src/adapters/base.ts b/src/adapters/base.ts index d41f8091d..bb9a0ca65 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -582,6 +582,11 @@ export abstract class SiteAdapter { * 精准查找提交按钮 * 当站点存在多个相邻 icon button,且仅靠通用选择器/距离判断容易误判时使用 */ + // NOTE: the legacy comment above refers to findSubmitButton rather than this mount hook. + /** + * Provide a custom anchor for mounting the usage monitor. + * Override when the default placement near the editor would break layout. + */ getUsageCounterMountAnchor( _editor: HTMLElement, _submitButton: HTMLElement | null, @@ -589,6 +594,9 @@ export abstract class SiteAdapter { return null } + /** + * Precisely locate the submit button when generic selectors are ambiguous. + */ findSubmitButton(_editor: HTMLElement | null): HTMLElement | null { return null } diff --git a/src/adapters/perplexity.ts b/src/adapters/perplexity.ts index 4134991f7..5dc8f992d 100644 --- a/src/adapters/perplexity.ts +++ b/src/adapters/perplexity.ts @@ -74,8 +74,7 @@ const THREAD_TITLE_SELECTOR = ".h-headerHeight.fixed.z-10 .cursor-pointer.transition.duration-300.hover\\:opacity-70, input[placeholder='Untitled']" const QUERY_EDIT_BUTTON_SELECTOR = 'button[data-testid="edit-query-button"]' -const THREAD_LIST_ENDPOINT = - "https://www.perplexity.ai/rest/thread/list_ask_threads?version=2.18&source=default" +const THREAD_LIST_ENDPOINT_PATH = "/rest/thread/list_ask_threads?version=2.18&source=default" const THREAD_LIST_PAGE_SIZE = 100 const THREAD_LIST_MAX_PAGES = 5 const THREAD_LIST_CACHE_TTL_MS = 5 * 60 * 1000 @@ -89,6 +88,18 @@ interface PerplexityThreadListEntry { title?: unknown } +function getPerplexityOrigin(): string { + if (typeof window !== "undefined" && HOSTNAMES.has(window.location.hostname)) { + return window.location.origin + } + + return "https://www.perplexity.ai" +} + +function buildPerplexityUrl(pathname: string): string { + return new URL(pathname, getPerplexityOrigin()).toString() +} + export class PerplexityAdapter extends SiteAdapter { private threadListCache: ConversationInfo[] = [] private threadListCacheExpiresAt = 0 @@ -121,7 +132,7 @@ export class PerplexityAdapter extends SiteAdapter { } getNewTabUrl(): string { - return "https://www.perplexity.ai/" + return buildPerplexityUrl("/") } getSessionName(): string | null { @@ -175,12 +186,7 @@ export class PerplexityAdapter extends SiteAdapter { editor.focus() if (editor instanceof HTMLTextAreaElement || editor instanceof HTMLInputElement) { - const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set - if (setter) { - setter.call(editor, content) - } else { - editor.value = content - } + this.setTextEntryValue(editor, content) editor.dispatchEvent( new InputEvent("input", { bubbles: true, composed: true, data: content }), ) @@ -228,12 +234,7 @@ export class PerplexityAdapter extends SiteAdapter { editor.focus() if (editor instanceof HTMLTextAreaElement || editor instanceof HTMLInputElement) { - const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set - if (setter) { - setter.call(editor, "") - } else { - editor.value = "" - } + this.setTextEntryValue(editor, "") editor.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true, data: "" })) editor.dispatchEvent(new Event("change", { bubbles: true })) if (editor instanceof HTMLTextAreaElement) { @@ -379,7 +380,7 @@ export class PerplexityAdapter extends SiteAdapter { } navigateToConversation(id: string, url?: string): boolean { - return super.navigateToConversation(id, url || `https://www.perplexity.ai/search/${id}`) + return super.navigateToConversation(id, url || buildPerplexityUrl(`/search/${id}`)) } getResponseContainerSelector(): string { @@ -746,6 +747,7 @@ export class PerplexityAdapter extends SiteAdapter { private mergeConversationInfos(...lists: ConversationInfo[][]): ConversationInfo[] { const merged = new Map() + const currentSessionId = this.getSessionId() for (const list of lists) { for (const item of list) { @@ -759,8 +761,8 @@ export class PerplexityAdapter extends SiteAdapter { ...existing, ...item, title: preferredTitle, - url: item.url || existing?.url || `https://www.perplexity.ai/search/${item.id}`, - isActive: Boolean(existing?.isActive || item.isActive), + url: item.url || existing?.url || buildPerplexityUrl(`/search/${item.id}`), + isActive: currentSessionId ? item.id === currentSessionId : false, }) } } @@ -774,7 +776,7 @@ export class PerplexityAdapter extends SiteAdapter { const currentSessionId = this.getSessionId() for (let page = 0; page < THREAD_LIST_MAX_PAGES; page += 1) { - const response = await fetch(THREAD_LIST_ENDPOINT, { + const response = await fetch(buildPerplexityUrl(THREAD_LIST_ENDPOINT_PATH), { method: "POST", credentials: "include", headers: { @@ -807,7 +809,7 @@ export class PerplexityAdapter extends SiteAdapter { typeof entry?.title === "string" ? entry.title : "", slug, ), - url: `https://www.perplexity.ai/search/${slug}`, + url: buildPerplexityUrl(`/search/${slug}`), isActive: slug === currentSessionId, }) } @@ -879,13 +881,29 @@ export class PerplexityAdapter extends SiteAdapter { } private shouldSkipOutlineElement(element: Element): boolean { + const userQueryAncestor = element.closest(USER_QUERY_SELECTOR) + return ( element.closest(".gh-root") !== null || element.closest(".gh-user-query-markdown") !== null || - element.closest(USER_QUERY_SELECTOR) !== null + (userQueryAncestor !== null && !element.matches(USER_QUERY_SELECTOR)) ) } + private setTextEntryValue(editor: HTMLTextAreaElement | HTMLInputElement, value: string): void { + const prototype = + editor instanceof HTMLTextAreaElement + ? HTMLTextAreaElement.prototype + : HTMLInputElement.prototype + const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set + + if (setter) { + setter.call(editor, value) + } else { + editor.value = value + } + } + private collectTopLevelBlocks(elements: T[]): T[] { return elements.filter( (element) => !elements.some((other) => other !== element && other.contains(element)), diff --git a/src/core/usage-counter-manager.ts b/src/core/usage-counter-manager.ts index 3e9750ace..5ae557eb9 100644 --- a/src/core/usage-counter-manager.ts +++ b/src/core/usage-counter-manager.ts @@ -1235,6 +1235,49 @@ export class UsageCounterManager { } } + { + const nodes = this.queryAllSelectors(selectors) + if (nodes.length === 0) { + return { + conversationText: "", + conversationChars: 0, + conversationTokens: 0, + outputChars: 0, + outputTokens: 0, + } + } + + const userSelector = this.adapter.getUserQuerySelector() + const uniqueNodes = this.sortElementsInDomOrder(Array.from(new Set(nodes))) + const conversationChunks: string[] = [] + const outputChunks: string[] = [] + + uniqueNodes.forEach((node) => { + const isUser = userSelector ? this.matchesSelector(node, userSelector) : false + const text = isUser + ? this.adapter.extractUserQueryMarkdown(node) + : this.adapter.extractAssistantResponseText(node) + const normalized = normalizeText(text || node.textContent || "") + if (!normalized) return + + conversationChunks.push(normalized) + if (!isUser) { + outputChunks.push(normalized) + } + }) + + const conversationText = conversationChunks.join("\n") + const outputText = outputChunks.join("\n") + + return { + conversationText, + conversationChars: conversationText.length, + conversationTokens: this.estimateTokens(conversationText), + outputChars: outputText.length, + outputTokens: this.estimateTokens(outputText), + } + } + let nodes: Element[] = [] try { // DOMToolkit.query 在 all=true 且传入 selector 数组时,会返回“首个命中的 selector 结果”, @@ -1288,6 +1331,40 @@ export class UsageCounterManager { } } + private queryAllSelectors(selectors: string[]): Element[] { + const results: Element[] = [] + const seen = new Set() + + selectors.forEach((selector) => { + let matched: Element[] = [] + try { + matched = (DOMToolkit.query(selector, { all: true, shadow: true }) as Element[]) || [] + } catch { + matched = [] + } + + matched.forEach((element) => { + if (!seen.has(element)) { + seen.add(element) + results.push(element) + } + }) + }) + + return results + } + + private sortElementsInDomOrder(elements: Element[]): Element[] { + return [...elements].sort((left, right) => { + if (left === right) return 0 + + const position = left.compareDocumentPosition(right) + if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1 + if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1 + return 0 + }) + } + private matchesSelector(element: Element, selector: string): boolean { try { return element.matches(selector) From 198b82a34d74ce184a668e42a71cfb15cc415ac6 Mon Sep 17 00:00:00 2001 From: KanameMadoka520 <2441883200@qq.com> Date: Fri, 17 Apr 2026 10:28:29 +0800 Subject: [PATCH 04/27] docs: clarify usage monitor mount hook comment --- src/adapters/base.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/adapters/base.ts b/src/adapters/base.ts index bb9a0ca65..ef28b7d7c 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -578,11 +578,6 @@ export abstract class SiteAdapter { return [] } - /** - * 精准查找提交按钮 - * 当站点存在多个相邻 icon button,且仅靠通用选择器/距离判断容易误判时使用 - */ - // NOTE: the legacy comment above refers to findSubmitButton rather than this mount hook. /** * Provide a custom anchor for mounting the usage monitor. * Override when the default placement near the editor would break layout. From 59df35c0e50b5cdfeafba66c6492c8d1e82d9176 Mon Sep 17 00:00:00 2001 From: KanameMadoka520 <2441883200@qq.com> Date: Fri, 17 Apr 2026 20:28:03 +0800 Subject: [PATCH 05/27] fix: improve perplexity adapter parity --- src/adapters/perplexity.ts | 563 ++++++++++++++++++-- src/constants/ui.ts | 5 + src/core/theme-manager.ts | 53 +- src/tabs/options/pages/SiteSettingsPage.tsx | 11 + src/utils/storage.ts | 1 + 5 files changed, 576 insertions(+), 57 deletions(-) diff --git a/src/adapters/perplexity.ts b/src/adapters/perplexity.ts index 5dc8f992d..d7c7a9630 100644 --- a/src/adapters/perplexity.ts +++ b/src/adapters/perplexity.ts @@ -13,10 +13,14 @@ import { htmlToMarkdown } from "~utils/exporter" import { SiteAdapter, type ConversationInfo, + type ConversationDeleteTarget, type ConversationObserverConfig, type ExportConfig, + type ModelSwitcherConfig, type NetworkMonitorConfig, type OutlineItem, + type SiteDeleteConversationResult, + type ZenModeConfig, } from "./base" const HOSTNAMES = new Set(["www.perplexity.ai", "perplexity.ai"]) @@ -78,6 +82,34 @@ const THREAD_LIST_ENDPOINT_PATH = "/rest/thread/list_ask_threads?version=2.18&so const THREAD_LIST_PAGE_SIZE = 100 const THREAD_LIST_MAX_PAGES = 5 const THREAD_LIST_CACHE_TTL_MS = 5 * 60 * 1000 +const THREAD_LIST_REQUEST_TIMEOUT_MS = 10_000 +const DELETE_FLOW_TIMEOUT_MS = 5_000 + +const THREAD_ACTION_BUTTON_SELECTORS = [ + 'button[aria-label="Thread actions"]', + 'button[aria-label*="Thread actions" i]', + 'button[aria-label*="话题"]', + 'button[aria-label*="对话"]', + 'button[aria-label*="线程"]', +] + +const DELETE_MENU_ITEM_LABELS = ["Delete", "删除"] +const CONFIRM_BUTTON_LABELS = ["Confirm", "确认", "Delete", "删除"] + +const THREAD_ACTION_LABELS = ["Thread actions", "话题操作", "对话操作", "线程操作"] + +const ZEN_MODE_HIDE_SELECTORS = [ + ".group\\/sidebar", + "aside", + 'nav:has(a[href^="/search/"])', + 'nav:has(a[href^="/page/"])', +] +const CLEAN_MODE_HIDE_SELECTORS = [ + "footer", + "[data-testid='disclaimer']", + "[data-testid='assistant-disclaimer']", + "[data-testid='legal-disclaimer']", +] const EXPORT_ROLE_ATTR = "data-gh-perplexity-export-role" const EXPORT_USER_SELECTOR = `[${EXPORT_ROLE_ATTR}="user"]` @@ -103,6 +135,7 @@ function buildPerplexityUrl(pathname: string): string { export class PerplexityAdapter extends SiteAdapter { private threadListCache: ConversationInfo[] = [] private threadListCacheExpiresAt = 0 + private loadAllConversationsPromise: Promise | null = null match(): boolean { return HOSTNAMES.has(window.location.hostname) @@ -368,15 +401,38 @@ export class PerplexityAdapter extends SiteAdapter { } } + async deleteConversationOnSite( + target: ConversationDeleteTarget, + ): Promise { + const success = await this.deleteConversationViaUi(target.id) + + return { + id: target.id, + success, + method: success ? "ui" : "none", + reason: success ? undefined : "ui_failed", + } + } + async loadAllConversations(): Promise { - try { - const apiThreads = await this.fetchThreadsViaApi() - if (apiThreads.length > 0) { - this.cacheThreadList(apiThreads) - } - } catch (error) { - console.warn("[PerplexityAdapter] Failed to preload thread list:", error) + if (this.loadAllConversationsPromise) { + return this.loadAllConversationsPromise } + + this.loadAllConversationsPromise = (async () => { + try { + const apiThreads = await this.fetchThreadsViaApi() + if (apiThreads.length > 0) { + this.cacheThreadList(apiThreads) + } + } catch (error) { + console.warn("[PerplexityAdapter] Failed to preload thread list:", error) + } finally { + this.loadAllConversationsPromise = null + } + })() + + return this.loadAllConversationsPromise } navigateToConversation(id: string, url?: string): boolean { @@ -395,6 +451,17 @@ export class PerplexityAdapter extends SiteAdapter { return USER_QUERY_SELECTOR } + getLatestReplyText(): string | null { + const responses = this.collectTopLevelBlocks( + Array.from(document.querySelectorAll(ASSISTANT_MESSAGE_SELECTOR)).filter( + (element) => + !this.shouldSkipExportElement(element) && !element.closest(USER_QUERY_SELECTOR), + ), + ) + const last = responses[responses.length - 1] + return last ? this.extractAssistantResponseText(last) : null + } + extractUserQueryText(element: Element): string { const source = (this.findUserContentRoot(element) || element).cloneNode(true) as HTMLElement source @@ -482,31 +549,28 @@ export class PerplexityAdapter extends SiteAdapter { const userQuerySelector = this.getUserQuerySelector() if (!userQuerySelector) return outline - const headingSelectors = Array.from({ length: maxLevel }, (_, index) => `h${index + 1}`).join( - ", ", - ) + const headings = this.collectOutlineHeadingCandidates(container, maxLevel) const rawUserQueries = Array.from(container.querySelectorAll(userQuerySelector)) const userQueries = this.collectTopLevelBlocks(rawUserQueries).filter( (element) => !this.shouldSkipOutlineElement(element), ) const userQuerySet = new Set(userQueries) - const allElements = Array.from( - container.querySelectorAll(`${userQuerySelector}, ${headingSelectors}`), - ).filter((element) => { - if (element.matches(userQuerySelector)) { - return userQuerySet.has(element) - } - - return ( - !this.shouldSkipOutlineElement(element) && - element.closest(ASSISTANT_MESSAGE_SELECTOR) !== null - ) - }) + const allElements = [...userQueries, ...headings].sort((left, right) => + this.compareElementsByDocumentOrder(left, right), + ) allElements.forEach((element, index) => { const isUserQuery = element.matches(userQuerySelector) - const tagName = element.tagName.toLowerCase() + const headingLevel = isUserQuery ? null : this.getOutlineHeadingLevel(element) + + if (element.matches(userQuerySelector)) { + if (!userQuerySet.has(element)) { + return + } + } else if (headingLevel === null) { + return + } if (isUserQuery) { if (!includeUserQueries) return @@ -538,16 +602,11 @@ export class PerplexityAdapter extends SiteAdapter { return } - if (!/^h[1-6]$/.test(tagName)) return - - const level = parseInt(tagName.charAt(1), 10) - if (Number.isNaN(level) || level > maxLevel) return - const text = element.textContent?.trim() || "" if (!text) return const item: OutlineItem = { - level, + level: headingLevel, text, element, } @@ -561,13 +620,10 @@ export class PerplexityAdapter extends SiteAdapter { break } - const candidateTagName = candidate.tagName.toLowerCase() - if (/^h[1-6]$/.test(candidateTagName)) { - const candidateLevel = parseInt(candidateTagName.charAt(1), 10) - if (!Number.isNaN(candidateLevel) && candidateLevel <= level) { - nextBoundary = candidate - break - } + const candidateLevel = this.getOutlineHeadingLevel(candidate) + if (candidateLevel !== null && candidateLevel <= headingLevel) { + nextBoundary = candidate + break } } @@ -602,6 +658,101 @@ export class PerplexityAdapter extends SiteAdapter { return [{ selector: USER_QUERY_SELECTOR, property: "maxWidth" }] } + getZenModeConfig(): ZenModeConfig | null { + return { + hide: [...ZEN_MODE_HIDE_SELECTORS], + styles: [ + { selector: ":root", property: "--sidebar-pinned-width", value: "0px" }, + { + selector: "main", + property: "padding-left", + value: "0px", + extraCss: "padding-right: 0 !important; width: 100% !important;", + }, + { + selector: "[role='tabpanel']", + property: "max-width", + value: "none", + extraCss: "width: 100% !important;", + }, + { + selector: "main .mx-auto", + property: "max-width", + value: "min(1120px, calc(100vw - 48px))", + }, + ], + } + } + + getCleanModeConfig(): ZenModeConfig | null { + return { + hide: [...CLEAN_MODE_HIDE_SELECTORS], + } + } + + getModelSwitcherConfig(keyword: string): ModelSwitcherConfig | null { + return { + targetModelKeyword: keyword, + selectorButtonSelectors: [ + 'button[aria-label="Select AI model"]', + 'button[aria-label*="Select AI model" i]', + 'button[aria-label*="model" i]', + 'button[aria-label*="模型"]', + ], + menuItemSelector: "[role='menuitemradio'], [role='menuitemcheckbox'], [role='menuitem']", + checkInterval: 1000, + maxAttempts: 12, + menuRenderDelay: 200, + } + } + + async toggleTheme(targetMode: "light" | "dark" | "system"): Promise { + try { + const root = document.documentElement + const resolvedMode = + targetMode === "system" + ? window.matchMedia?.("(prefers-color-scheme: dark)").matches + ? "dark" + : "light" + : targetMode + + root.setAttribute("data-color-scheme", resolvedMode) + document.body.setAttribute("data-color-scheme", resolvedMode) + + root.classList.toggle("dark", resolvedMode === "dark") + root.classList.toggle("light", resolvedMode === "light") + document.body.classList.toggle("dark", resolvedMode === "dark") + document.body.classList.toggle("light", resolvedMode === "light") + root.style.colorScheme = resolvedMode + + try { + localStorage.setItem("theme", targetMode) + localStorage.setItem("appearance", targetMode) + window.dispatchEvent( + new StorageEvent("storage", { + key: "theme", + newValue: targetMode, + storageArea: localStorage, + }), + ) + window.dispatchEvent( + new StorageEvent("storage", { + key: "appearance", + newValue: targetMode, + storageArea: localStorage, + }), + ) + } catch { + // ignore localStorage access issues + } + + return true + } catch (error) { + console.error("[PerplexityAdapter] toggleTheme error:", error) + return false + } + } + private selectEditorContents(editor: HTMLElement): void { const selection = window.getSelection() if (!selection) return @@ -674,6 +825,47 @@ export class PerplexityAdapter extends SiteAdapter { } } + private async deleteConversationViaUi(id: string): Promise { + const sidebarLink = await this.waitForValue(() => this.findSidebarConversationLink(id), 600) + if (sidebarLink) { + const container = this.findConversationItemContainer(sidebarLink) + const actionButton = await this.openThreadActionMenu({ + preferredContainer: container, + triggerScope: container || sidebarLink, + }) + + if (actionButton && (await this.confirmDeleteFromOpenMenu())) { + this.syncConversationListAfterDelete(id) + return true + } + } + + if (this.getSessionId() !== id && !this.navigateToConversation(id)) { + return false + } + + const ready = await this.waitForCondition( + () => this.getSessionId() === id, + DELETE_FLOW_TIMEOUT_MS, + ) + if (!ready) return false + + const actionButton = await this.openThreadActionMenu({ + preferredContainer: document.querySelector( + ".h-headerHeight.fixed.z-10", + ) as HTMLElement | null, + triggerScope: document.body, + }) + if (!actionButton) return false + + const deleted = await this.confirmDeleteFromOpenMenu() + if (deleted) { + this.syncConversationListAfterDelete(id) + } + + return deleted + } + private findScrollableParent(element: Element | null): HTMLElement | null { let current = element instanceof HTMLElement ? element : element?.parentElement || null @@ -687,6 +879,20 @@ export class PerplexityAdapter extends SiteAdapter { return null } + private syncConversationListAfterDelete(id: string): void { + this.threadListCache = this.threadListCache.filter((item) => item.id !== id) + this.threadListCacheExpiresAt = Math.min(this.threadListCacheExpiresAt, Date.now() + 10_000) + + document.querySelectorAll(SIDEBAR_LINK_SELECTOR).forEach((element) => { + const anchor = element as HTMLAnchorElement + if (this.parseThreadSlugFromUrl(anchor.getAttribute("href") || anchor.href || "") !== id) + return + + const container = this.findConversationItemContainer(anchor) + ;(container || anchor).remove() + }) + } + private parseThreadSlugFromUrl(url: string): string { try { const parsed = new URL(url, window.location.origin) @@ -733,6 +939,28 @@ export class PerplexityAdapter extends SiteAdapter { return Array.from(result.values()) } + private findSidebarConversationLink(id: string): HTMLAnchorElement | null { + const links = document.querySelectorAll(SIDEBAR_LINK_SELECTOR) + for (const link of Array.from(links)) { + const anchor = link as HTMLAnchorElement + const slug = this.parseThreadSlugFromUrl(anchor.getAttribute("href") || anchor.href || "") + if (slug === id) { + return anchor + } + } + + return null + } + + private findConversationItemContainer(anchor: Element | null): HTMLElement | null { + if (!(anchor instanceof HTMLElement)) return null + + return (anchor.closest("li") || + anchor.closest("[role='listitem']") || + anchor.closest(".group") || + anchor.parentElement) as HTMLElement | null + } + private getCachedThreadList(): ConversationInfo[] { if (Date.now() > this.threadListCacheExpiresAt) { return [] @@ -776,26 +1004,51 @@ export class PerplexityAdapter extends SiteAdapter { const currentSessionId = this.getSessionId() for (let page = 0; page < THREAD_LIST_MAX_PAGES; page += 1) { - const response = await fetch(buildPerplexityUrl(THREAD_LIST_ENDPOINT_PATH), { - method: "POST", - credentials: "include", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - limit: THREAD_LIST_PAGE_SIZE, - offset: page * THREAD_LIST_PAGE_SIZE, - search_term: "", - with_temporary_threads: false, - }), - }) + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), THREAD_LIST_REQUEST_TIMEOUT_MS) + + let responseText = "" + + try { + const response = await fetch(buildPerplexityUrl(THREAD_LIST_ENDPOINT_PATH), { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + limit: THREAD_LIST_PAGE_SIZE, + offset: page * THREAD_LIST_PAGE_SIZE, + search_term: "", + with_temporary_threads: false, + }), + signal: controller.signal, + }) + + responseText = await response.text() - if (!response.ok) { - throw new Error(`HTTP ${response.status}`) + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + throw new Error(`Thread list request timed out after ${THREAD_LIST_REQUEST_TIMEOUT_MS}ms`) + } + + throw error + } finally { + clearTimeout(timeoutId) + } + + let data: PerplexityThreadListEntry[] + try { + data = JSON.parse(responseText) as PerplexityThreadListEntry[] + } catch { + const preview = responseText.replace(/\s+/g, " ").trim().slice(0, 160) + throw new Error(`Invalid thread list response JSON${preview ? `: ${preview}` : ""}`) } - const data = JSON.parse(await response.text()) as PerplexityThreadListEntry[] if (!Array.isArray(data) || data.length === 0) break for (const entry of data) { @@ -824,6 +1077,115 @@ export class PerplexityAdapter extends SiteAdapter { return results } + private async openThreadActionMenu({ + preferredContainer, + triggerScope, + }: { + preferredContainer: HTMLElement | null + triggerScope: ParentNode + }): Promise { + if (preferredContainer) { + this.revealConversationActions(preferredContainer) + } + + const actionButton = await this.waitForValue( + () => this.findThreadActionButton(preferredContainer, triggerScope), + 1500, + ) + if (!actionButton) return null + + this.simulateClick(actionButton) + + const opened = await this.waitForCondition( + () => this.findOpenDeleteMenuItem() !== null, + DELETE_FLOW_TIMEOUT_MS, + ) + return opened ? actionButton : null + } + + private revealConversationActions(container: HTMLElement): void { + const events = [ + new PointerEvent("pointerenter", { bubbles: true, composed: true }), + new MouseEvent("mouseenter", { bubbles: true, composed: true }), + new MouseEvent("mouseover", { bubbles: true, composed: true }), + ] + + events.forEach((event) => container.dispatchEvent(event)) + } + + private findThreadActionButton( + preferredContainer: HTMLElement | null, + triggerScope: ParentNode, + ): HTMLElement | null { + const scopes = [preferredContainer, triggerScope, document.body].filter(Boolean) as ParentNode[] + + for (const scope of scopes) { + for (const selector of THREAD_ACTION_BUTTON_SELECTORS) { + const candidates = scope.querySelectorAll(selector) + for (const candidate of Array.from(candidates)) { + if (!(candidate instanceof HTMLElement)) continue + if (!this.isVisibleElement(candidate)) continue + if (this.matchesUiLabel(candidate, THREAD_ACTION_LABELS)) { + return candidate + } + } + } + } + + return null + } + + private async confirmDeleteFromOpenMenu(): Promise { + const deleteItem = await this.waitForValue( + () => this.findOpenDeleteMenuItem(), + DELETE_FLOW_TIMEOUT_MS, + ) + if (!deleteItem) return false + + this.simulateClick(deleteItem) + + const confirmButton = await this.waitForValue( + () => this.findConfirmationButton(), + DELETE_FLOW_TIMEOUT_MS, + ) + if (!confirmButton) return false + + this.simulateClick(confirmButton) + + await this.sleep(300) + return true + } + + private findOpenDeleteMenuItem(): HTMLElement | null { + const menus = document.querySelectorAll("[role='menu'], [data-radix-popper-content-wrapper]") + for (const menu of Array.from(menus)) { + const buttons = menu.querySelectorAll("button, [role='menuitem'], [role='menuitemradio']") + for (const button of Array.from(buttons)) { + if (!(button instanceof HTMLElement)) continue + if (this.matchesUiLabel(button, DELETE_MENU_ITEM_LABELS)) { + return button + } + } + } + + return null + } + + private findConfirmationButton(): HTMLElement | null { + const dialogs = document.querySelectorAll("[role='dialog'], [data-radix-portal]") + for (const dialog of Array.from(dialogs)) { + const buttons = dialog.querySelectorAll("button, [role='button']") + for (const button of Array.from(buttons)) { + if (!(button instanceof HTMLElement)) continue + if (this.matchesUiLabel(button, CONFIRM_BUTTON_LABELS)) { + return button + } + } + } + + return null + } + private findUserContentRoot(element: Element): HTMLElement | null { const candidates = Array.from(element.querySelectorAll(USER_QUERY_CONTENT_SELECTOR)).filter( (candidate) => candidate instanceof HTMLElement, @@ -910,6 +1272,55 @@ export class PerplexityAdapter extends SiteAdapter { ) } + private collectOutlineHeadingCandidates(container: Element, maxLevel: number): Element[] { + const selectors = Array.from({ length: maxLevel }, (_, index) => { + const level = index + 1 + return [`h${level}`, `[role='heading'][aria-level='${level}']`] + }).flat() + + const candidates = Array.from(new Set(container.querySelectorAll(selectors.join(", ")))) + return this.collectTopLevelBlocks( + candidates.filter((element) => this.isOutlineHeadingCandidate(element, maxLevel)), + ) + } + + private isOutlineHeadingCandidate(element: Element, maxLevel: number): boolean { + if (this.shouldSkipOutlineElement(element)) return false + if (element.closest("nav, aside, header, footer, [role='dialog'], button, [role='button']")) { + return false + } + + const level = this.getOutlineHeadingLevel(element) + if (level === null || level > maxLevel) return false + + const text = element.textContent?.trim() || "" + return Boolean(text) + } + + private getOutlineHeadingLevel(element: Element): number | null { + const tagName = element.tagName.toLowerCase() + if (/^h[1-6]$/.test(tagName)) { + const level = parseInt(tagName.charAt(1), 10) + return Number.isNaN(level) ? null : level + } + + if (element.getAttribute("role") === "heading") { + const rawLevel = parseInt(element.getAttribute("aria-level") || "", 10) + return Number.isNaN(rawLevel) ? null : rawLevel + } + + return null + } + + private compareElementsByDocumentOrder(left: Element, right: Element): number { + if (left === right) return 0 + + const position = left.compareDocumentPosition(right) + if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1 + if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1 + return 0 + } + private calculateAssistantWordCountBetween( container: Element, startElement: Element, @@ -1084,4 +1495,50 @@ export class PerplexityAdapter extends SiteAdapter { element.classList.contains("disabled") ) } + + private matchesUiLabel(element: HTMLElement, labels: string[]): boolean { + const text = this.normalizeUiText( + [ + element.textContent || "", + element.getAttribute("aria-label") || "", + element.getAttribute("title") || "", + ].join(" "), + ) + + return labels.some((label) => { + const normalized = this.normalizeUiText(label) + return Boolean(normalized) && text.includes(normalized) + }) + } + + private normalizeUiText(value: string): string { + return value.replace(/\s+/g, " ").trim().toLowerCase() + } + + private async waitForValue(getter: () => T | null, timeoutMs: number): Promise { + const start = Date.now() + + while (Date.now() - start < timeoutMs) { + const value = getter() + if (value) return value + await this.sleep(80) + } + + return null + } + + private async waitForCondition(check: () => boolean, timeoutMs: number): Promise { + const start = Date.now() + + while (Date.now() - start < timeoutMs) { + if (check()) return true + await this.sleep(80) + } + + return false + } + + private async sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)) + } } diff --git a/src/constants/ui.ts b/src/constants/ui.ts index d59e09bb1..2ac1fbe30 100644 --- a/src/constants/ui.ts +++ b/src/constants/ui.ts @@ -689,6 +689,11 @@ export const SETTINGS_SEARCH_ITEMS: SettingsSearchItem[] = [ title: "模型锁定:Grok", keywords: ["model lock", "grok", "模型锁定"], }, + { + settingId: "model-lock-perplexity", + title: "模型锁定:Perplexity", + keywords: ["model lock", "perplexity", "perplexity.ai", "模型锁定"], + }, { settingId: "model-lock-qianwen", title: "模型锁定:Qianwen", diff --git a/src/core/theme-manager.ts b/src/core/theme-manager.ts index 1e036b108..d0f4d8f1e 100644 --- a/src/core/theme-manager.ts +++ b/src/core/theme-manager.ts @@ -193,6 +193,27 @@ export class ThemeManager { ) return true } + case SITE_IDS.PERPLEXITY: { + localStorage.setItem("theme", "system") + localStorage.setItem("appearance", "system") + document.documentElement.setAttribute("data-color-scheme", targetMode) + document.documentElement.classList.toggle("dark", targetMode === "dark") + document.documentElement.classList.toggle("light", targetMode === "light") + document.documentElement.style.colorScheme = targetMode + if (document.body) { + document.body.classList.toggle("dark", targetMode === "dark") + document.body.classList.toggle("light", targetMode === "light") + document.body.style.colorScheme = targetMode + } + window.dispatchEvent( + new StorageEvent("storage", { + key: "appearance", + newValue: "system", + storageArea: localStorage, + }), + ) + return true + } case SITE_IDS.QWENAI: { const previousTheme = localStorage.getItem("theme") localStorage.setItem("theme", "system") @@ -513,6 +534,15 @@ export class ThemeManager { return "light" } + const dataColorScheme = + document.documentElement.getAttribute("data-color-scheme") || + document.body.getAttribute("data-color-scheme") + if (dataColorScheme === "dark") { + return "dark" + } else if (dataColorScheme === "light") { + return "light" + } + // 4. Style colorScheme (Gemini Enterprise 使用这种方式) if (document.body.style.colorScheme === "dark") { return "dark" @@ -539,6 +569,21 @@ export class ThemeManager { } return null } + case SITE_IDS.PERPLEXITY: { + const storedTheme = localStorage.getItem("appearance") || localStorage.getItem("theme") + if (storedTheme === "light" || storedTheme === "dark" || storedTheme === "system") { + return storedTheme + } + if (storedTheme === "auto") { + return "system" + } + + const colorScheme = document.documentElement.getAttribute("data-color-scheme") + if (colorScheme === "light" || colorScheme === "dark") { + return colorScheme + } + return null + } case SITE_IDS.AISTUDIO: { const prefStr = localStorage.getItem("aiStudioUserPreference") if (!prefStr) return null @@ -779,12 +824,12 @@ ${cssVars} // 重新观察 body 和 html 元素 this.hostThemeObserver.observe(document.body, { attributes: true, - attributeFilter: ["class", "data-theme", "style"], + attributeFilter: ["class", "data-theme", "data-color-scheme", "style"], }) // 同时监听 html 元素的 class 和 data-theme 属性(ChatGPT 使用 html.dark/light) this.hostThemeObserver.observe(document.documentElement, { attributes: true, - attributeFilter: ["class", "data-theme"], + attributeFilter: ["class", "data-theme", "data-color-scheme"], }) } } @@ -848,13 +893,13 @@ ${cssVars} // 监听 body 的 class、data-theme、style 属性变化 this.hostThemeObserver.observe(document.body, { attributes: true, - attributeFilter: ["class", "data-theme", "style"], + attributeFilter: ["class", "data-theme", "data-color-scheme", "style"], }) // 同时监听 html 元素的 class 和 data-theme 属性(ChatGPT 使用 html.dark/light) this.hostThemeObserver.observe(document.documentElement, { attributes: true, - attributeFilter: ["class", "data-theme"], + attributeFilter: ["class", "data-theme", "data-color-scheme"], }) } } diff --git a/src/tabs/options/pages/SiteSettingsPage.tsx b/src/tabs/options/pages/SiteSettingsPage.tsx index 642e1a212..529450e9a 100644 --- a/src/tabs/options/pages/SiteSettingsPage.tsx +++ b/src/tabs/options/pages/SiteSettingsPage.tsx @@ -610,6 +610,17 @@ const SiteSettingsPage: React.FC = ({ siteId, initialTab settingId="model-lock-grok" /> + {/* Perplexity */} + showPrerequisiteToast(modelLockLabel)} + settingId="model-lock-perplexity" + /> + {/* Kimi */} Date: Fri, 17 Apr 2026 20:51:21 +0800 Subject: [PATCH 06/27] fix: refine perplexity outline behavior --- src/adapters/perplexity.ts | 90 ++++++++++++++++++++++++++++++++++++- src/core/outline-manager.ts | 24 ++++++++-- 2 files changed, 109 insertions(+), 5 deletions(-) diff --git a/src/adapters/perplexity.ts b/src/adapters/perplexity.ts index d7c7a9630..b2b6bb0b3 100644 --- a/src/adapters/perplexity.ts +++ b/src/adapters/perplexity.ts @@ -551,10 +551,16 @@ export class PerplexityAdapter extends SiteAdapter { const headings = this.collectOutlineHeadingCandidates(container, maxLevel) const rawUserQueries = Array.from(container.querySelectorAll(userQuerySelector)) - const userQueries = this.collectTopLevelBlocks(rawUserQueries).filter( - (element) => !this.shouldSkipOutlineElement(element), + const userQueries = this.dedupeUserQueries( + this.collectTopLevelBlocks(rawUserQueries).filter( + (element) => !this.shouldSkipOutlineElement(element), + ), + container, ) const userQuerySet = new Set(userQueries) + const userQueryKeyCounts = new Map() + const headingKeyCounts = new Map() + let currentGroupId = "preamble" const allElements = [...userQueries, ...headings].sort((left, right) => this.compareElementsByDocumentOrder(left, right), @@ -585,6 +591,12 @@ export class PerplexityAdapter extends SiteAdapter { isUserQuery: true, isTruncated: fullText.length > 80, } + item.id = this.buildOutlineOccurrenceId( + "query", + this.normalizeUiText(fullText), + userQueryKeyCounts, + ) + currentGroupId = item.id if (showWordCount) { const nextUserQuery = @@ -610,6 +622,11 @@ export class PerplexityAdapter extends SiteAdapter { text, element, } + item.id = this.buildOutlineOccurrenceId( + `heading::${currentGroupId}::${headingLevel}`, + this.normalizeUiText(text), + headingKeyCounts, + ) if (showWordCount) { let nextBoundary: Element | null = null @@ -1272,6 +1289,38 @@ export class PerplexityAdapter extends SiteAdapter { ) } + private dedupeUserQueries(elements: T[], container: Element): T[] { + if (elements.length <= 1) return elements + + const sorted = [...elements].sort((left, right) => + this.compareElementsByDocumentOrder(left, right), + ) + const deduped: T[] = [] + + for (const element of sorted) { + const previous = deduped[deduped.length - 1] + if (!previous) { + deduped.push(element) + continue + } + + const previousText = this.normalizeUiText(this.extractUserQueryText(previous)) + const currentText = this.normalizeUiText(this.extractUserQueryText(element)) + if ( + previousText && + previousText === currentText && + !this.hasAssistantResponseBetween(container, previous, element) + ) { + deduped[deduped.length - 1] = element + continue + } + + deduped.push(element) + } + + return deduped + } + private collectOutlineHeadingCandidates(container: Element, maxLevel: number): Element[] { const selectors = Array.from({ length: maxLevel }, (_, index) => { const level = index + 1 @@ -1321,6 +1370,43 @@ export class PerplexityAdapter extends SiteAdapter { return 0 } + private hasAssistantResponseBetween( + container: Element, + startElement: Element, + endElement: Element, + ): boolean { + const assistants = this.collectTopLevelBlocks( + Array.from(container.querySelectorAll(ASSISTANT_MESSAGE_SELECTOR)).filter( + (element) => + !this.shouldSkipExportElement(element) && !element.closest(USER_QUERY_SELECTOR), + ), + ) + + return assistants.some((assistant) => { + const afterStart = Boolean( + startElement.compareDocumentPosition(assistant) & Node.DOCUMENT_POSITION_FOLLOWING, + ) + if (!afterStart) return false + + const beforeEnd = Boolean( + endElement.compareDocumentPosition(assistant) & Node.DOCUMENT_POSITION_PRECEDING, + ) + return beforeEnd + }) + } + + private buildOutlineOccurrenceId( + prefix: string, + key: string, + counts: Map, + ): string { + const safeKey = key || "untitled" + const currentCount = counts.get(`${prefix}::${safeKey}`) || 0 + const nextCount = currentCount + 1 + counts.set(`${prefix}::${safeKey}`, nextCount) + return `${prefix}::${safeKey}::${nextCount}` + } + private calculateAssistantWordCountBetween( container: Element, startElement: Element, diff --git a/src/core/outline-manager.ts b/src/core/outline-manager.ts index a43d7d627..dbb7503d5 100644 --- a/src/core/outline-manager.ts +++ b/src/core/outline-manager.ts @@ -716,7 +716,17 @@ export class OutlineManager { "|" + showWordCountFlag + "|" + - outlineData.map((i) => `${i.text}:${(i as ExtendedOutlineItem).isBookmarked}`).join("|") + outlineData + .map((item) => + [ + this.getTreeStateKey(item), + item.level, + item.isUserQuery ? "q" : "h", + showWordCount ? item.wordCount ?? "" : "", + (item as ExtendedOutlineItem).isBookmarked ? "b" : "", + ].join(":"), + ) + .join("|") const currentStateMap: Record = {} if (this.tree.length > 0) { this.captureTreeState(this.tree, currentStateMap) @@ -933,7 +943,7 @@ export class OutlineManager { // State Management private captureTreeState(nodes: OutlineNode[], stateMap: Record) { nodes.forEach((node) => { - const key = `${node.level}_${node.text}` + const key = this.getTreeStateKey(node) const hasChildren = node.children && node.children.length > 0 stateMap[key] = { collapsed: node.collapsed, @@ -948,7 +958,7 @@ export class OutlineManager { private restoreTreeState(nodes: OutlineNode[], stateMap: Record) { nodes.forEach((node) => { - const key = `${node.level}_${node.text}` + const key = this.getTreeStateKey(node) const state = stateMap[key] if (state) { const hasChildrenNow = node.children && node.children.length > 0 @@ -983,6 +993,14 @@ export class OutlineManager { }) } + private getTreeStateKey(item: Pick): string { + if (item.id) { + return item.id + } + + return this.generateSignature(item as OutlineItem) + } + // Legacy: 使用原始 level (H1-H6) 判断,不是 relativeLevel private clearForceExpandedState(nodes: OutlineNode[], displayLevel: number) { nodes.forEach((node) => { From b2e9037df9cfa903b67dcc9a79503e3c4605ba05 Mon Sep 17 00:00:00 2001 From: KanameMadoka520 <2441883200@qq.com> Date: Fri, 17 Apr 2026 21:01:30 +0800 Subject: [PATCH 07/27] fix: guard notifications api in background --- src/background.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/background.ts b/src/background.ts index 18844c770..714df9474 100644 --- a/src/background.ts +++ b/src/background.ts @@ -69,7 +69,20 @@ function decodeNotifId(notifId: string): { tabId: number; windowId: number } | n // 点击通知时激活对应标签页并聚焦窗口 function getNotificationsApi(): typeof chrome.notifications | null { - return typeof chrome.notifications === "undefined" ? null : chrome.notifications + if (typeof chrome === "undefined" || typeof chrome.notifications === "undefined") { + return null + } + + const api = chrome.notifications + if ( + typeof api.create !== "function" || + typeof api.clear !== "function" || + typeof api.onClicked?.addListener !== "function" + ) { + return null + } + + return api } const notificationsApi = getNotificationsApi() From 8328a013642264cd38cf108b1c8006e5560c9a89 Mon Sep 17 00:00:00 2001 From: KanameMadoka520 <2441883200@qq.com> Date: Fri, 17 Apr 2026 21:16:35 +0800 Subject: [PATCH 08/27] fix: improve perplexity outline extraction --- src/adapters/perplexity.ts | 87 ++++++++++++++++++++++++++++++++++++-- src/core/modules-init.ts | 2 +- 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/src/adapters/perplexity.ts b/src/adapters/perplexity.ts index b2b6bb0b3..0a0153feb 100644 --- a/src/adapters/perplexity.ts +++ b/src/adapters/perplexity.ts @@ -553,7 +553,8 @@ export class PerplexityAdapter extends SiteAdapter { const rawUserQueries = Array.from(container.querySelectorAll(userQuerySelector)) const userQueries = this.dedupeUserQueries( this.collectTopLevelBlocks(rawUserQueries).filter( - (element) => !this.shouldSkipOutlineElement(element), + (element) => + !this.shouldSkipOutlineElement(element) && this.isValidUserQueryCandidate(element), ), container, ) @@ -1328,8 +1329,25 @@ export class PerplexityAdapter extends SiteAdapter { }).flat() const candidates = Array.from(new Set(container.querySelectorAll(selectors.join(", ")))) + const assistantRoots = this.collectTopLevelBlocks( + Array.from(container.querySelectorAll(ASSISTANT_MESSAGE_SELECTOR)).filter( + (element) => + !this.shouldSkipExportElement(element) && !element.closest(USER_QUERY_SELECTOR), + ), + ) + + assistantRoots.forEach((root) => { + root.querySelectorAll("p, li").forEach((element) => { + if (this.isPseudoHeadingCandidate(element, maxLevel)) { + candidates.push(element) + } + }) + }) + return this.collectTopLevelBlocks( - candidates.filter((element) => this.isOutlineHeadingCandidate(element, maxLevel)), + Array.from(new Set(candidates)).filter((element) => + this.isOutlineHeadingCandidate(element, maxLevel), + ), ) } @@ -1358,7 +1376,7 @@ export class PerplexityAdapter extends SiteAdapter { return Number.isNaN(rawLevel) ? null : rawLevel } - return null + return this.inferPseudoHeadingLevel(element) } private compareElementsByDocumentOrder(left: Element, right: Element): number { @@ -1407,6 +1425,69 @@ export class PerplexityAdapter extends SiteAdapter { return `${prefix}::${safeKey}::${nextCount}` } + private isValidUserQueryCandidate(element: Element): boolean { + if ( + element.closest( + ".h-headerHeight.fixed.z-10, header, nav, aside, [role='dialog'], .fixed, .sticky", + ) + ) { + return false + } + + if (element.closest(ASSISTANT_MESSAGE_SELECTOR)) { + return false + } + + return Boolean(this.extractUserQueryText(element)) + } + + private isPseudoHeadingCandidate(element: Element, maxLevel: number): boolean { + return ( + this.inferPseudoHeadingLevel(element) !== null && + this.isOutlineHeadingCandidate(element, maxLevel) + ) + } + + private inferPseudoHeadingLevel(element: Element): number | null { + if (!(element instanceof HTMLElement)) return null + if (element.matches("li") && element.querySelector("ol, ul")) return null + if (element.querySelector("h1, h2, h3, h4, h5, h6, [role='heading']")) return null + if (element.closest("pre, code, table, blockquote")) return null + + const text = element.innerText.replace(/\s+/g, " ").trim() + if (!text || text.length > 120) return null + + const chineseSectionPattern = + /^(?:第[一二三四五六七八九十百千0-9]+(?:阶段|部分|章|节)|[一二三四五六七八九十百千]+[、..::])/ + if (chineseSectionPattern.test(text)) { + return 1 + } + + const numberedPattern = text.match(/^(\d+(?:\.\d+){0,2})[、.).::]?\s+/) + if (numberedPattern) { + const depth = (numberedPattern[1].match(/\./g) || []).length + return Math.min(depth + 2, 6) + } + + const normalizedText = this.normalizeUiText(text) + const strongText = this.normalizeUiText( + Array.from(element.querySelectorAll("strong, b")) + .map((node) => node.textContent || "") + .join(" "), + ) + const isMostlyStrong = Boolean(strongText) && strongText.length / normalizedText.length >= 0.85 + const looksLikeStandaloneTitle = + text.length <= 36 && + !/[。!!;;]/.test(text) && + (!/[::??]$/.test(text) || text.length <= 48) + + if (isMostlyStrong && looksLikeStandaloneTitle) { + return 1 + } + + return null + } + private calculateAssistantWordCountBetween( container: Element, startElement: Element, diff --git a/src/core/modules-init.ts b/src/core/modules-init.ts index ef106eeb8..04d04710b 100644 --- a/src/core/modules-init.ts +++ b/src/core/modules-init.ts @@ -229,7 +229,7 @@ export function initMarkdownFixer(ctx: ModulesContext): void { if (config && enabled) { modules.markdownFixer = new MarkdownFixer(config) modules.markdownFixer.start() - console.warn(`[Ophel] MarkdownFixer started for ${adapter.getName()}`) + console.info(`[Ophel] MarkdownFixer started for ${adapter.getName()}`) } } From ad8f654fc30c6b7a0ac4dd71fdc21da4f57d6d8d Mon Sep 17 00:00:00 2001 From: KanameMadoka520 <2441883200@qq.com> Date: Fri, 17 Apr 2026 21:33:01 +0800 Subject: [PATCH 09/27] refactor: drop perplexity pseudo heading detection --- src/adapters/perplexity.ts | 68 ++------------------------------------ 1 file changed, 2 insertions(+), 66 deletions(-) diff --git a/src/adapters/perplexity.ts b/src/adapters/perplexity.ts index 0a0153feb..b43b64df0 100644 --- a/src/adapters/perplexity.ts +++ b/src/adapters/perplexity.ts @@ -1329,25 +1329,8 @@ export class PerplexityAdapter extends SiteAdapter { }).flat() const candidates = Array.from(new Set(container.querySelectorAll(selectors.join(", ")))) - const assistantRoots = this.collectTopLevelBlocks( - Array.from(container.querySelectorAll(ASSISTANT_MESSAGE_SELECTOR)).filter( - (element) => - !this.shouldSkipExportElement(element) && !element.closest(USER_QUERY_SELECTOR), - ), - ) - - assistantRoots.forEach((root) => { - root.querySelectorAll("p, li").forEach((element) => { - if (this.isPseudoHeadingCandidate(element, maxLevel)) { - candidates.push(element) - } - }) - }) - return this.collectTopLevelBlocks( - Array.from(new Set(candidates)).filter((element) => - this.isOutlineHeadingCandidate(element, maxLevel), - ), + candidates.filter((element) => this.isOutlineHeadingCandidate(element, maxLevel)), ) } @@ -1376,7 +1359,7 @@ export class PerplexityAdapter extends SiteAdapter { return Number.isNaN(rawLevel) ? null : rawLevel } - return this.inferPseudoHeadingLevel(element) + return null } private compareElementsByDocumentOrder(left: Element, right: Element): number { @@ -1441,53 +1424,6 @@ export class PerplexityAdapter extends SiteAdapter { return Boolean(this.extractUserQueryText(element)) } - private isPseudoHeadingCandidate(element: Element, maxLevel: number): boolean { - return ( - this.inferPseudoHeadingLevel(element) !== null && - this.isOutlineHeadingCandidate(element, maxLevel) - ) - } - - private inferPseudoHeadingLevel(element: Element): number | null { - if (!(element instanceof HTMLElement)) return null - if (element.matches("li") && element.querySelector("ol, ul")) return null - if (element.querySelector("h1, h2, h3, h4, h5, h6, [role='heading']")) return null - if (element.closest("pre, code, table, blockquote")) return null - - const text = element.innerText.replace(/\s+/g, " ").trim() - if (!text || text.length > 120) return null - - const chineseSectionPattern = - /^(?:第[一二三四五六七八九十百千0-9]+(?:阶段|部分|章|节)|[一二三四五六七八九十百千]+[、..::])/ - if (chineseSectionPattern.test(text)) { - return 1 - } - - const numberedPattern = text.match(/^(\d+(?:\.\d+){0,2})[、.).::]?\s+/) - if (numberedPattern) { - const depth = (numberedPattern[1].match(/\./g) || []).length - return Math.min(depth + 2, 6) - } - - const normalizedText = this.normalizeUiText(text) - const strongText = this.normalizeUiText( - Array.from(element.querySelectorAll("strong, b")) - .map((node) => node.textContent || "") - .join(" "), - ) - const isMostlyStrong = Boolean(strongText) && strongText.length / normalizedText.length >= 0.85 - const looksLikeStandaloneTitle = - text.length <= 36 && - !/[。!!;;]/.test(text) && - (!/[::??]$/.test(text) || text.length <= 48) - - if (isMostlyStrong && looksLikeStandaloneTitle) { - return 1 - } - - return null - } - private calculateAssistantWordCountBetween( container: Element, startElement: Element, From 81cb1ae90b60dd128d69c688736095eed9b3c03a Mon Sep 17 00:00:00 2001 From: KanameMadoka520 <2441883200@qq.com> Date: Fri, 17 Apr 2026 21:36:21 +0800 Subject: [PATCH 10/27] feat: derive perplexity outline from markdown headings --- src/adapters/perplexity.ts | 215 ++++++++++++++++++++++++++++--------- 1 file changed, 164 insertions(+), 51 deletions(-) diff --git a/src/adapters/perplexity.ts b/src/adapters/perplexity.ts index b43b64df0..9fc684199 100644 --- a/src/adapters/perplexity.ts +++ b/src/adapters/perplexity.ts @@ -549,7 +549,6 @@ export class PerplexityAdapter extends SiteAdapter { const userQuerySelector = this.getUserQuerySelector() if (!userQuerySelector) return outline - const headings = this.collectOutlineHeadingCandidates(container, maxLevel) const rawUserQueries = Array.from(container.querySelectorAll(userQuerySelector)) const userQueries = this.dedupeUserQueries( this.collectTopLevelBlocks(rawUserQueries).filter( @@ -558,26 +557,16 @@ export class PerplexityAdapter extends SiteAdapter { ), container, ) - const userQuerySet = new Set(userQueries) + const assistantRoots = this.collectAssistantRoots(container) const userQueryKeyCounts = new Map() - const headingKeyCounts = new Map() let currentGroupId = "preamble" - const allElements = [...userQueries, ...headings].sort((left, right) => + const blocks = [...userQueries, ...assistantRoots].sort((left, right) => this.compareElementsByDocumentOrder(left, right), ) - allElements.forEach((element, index) => { + blocks.forEach((element, index) => { const isUserQuery = element.matches(userQuerySelector) - const headingLevel = isUserQuery ? null : this.getOutlineHeadingLevel(element) - - if (element.matches(userQuerySelector)) { - if (!userQuerySet.has(element)) { - return - } - } else if (headingLevel === null) { - return - } if (isUserQuery) { if (!includeUserQueries) return @@ -601,9 +590,8 @@ export class PerplexityAdapter extends SiteAdapter { if (showWordCount) { const nextUserQuery = - allElements - .slice(index + 1) - .find((candidate) => candidate.matches(userQuerySelector)) || null + blocks.slice(index + 1).find((candidate) => candidate.matches(userQuerySelector)) || + null item.wordCount = this.calculateAssistantWordCountBetween( container, element, @@ -615,44 +603,16 @@ export class PerplexityAdapter extends SiteAdapter { return } - const text = element.textContent?.trim() || "" - if (!text) return - - const item: OutlineItem = { - level: headingLevel, - text, + const assistantItems = this.collectAssistantOutlineItems( element, - } - item.id = this.buildOutlineOccurrenceId( - `heading::${currentGroupId}::${headingLevel}`, - this.normalizeUiText(text), - headingKeyCounts, + currentGroupId, + maxLevel, + showWordCount, ) - if (showWordCount) { - let nextBoundary: Element | null = null - for (let i = index + 1; i < allElements.length; i += 1) { - const candidate = allElements[i] - if (candidate.matches(userQuerySelector)) { - nextBoundary = candidate - break - } - - const candidateLevel = this.getOutlineHeadingLevel(candidate) - if (candidateLevel !== null && candidateLevel <= headingLevel) { - nextBoundary = candidate - break - } - } - - item.wordCount = this.calculateRangeWordCount( - element, - nextBoundary, - element.closest(ASSISTANT_MESSAGE_SELECTOR) || container, - ) + if (assistantItems.length > 0) { + outline.push(...assistantItems) } - - outline.push(item) }) return outline @@ -1334,6 +1294,105 @@ export class PerplexityAdapter extends SiteAdapter { ) } + private collectAssistantRoots(container: Element): Element[] { + return this.collectTopLevelBlocks( + Array.from(container.querySelectorAll(ASSISTANT_MESSAGE_SELECTOR)).filter( + (element) => + !this.shouldSkipExportElement(element) && !element.closest(USER_QUERY_SELECTOR), + ), + ) + } + + private collectAssistantOutlineItems( + assistantRoot: Element, + groupId: string, + maxLevel: number, + showWordCount: boolean, + ): OutlineItem[] { + const assistantId = this.getAssistantOutlineScopeId(assistantRoot) + const occurrenceCounts = new Map() + const realHeadings = this.collectOutlineHeadingCandidates(assistantRoot, maxLevel) + + if (realHeadings.length > 0) { + return realHeadings.flatMap((element, index) => { + const level = this.getOutlineHeadingLevel(element) + const text = element.textContent?.trim() || "" + if (level === null || !text) return [] + + const item: OutlineItem = { + level, + text, + element, + } + item.id = this.buildOutlineOccurrenceId( + `heading::${groupId}::${assistantId}::${level}`, + this.normalizeUiText(text), + occurrenceCounts, + ) + + if (showWordCount) { + let nextBoundary: Element | null = null + for (let i = index + 1; i < realHeadings.length; i += 1) { + const candidate = realHeadings[i] + const candidateLevel = this.getOutlineHeadingLevel(candidate) + if (candidateLevel !== null && candidateLevel <= level) { + nextBoundary = candidate + break + } + } + + item.wordCount = this.calculateRangeWordCount(element, nextBoundary, assistantRoot) + } + + return [item] + }) + } + + return this.extractOutlineFromAssistantMarkdown( + assistantRoot, + groupId, + assistantId, + maxLevel, + showWordCount, + ) + } + + private extractOutlineFromAssistantMarkdown( + assistantRoot: Element, + groupId: string, + assistantId: string, + maxLevel: number, + showWordCount: boolean, + ): OutlineItem[] { + const markdown = this.extractAssistantResponseText(assistantRoot) + if (!markdown) return [] + + const headings = this.parseMarkdownHeadings(markdown, maxLevel) + if (headings.length === 0) return [] + + const occurrenceCounts = new Map() + return headings.map((heading, index) => { + const item: OutlineItem = { + level: heading.level, + text: heading.text, + element: assistantRoot, + context: `markdown-line:${heading.line}`, + } + item.id = this.buildOutlineOccurrenceId( + `heading::${groupId}::${assistantId}::${heading.level}`, + this.normalizeUiText(heading.text), + occurrenceCounts, + ) + + if (showWordCount) { + const nextLine = headings[index + 1]?.line ?? null + item.wordCount = this.countMarkdownSectionLength(markdown, heading.line, nextLine) + } + + return item + }) + } + private isOutlineHeadingCandidate(element: Element, maxLevel: number): boolean { if (this.shouldSkipOutlineElement(element)) return false if (element.closest("nav, aside, header, footer, [role='dialog'], button, [role='button']")) { @@ -1362,6 +1421,16 @@ export class PerplexityAdapter extends SiteAdapter { return null } + private getAssistantOutlineScopeId(element: Element): string { + const id = (element as HTMLElement).id?.trim() + if (id) return id + + const labelledBy = element.getAttribute("aria-labelledby")?.trim() + if (labelledBy) return labelledBy + + return this.normalizeUiText(element.textContent || "").slice(0, 80) || "assistant" + } + private compareElementsByDocumentOrder(left: Element, right: Element): number { if (left === right) return 0 @@ -1408,6 +1477,50 @@ export class PerplexityAdapter extends SiteAdapter { return `${prefix}::${safeKey}::${nextCount}` } + private parseMarkdownHeadings( + markdown: string, + maxLevel: number, + ): Array<{ level: number; text: string; line: number }> { + const lines = markdown.split(/\r?\n/) + const headings: Array<{ level: number; text: string; line: number }> = [] + let inFence = false + + lines.forEach((line, index) => { + if (/^\s*```/.test(line)) { + inFence = !inFence + return + } + if (inFence) return + + const match = line.match(/^\s*(#{1,6})\s+(.+?)\s*#*\s*$/) + if (!match) return + + const level = match[1].length + if (level > maxLevel) return + + const text = match[2].trim() + if (!text) return + + headings.push({ level, text, line: index }) + }) + + return headings + } + + private countMarkdownSectionLength( + markdown: string, + startLine: number, + nextLine: number | null, + ): number { + const lines = markdown.split(/\r?\n/) + const endLine = nextLine ?? lines.length + return lines + .slice(startLine + 1, endLine) + .join("\n") + .replace(/\s+/g, " ") + .trim().length + } + private isValidUserQueryCandidate(element: Element): boolean { if ( element.closest( From 0372f284f027a2bccce1db23f9dc7abada8c20e0 Mon Sep 17 00:00:00 2001 From: KanameMadoka520 <2441883200@qq.com> Date: Fri, 17 Apr 2026 22:37:27 +0800 Subject: [PATCH 11/27] fix: parse markdown code blocks in perplexity outline --- src/adapters/perplexity.ts | 70 ++++++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 7 deletions(-) diff --git a/src/adapters/perplexity.ts b/src/adapters/perplexity.ts index 9fc684199..5a28e37a8 100644 --- a/src/adapters/perplexity.ts +++ b/src/adapters/perplexity.ts @@ -1365,18 +1365,44 @@ export class PerplexityAdapter extends SiteAdapter { showWordCount: boolean, ): OutlineItem[] { const markdown = this.extractAssistantResponseText(assistantRoot) - if (!markdown) return [] + const headings = markdown ? this.parseMarkdownHeadings(markdown, maxLevel) : [] + if (headings.length > 0) { + const occurrenceCounts = new Map() + return headings.map((heading, index) => { + const item: OutlineItem = { + level: heading.level, + text: heading.text, + element: assistantRoot, + context: `markdown-line:${heading.line}`, + } + item.id = this.buildOutlineOccurrenceId( + `heading::${groupId}::${assistantId}::${heading.level}`, + this.normalizeUiText(heading.text), + occurrenceCounts, + ) - const headings = this.parseMarkdownHeadings(markdown, maxLevel) - if (headings.length === 0) return [] + if (showWordCount) { + const nextLine = headings[index + 1]?.line ?? null + item.wordCount = this.countMarkdownSectionLength(markdown, heading.line, nextLine) + } + + return item + }) + } + + const codeMarkdown = this.extractMarkdownFromCodeBlocks(assistantRoot) + if (!codeMarkdown) return [] + + const codeHeadings = this.parseMarkdownHeadings(codeMarkdown, maxLevel) + if (codeHeadings.length === 0) return [] const occurrenceCounts = new Map() - return headings.map((heading, index) => { + return codeHeadings.map((heading, index) => { const item: OutlineItem = { level: heading.level, text: heading.text, element: assistantRoot, - context: `markdown-line:${heading.line}`, + context: `code-markdown-line:${heading.line}`, } item.id = this.buildOutlineOccurrenceId( `heading::${groupId}::${assistantId}::${heading.level}`, @@ -1385,8 +1411,8 @@ export class PerplexityAdapter extends SiteAdapter { ) if (showWordCount) { - const nextLine = headings[index + 1]?.line ?? null - item.wordCount = this.countMarkdownSectionLength(markdown, heading.line, nextLine) + const nextLine = codeHeadings[index + 1]?.line ?? null + item.wordCount = this.countMarkdownSectionLength(codeMarkdown, heading.line, nextLine) } return item @@ -1507,6 +1533,36 @@ export class PerplexityAdapter extends SiteAdapter { return headings } + private extractMarkdownFromCodeBlocks(assistantRoot: Element): string | null { + const codeBlocks = Array.from(assistantRoot.querySelectorAll("pre code, pre")) + .map((element) => (element.textContent || "").replace(/\r\n/g, "\n").trim()) + .filter(Boolean) + + for (const block of codeBlocks) { + const normalized = this.normalizeCodeBlockMarkdown(block) + if (!normalized) continue + if (this.parseMarkdownHeadings(normalized, 6).length > 0) { + return normalized + } + } + + return null + } + + private normalizeCodeBlockMarkdown(source: string): string | null { + const lines = source.split("\n") + if (lines.length === 0) return null + + const firstLine = this.normalizeUiText(lines[0]) + const body = lines.slice(1).join("\n").trim() + + if (["text", "markdown", "md", "txt"].includes(firstLine) && body) { + return body + } + + return source.trim() || null + } + private countMarkdownSectionLength( markdown: string, startLine: number, From f026ffb34f5e2d24c9acf7f704ff0ba9ebc8b615 Mon Sep 17 00:00:00 2001 From: KanameMadoka520 <2441883200@qq.com> Date: Fri, 17 Apr 2026 22:54:50 +0800 Subject: [PATCH 12/27] fix: use main container for perplexity outline --- src/adapters/perplexity.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/adapters/perplexity.ts b/src/adapters/perplexity.ts index 5a28e37a8..1f0d1b4ba 100644 --- a/src/adapters/perplexity.ts +++ b/src/adapters/perplexity.ts @@ -541,9 +541,7 @@ export class PerplexityAdapter extends SiteAdapter { extractOutline(maxLevel = 6, includeUserQueries = false, showWordCount = false): OutlineItem[] { const outline: OutlineItem[] = [] - const container = - document.querySelector("[role='tabpanel']") || - document.querySelector(this.getResponseContainerSelector()) + const container = this.getOutlineContainer() if (!container) return outline const userQuerySelector = this.getUserQuerySelector() @@ -1294,6 +1292,20 @@ export class PerplexityAdapter extends SiteAdapter { ) } + private getOutlineContainer(): Element | null { + const main = document.querySelector("main") + if (main) return main + + const panelTabs = Array.from(document.querySelectorAll("[role='tabpanel']")).filter((element) => + element.closest("main"), + ) + if (panelTabs.length > 0) { + return panelTabs[panelTabs.length - 1] + } + + return document.querySelector(this.getResponseContainerSelector()) + } + private collectAssistantRoots(container: Element): Element[] { return this.collectTopLevelBlocks( Array.from(container.querySelectorAll(ASSISTANT_MESSAGE_SELECTOR)).filter( From c20c3a9d1b5ef46cf84a940709c69e38aed03d20 Mon Sep 17 00:00:00 2001 From: KanameMadoka520 <2441883200@qq.com> Date: Fri, 17 Apr 2026 23:25:42 +0800 Subject: [PATCH 13/27] feat: add perplexity native theme tint --- src/adapters/perplexity.ts | 5 ++ .../native-theme-adapters/perplexity.ts | 63 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 src/styles/native-theme-adapters/perplexity.ts diff --git a/src/adapters/perplexity.ts b/src/adapters/perplexity.ts index 1f0d1b4ba..7d4989c3c 100644 --- a/src/adapters/perplexity.ts +++ b/src/adapters/perplexity.ts @@ -7,6 +7,7 @@ * - 会话列表优先从侧边栏 DOM 读取,必要时用线程 API 预拉取做缓存 */ import { SITE_IDS } from "~constants" +import { perplexityNativeThemeCss } from "~styles/native-theme-adapters/perplexity" import { DOMToolkit } from "~utils/dom-toolkit" import { htmlToMarkdown } from "~utils/exporter" @@ -153,6 +154,10 @@ export class PerplexityAdapter extends SiteAdapter { return { primary: "#1fb8cd", secondary: "#1597aa" } } + getNativeThemeCss(): string | null { + return perplexityNativeThemeCss + } + getSessionId(): string { const match = window.location.pathname.match(THREAD_PATH_PATTERN) const slug = match?.[1]?.trim() || "" diff --git a/src/styles/native-theme-adapters/perplexity.ts b/src/styles/native-theme-adapters/perplexity.ts new file mode 100644 index 000000000..8392f6afc --- /dev/null +++ b/src/styles/native-theme-adapters/perplexity.ts @@ -0,0 +1,63 @@ +export const perplexityNativeThemeCss = ` +/* Perplexity host theme adapter: keep the host page close to native, + * but let Ophel's accent/background subtly tint key surfaces. */ + +html, +body, +main { + background-color: color-mix(in srgb, var(--gh-primary) 1%, var(--gh-bg)) !important; +} + +aside, +.group\\/sidebar, +nav:has(a[href^="/search/"]), +nav:has(a[href^="/page/"]) { + background-color: color-mix(in srgb, var(--gh-primary) 2%, var(--gh-bg-secondary)) !important; + border-color: color-mix(in srgb, var(--gh-primary) 12%, transparent) !important; +} + +/* Conversation chips / cards / panels */ +.bg-offset, +.bg-offsetPlus, +[class*="bg-offset"], +[class*="bg-offsetPlus"] { + border-color: color-mix(in srgb, var(--gh-primary) 12%, transparent) !important; +} + +/* Active top tabs, active sidebar items and similar selected states */ +[role="tab"][aria-selected="true"], +button[aria-selected="true"], +a[aria-current="page"], +[data-state="active"] { + color: var(--gh-primary) !important; + background-color: color-mix(in srgb, var(--gh-primary) 10%, var(--gh-bg-secondary)) !important; + border-color: color-mix(in srgb, var(--gh-primary) 20%, transparent) !important; +} + +[role="tab"][aria-selected="true"] { + box-shadow: inset 0 -2px 0 var(--gh-primary) !important; +} + +/* Make icons follow the active foreground where possible */ +[role="tab"][aria-selected="true"] svg, +button[aria-selected="true"] svg, +a[aria-current="page"] svg, +[data-state="active"] svg { + color: var(--gh-primary) !important; + stroke: currentColor !important; +} + +/* Inputs and textareas keep the same minimal treatment as ChatGPT: + * just tint borders/backgrounds, do not force all vectors. */ +input, +textarea, +[role="textbox"], +[contenteditable="true"] { + border-color: color-mix(in srgb, var(--gh-primary) 18%, transparent) !important; +} + +main .mx-auto, +[role="tabpanel"] .mx-auto { + background-color: color-mix(in srgb, var(--gh-primary) 1%, transparent) !important; +} +` From 9cfa7228e6afa1c7672a757ce590ffcca45f5f99 Mon Sep 17 00:00:00 2001 From: KanameMadoka520 <2441883200@qq.com> Date: Fri, 17 Apr 2026 23:32:56 +0800 Subject: [PATCH 14/27] revert: remove perplexity host tint --- src/adapters/perplexity.ts | 5 -- .../native-theme-adapters/perplexity.ts | 63 ------------------- 2 files changed, 68 deletions(-) delete mode 100644 src/styles/native-theme-adapters/perplexity.ts diff --git a/src/adapters/perplexity.ts b/src/adapters/perplexity.ts index 7d4989c3c..1f0d1b4ba 100644 --- a/src/adapters/perplexity.ts +++ b/src/adapters/perplexity.ts @@ -7,7 +7,6 @@ * - 会话列表优先从侧边栏 DOM 读取,必要时用线程 API 预拉取做缓存 */ import { SITE_IDS } from "~constants" -import { perplexityNativeThemeCss } from "~styles/native-theme-adapters/perplexity" import { DOMToolkit } from "~utils/dom-toolkit" import { htmlToMarkdown } from "~utils/exporter" @@ -154,10 +153,6 @@ export class PerplexityAdapter extends SiteAdapter { return { primary: "#1fb8cd", secondary: "#1597aa" } } - getNativeThemeCss(): string | null { - return perplexityNativeThemeCss - } - getSessionId(): string { const match = window.location.pathname.match(THREAD_PATH_PATTERN) const slug = match?.[1]?.trim() || "" diff --git a/src/styles/native-theme-adapters/perplexity.ts b/src/styles/native-theme-adapters/perplexity.ts deleted file mode 100644 index 8392f6afc..000000000 --- a/src/styles/native-theme-adapters/perplexity.ts +++ /dev/null @@ -1,63 +0,0 @@ -export const perplexityNativeThemeCss = ` -/* Perplexity host theme adapter: keep the host page close to native, - * but let Ophel's accent/background subtly tint key surfaces. */ - -html, -body, -main { - background-color: color-mix(in srgb, var(--gh-primary) 1%, var(--gh-bg)) !important; -} - -aside, -.group\\/sidebar, -nav:has(a[href^="/search/"]), -nav:has(a[href^="/page/"]) { - background-color: color-mix(in srgb, var(--gh-primary) 2%, var(--gh-bg-secondary)) !important; - border-color: color-mix(in srgb, var(--gh-primary) 12%, transparent) !important; -} - -/* Conversation chips / cards / panels */ -.bg-offset, -.bg-offsetPlus, -[class*="bg-offset"], -[class*="bg-offsetPlus"] { - border-color: color-mix(in srgb, var(--gh-primary) 12%, transparent) !important; -} - -/* Active top tabs, active sidebar items and similar selected states */ -[role="tab"][aria-selected="true"], -button[aria-selected="true"], -a[aria-current="page"], -[data-state="active"] { - color: var(--gh-primary) !important; - background-color: color-mix(in srgb, var(--gh-primary) 10%, var(--gh-bg-secondary)) !important; - border-color: color-mix(in srgb, var(--gh-primary) 20%, transparent) !important; -} - -[role="tab"][aria-selected="true"] { - box-shadow: inset 0 -2px 0 var(--gh-primary) !important; -} - -/* Make icons follow the active foreground where possible */ -[role="tab"][aria-selected="true"] svg, -button[aria-selected="true"] svg, -a[aria-current="page"] svg, -[data-state="active"] svg { - color: var(--gh-primary) !important; - stroke: currentColor !important; -} - -/* Inputs and textareas keep the same minimal treatment as ChatGPT: - * just tint borders/backgrounds, do not force all vectors. */ -input, -textarea, -[role="textbox"], -[contenteditable="true"] { - border-color: color-mix(in srgb, var(--gh-primary) 18%, transparent) !important; -} - -main .mx-auto, -[role="tabpanel"] .mx-auto { - background-color: color-mix(in srgb, var(--gh-primary) 1%, transparent) !important; -} -` From 6203bb7b61e487e80d237f7fb1527db0c9cc7430 Mon Sep 17 00:00:00 2001 From: KanameMadoka520 <2441883200@qq.com> Date: Thu, 23 Apr 2026 20:07:11 +0800 Subject: [PATCH 15/27] fix: harden perplexity model locking and sync flows --- src/adapters/base.ts | 68 + src/adapters/perplexity.ts | 1808 ++++++++++++++++++++++-- src/components/ConversationsTab.tsx | 19 +- src/core/assistant-mermaid-renderer.ts | 33 +- src/core/conversation/manager.ts | 98 +- src/core/model-locker.ts | 273 +++- src/core/modules-init.ts | 4 +- src/stores/chrome-adapter.ts | 15 + 8 files changed, 2182 insertions(+), 136 deletions(-) diff --git a/src/adapters/base.ts b/src/adapters/base.ts index ef28b7d7c..976b297c7 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -35,6 +35,12 @@ export interface ConversationDeleteTarget { url?: string } +export interface ConversationRenameTarget { + id: string + title?: string + url?: string +} + export interface SiteDeleteConversationResult { id: string success: boolean @@ -87,6 +93,8 @@ export interface ConversationObserverConfig { shadow: boolean extractInfo: (el: Element) => ConversationInfo | null getTitleElement: (el: Element) => Element | null + enablePolling?: boolean + pollIntervalMs?: number } export interface AnchorData { @@ -204,6 +212,17 @@ export function normalizeAssistantMermaidSource(source: string): string { if (!normalized) return "" const lines = normalized.split("\n") + + // Some sites wrap code blocks as: + // text\nflowchart TD ... + // Strip a leading plain-text language marker before Mermaid detection. + if (lines.length > 1) { + const firstLine = lines[0].trim().toLowerCase() + if (/^(text|plain(?:\s|-)?text|text\/plain|markdown|text\/markdown|md|txt)$/.test(firstLine)) { + return normalizeAssistantMermaidSource(lines.slice(1).join("\n")) + } + } + const firstMeaningfulLineIndex = getFirstMeaningfulMermaidLineIndex(lines) if (firstMeaningfulLineIndex === -1) { return normalized @@ -446,6 +465,17 @@ export abstract class SiteAdapter { return results } + async renameConversationOnSite( + _target: ConversationRenameTarget, + _newTitle: string, + ): Promise<{ success: boolean; method: "api" | "ui" | "none"; reason?: string }> { + return { + success: false, + method: "none", + reason: "not_supported", + } + } + async loadAllConversations(): Promise { const container = this.getSidebarScrollContainer() if (!container) return @@ -500,6 +530,44 @@ export abstract class SiteAdapter { return selectorBtn?.textContent || selectorBtn?.innerText || "" } + /** + * Whether the site's model selector menu is currently open. + * Adapters can override this so the shared model locker avoids + * interfering with a manually opened model menu. + */ + isModelSelectorOpen(): boolean { + return false + } + + /** + * Some sites need a persistent model-lock monitor rather than a short + * relock window after route changes. + */ + usesPersistentModelLockMonitor(): boolean { + return false + } + + getModelLockMonitorInterval(): number { + return 1000 + } + + getModelLockMonitorRoot(): Node | null { + return typeof document !== "undefined" ? document.body : null + } + + getModelLockMutationDebounce(): number { + return 120 + } + + /** + * Whether the site's model-lock UI is currently available. + * Sites with temporary mode/tool menus instead of a model selector can + * return false so the shared locker waits for the selector to come back. + */ + isModelLockUiReady(): boolean { + return true + } + /** 获取网络监控配置 */ getNetworkMonitorConfig(): NetworkMonitorConfig | null { return null diff --git a/src/adapters/perplexity.ts b/src/adapters/perplexity.ts index 1f0d1b4ba..14234a450 100644 --- a/src/adapters/perplexity.ts +++ b/src/adapters/perplexity.ts @@ -73,6 +73,13 @@ const RESPONSE_CONTAINER_SELECTOR = [ ].join(", ") const SIDEBAR_LINK_SELECTOR = "a[href^='/search/'], a[href^='/page/']" +const SIDEBAR_ITEM_CONTAINER_SELECTORS = [ + ".group\\/sidebar-submenu", + "[data-title-hover='true']", + "[data-testid='sidebar-item']", + "li", + "[role='listitem']", +].join(", ") const SIDEBAR_SCROLL_CONTAINER_SELECTORS = [".group\\/sidebar", "aside", "nav"] const THREAD_TITLE_SELECTOR = ".h-headerHeight.fixed.z-10 .cursor-pointer.transition.duration-300.hover\\:opacity-70, input[placeholder='Untitled']" @@ -88,15 +95,76 @@ const DELETE_FLOW_TIMEOUT_MS = 5_000 const THREAD_ACTION_BUTTON_SELECTORS = [ 'button[aria-label="Thread actions"]', 'button[aria-label*="Thread actions" i]', + 'button[aria-label*="More" i]', + 'button[title*="More" i]', + 'button[aria-haspopup="menu"]', + '[role="button"][aria-haspopup="menu"]', 'button[aria-label*="话题"]', 'button[aria-label*="对话"]', 'button[aria-label*="线程"]', ] -const DELETE_MENU_ITEM_LABELS = ["Delete", "删除"] -const CONFIRM_BUTTON_LABELS = ["Confirm", "确认", "Delete", "删除"] +const DELETE_MENU_ITEM_LABELS = ["Delete", "删除", "删除问题", "Delete question", "Delete thread"] +const RENAME_MENU_ITEM_LABELS = ["Rename", "重命名", "Edit title", "编辑问题标题"] +const CONFIRM_BUTTON_LABELS = ["Confirm", "确认", "确定", "Delete", "删除", "Yes", "OK"] +const CANCEL_BUTTON_LABELS = ["Cancel", "取消", "No"] +const SAVE_BUTTON_LABELS = ["Save", "保存"] +const RENAME_DIALOG_TITLE_LABELS = [ + "编辑问题标题", + "Rename", + "Rename thread", + "Edit title", + "Edit question title", +] const THREAD_ACTION_LABELS = ["Thread actions", "话题操作", "对话操作", "线程操作"] +const THREAD_ACTION_SIGNAL_LABELS = [ + ...THREAD_ACTION_LABELS, + "More", + "更多", + "菜单", + "menu", + "...", + "…", +] +const MODEL_MENU_HINTS = [ + "best", + "sonar", + "gpt", + "claude", + "gemini", + "nemotron", + "o1", + "o3", + "o4", + "r1", + "最佳", + "模型", +] +const MODEL_SELECTOR_PRIMARY_HINTS = [ + "best", + "sonar", + "gpt", + "claude", + "gemini", + "nemotron", + "o1", + "o3", + "o4", + "r1", + "最佳", +] +const MODEL_SELECTOR_BUTTON_SELECTORS = [ + 'button[aria-label="模型"][aria-haspopup="menu"]', + 'button[aria-label*="模型"][aria-haspopup="menu"]', + 'button[aria-label*="GPT"][aria-haspopup="menu"]', + 'button[aria-label*="Gemini"][aria-haspopup="menu"]', + 'button[aria-label*="Claude"][aria-haspopup="menu"]', + 'button[aria-label*="Sonar"][aria-haspopup="menu"]', +] +const MODEL_SELECTOR_EXCLUDE_PATTERNS = [ + /线程操作|讨论线程的操作|更多操作|下载|改写问题|添加文件或工具|分享|深度研究|连接器和来源|上传文件或图片|模型委员会|附件|来源|研究/i, +] const ZEN_MODE_HIDE_SELECTORS = [ ".group\\/sidebar", @@ -136,6 +204,7 @@ export class PerplexityAdapter extends SiteAdapter { private threadListCache: ConversationInfo[] = [] private threadListCacheExpiresAt = 0 private loadAllConversationsPromise: Promise | null = null + private lastManualModelSelectorToggleAt = 0 match(): boolean { return HOSTNAMES.has(window.location.hostname) @@ -185,13 +254,38 @@ export class PerplexityAdapter extends SiteAdapter { } getConversationTitle(): string | null { + const sessionId = this.getSessionId() + if (sessionId) { + const sidebarLink = this.findSidebarConversationLink(sessionId) + if (sidebarLink) { + const sidebarTitle = this.extractConversationTitle(sidebarLink, sessionId) + if (sidebarTitle) { + return sidebarTitle + } + } + } + const titleElement = document.querySelector(THREAD_TITLE_SELECTOR) const title = titleElement instanceof HTMLInputElement ? titleElement.value.trim() : titleElement?.textContent?.trim() || "" - return title || this.getSessionName() + return title || null + } + + getCurrentConversationInfo(): ConversationInfo | null { + const id = this.getSessionId() + if (!id || this.isNewConversation()) { + return null + } + + return { + id, + title: this.getConversationTitle() || "", + url: window.location.href, + cid: this.getCurrentCid() || undefined, + } } getTextareaSelectors(): string[] { @@ -396,6 +490,8 @@ export class PerplexityAdapter extends SiteAdapter { return { selector: SIDEBAR_LINK_SELECTOR, shadow: false, + enablePolling: true, + pollIntervalMs: 1500, extractInfo: (element) => this.extractConversationInfo(element), getTitleElement: (element) => element.querySelector("span, div[dir='auto']") || element, } @@ -404,13 +500,25 @@ export class PerplexityAdapter extends SiteAdapter { async deleteConversationOnSite( target: ConversationDeleteTarget, ): Promise { - const success = await this.deleteConversationViaUi(target.id) + const result = await this.deleteConversationViaUi(target.id) return { id: target.id, - success, - method: success ? "ui" : "none", - reason: success ? undefined : "ui_failed", + success: result.success, + method: result.success ? "ui" : "none", + reason: result.success ? undefined : result.reason, + } + } + + async renameConversationOnSite( + target: { id: string; title?: string; url?: string }, + newTitle: string, + ): Promise<{ success: boolean; method: "api" | "ui" | "none"; reason?: string }> { + const result = await this.renameConversationViaUi(target.id, newTitle) + return { + success: result.success, + method: "ui", + reason: result.success ? undefined : result.reason, } } @@ -682,6 +790,256 @@ export class PerplexityAdapter extends SiteAdapter { } } + getModelLockCheckText(selectorBtn?: HTMLElement | null): string { + return this.getCurrentPerplexityModelText() || super.getModelLockCheckText(selectorBtn) + } + + isModelSelectorOpen(): boolean { + const selectorBtn = this.findPerplexityModelSelectorButton() + if (selectorBtn) { + const expanded = (selectorBtn.getAttribute("aria-expanded") || "").toLowerCase() + const state = (selectorBtn.getAttribute("data-state") || "").toLowerCase() + if (expanded === "true" || state === "open") { + return true + } + } + + return this.findPerplexityModelMenuRoots(selectorBtn || null).length > 0 + } + + isModelLockUiReady(): boolean { + return this.findPerplexityModelSelectorButton() !== null + } + + usesPersistentModelLockMonitor(): boolean { + return true + } + + getModelLockMonitorInterval(): number { + return 1200 + } + + getModelLockMonitorRoot(): Node | null { + return ( + this.getTextareaElement()?.closest("form") || document.querySelector("main") || document.body + ) + } + + getModelLockMutationDebounce(): number { + return 80 + } + + clickModelSelector(): boolean { + const button = this.findPerplexityModelSelectorButton() + if (!button) { + void this.showPerplexityDebugToast( + "[Perplexity Debug] model selector button not found", + "perplexity-model-selector-missing", + ) + return false + } + + this.lastManualModelSelectorToggleAt = Date.now() + void this.togglePerplexityModelSelector(button) + return true + } + + lockModel(keyword: string, onSuccess?: () => void): void { + const target = this.normalizeUiText(keyword) + if (!target) return + + void (async () => { + const manualToggleCooldownMs = 900 + const elapsedSinceManualToggle = Date.now() - this.lastManualModelSelectorToggleAt + if (elapsedSinceManualToggle >= 0 && elapsedSinceManualToggle < manualToggleCooldownMs) { + return + } + + let lastFailureReason = "unknown" + + for (let attempt = 0; attempt < 5; attempt += 1) { + this.logPerplexityModelLockDebug("attempt_start", { + keyword, + normalizedKeyword: target, + attempt: attempt + 1, + }) + + const selectorBtn = await this.waitForValue( + () => this.findPerplexityModelSelectorButton(), + 3_000, + ) + if (!selectorBtn) { + lastFailureReason = "selector_button_not_found" + this.logPerplexityModelLockDebug("selector_missing", { + keyword, + attempt: attempt + 1, + }) + break + } + + this.logPerplexityModelLockDebug("selector_found", { + keyword, + attempt: attempt + 1, + text: selectorBtn.textContent || "", + ariaLabel: selectorBtn.getAttribute("aria-label"), + dataState: selectorBtn.getAttribute("data-state"), + ariaExpanded: selectorBtn.getAttribute("aria-expanded"), + rect: this.getDebugRect(selectorBtn), + }) + + const currentModel = this.getCurrentPerplexityModelText() + if (currentModel.includes(target) || this.isTargetModelChecked(target)) { + this.logPerplexityModelLockDebug("already_on_target", { + keyword, + currentText: currentModel, + }) + onSuccess?.() + return + } + + if (!this.isModelSelectorOpen()) { + const opened = this.openPerplexityModelSelector() + if (!opened) { + lastFailureReason = "click_model_selector_failed" + this.logPerplexityModelLockDebug("click_model_selector_failed", { + keyword, + attempt: attempt + 1, + }) + await this.sleep(250) + continue + } + } + await this.sleep(300) + + const menuItems = await this.waitForValue(() => { + const items = this.collectPerplexityOpenModelMenuItemsStrict(selectorBtn) + return items.length > 0 ? items : null + }, 3_000) + + if (!menuItems || menuItems.length === 0) { + lastFailureReason = "click_model_selector_failed" + this.logPerplexityModelLockDebug("menu_scan_empty", { + keyword, + attempt: attempt + 1, + }) + lastFailureReason = "first_menu_scan_empty" + this.openPerplexityModelSelector() + await this.sleep(350) + const retryItems = this.collectPerplexityOpenModelMenuItemsStrict(selectorBtn) + if (retryItems.length === 0) { + lastFailureReason = "retry_menu_scan_empty" + this.logPerplexityModelLockDebug("menu_retry_empty", { + keyword, + attempt: attempt + 1, + rootCount: this.findPerplexityModelMenuRoots(selectorBtn).length, + checkedItems: this.getCheckedModelItemTexts(), + }) + document.body.click() + await this.sleep(300) + continue + } + } + + const effectiveItems = + menuItems && menuItems.length > 0 + ? menuItems + : this.collectPerplexityOpenModelMenuItemsStrict(selectorBtn) + + if (!effectiveItems || effectiveItems.length === 0) { + lastFailureReason = "effective_menu_items_empty" + this.logPerplexityModelLockDebug("effective_items_empty", { + keyword, + attempt: attempt + 1, + }) + document.body.click() + await this.sleep(300) + continue + } + + this.logPerplexityModelLockDebug("menu_items_found", { + keyword, + attempt: attempt + 1, + count: effectiveItems.length, + items: effectiveItems.slice(0, 10).map((item) => ({ + text: this.normalizeUiText(item.textContent || ""), + role: item.getAttribute("role"), + ariaChecked: item.getAttribute("aria-checked"), + dataState: item.getAttribute("data-state"), + rect: this.getDebugRect(item), + })), + }) + + const matchedItem = this.findPerplexityModelMenuItemStrict(effectiveItems, target) + if (!matchedItem) { + lastFailureReason = "matched_item_not_found" + const preview = effectiveItems + .slice(0, 8) + .map((item) => this.normalizeUiText(item.textContent || "").slice(0, 32)) + .filter(Boolean) + .join(" | ") + this.logPerplexityModelLockDebug("matched_item_missing", { + keyword, + attempt: attempt + 1, + menuPreview: preview, + }) + void this.showPerplexityDebugToast( + `[Perplexity Debug] target model not found: ${keyword}${preview ? ` | menu: ${preview}` : ""}`, + "perplexity-model-lock-not-found", + ) + document.body.click() + return + } + + this.logPerplexityModelLockDebug("matched_item_found", { + keyword, + attempt: attempt + 1, + text: this.normalizeUiText(matchedItem.textContent || ""), + role: matchedItem.getAttribute("role"), + rect: this.getDebugRect(matchedItem), + }) + + this.activatePerplexityModelMenuItem(matchedItem) + + const switched = await this.waitForCondition(() => { + if (this.getCurrentPerplexityModelText().includes(target)) return true + return this.isTargetModelChecked(target) + }, 3_500) + + await this.closePerplexityModelSelector(selectorBtn) + + if (switched) { + this.logPerplexityModelLockDebug("switch_confirmed", { + keyword, + attempt: attempt + 1, + checkedItems: this.getCheckedModelItemTexts(), + }) + onSuccess?.() + return + } + + lastFailureReason = "switch_not_confirmed" + this.logPerplexityModelLockDebug("switch_not_confirmed", { + keyword, + attempt: attempt + 1, + checkedItems: this.getCheckedModelItemTexts(), + selectorText: this.findPerplexityModelSelectorButton()?.textContent || "", + }) + await this.closePerplexityModelSelector(selectorBtn) + await this.sleep(300) + } + + this.logPerplexityModelLockDebug("lock_failed", { + keyword, + reason: lastFailureReason, + checkedItems: this.getCheckedModelItemTexts(), + }) + void this.showPerplexityDebugToast( + `[Perplexity Debug] model selection failed for "${keyword}" | ${lastFailureReason}`, + "perplexity-model-lock-menu", + ) + })() + } + async toggleTheme(targetMode: "light" | "dark" | "system"): Promise { try { const root = document.documentElement @@ -801,7 +1159,10 @@ export class PerplexityAdapter extends SiteAdapter { } } - private async deleteConversationViaUi(id: string): Promise { + private async deleteConversationViaUi( + id: string, + ): Promise<{ success: boolean; reason?: string }> { + let sidebarReason: string | undefined const sidebarLink = await this.waitForValue(() => this.findSidebarConversationLink(id), 600) if (sidebarLink) { const container = this.findConversationItemContainer(sidebarLink) @@ -810,21 +1171,34 @@ export class PerplexityAdapter extends SiteAdapter { triggerScope: container || sidebarLink, }) - if (actionButton && (await this.confirmDeleteFromOpenMenu())) { - this.syncConversationListAfterDelete(id) - return true + if (actionButton) { + const result = await this.confirmDeleteFromOpenMenu(id, false, actionButton) + if (result.success) { + this.syncConversationListAfterDelete(id) + return { success: true } + } + + sidebarReason = result.reason + } else { + sidebarReason = "sidebar_action_button_not_found" } } if (this.getSessionId() !== id && !this.navigateToConversation(id)) { - return false + return { success: false, reason: sidebarReason || "navigate_failed" } } const ready = await this.waitForCondition( () => this.getSessionId() === id, DELETE_FLOW_TIMEOUT_MS, ) - if (!ready) return false + if (!ready) { + void this.showPerplexityDebugToast( + `[Perplexity Debug] delete target did not become active: ${id}`, + "perplexity-delete-session-not-ready", + ) + return { success: false, reason: sidebarReason || "session_not_ready" } + } const actionButton = await this.openThreadActionMenu({ preferredContainer: document.querySelector( @@ -832,14 +1206,120 @@ export class PerplexityAdapter extends SiteAdapter { ) as HTMLElement | null, triggerScope: document.body, }) - if (!actionButton) return false + if (!actionButton) { + void this.showPerplexityDebugToast( + `[Perplexity Debug] conversation action button not found: ${id}`, + "perplexity-delete-action-button", + ) + return { success: false, reason: sidebarReason || "action_button_not_found" } + } - const deleted = await this.confirmDeleteFromOpenMenu() - if (deleted) { + const deleted = await this.confirmDeleteFromOpenMenu(id, true, actionButton) + if (deleted.success) { this.syncConversationListAfterDelete(id) + return { success: true } + } + + return { success: false, reason: deleted.reason || sidebarReason || "deletion_not_observed" } + } + + private async renameConversationViaUi( + id: string, + newTitle: string, + ): Promise<{ success: boolean; reason?: string }> { + const normalizedTitle = newTitle.trim() + if (!normalizedTitle) { + return { success: false, reason: "empty_title" } + } + + const sidebarLink = await this.waitForValue(() => this.findSidebarConversationLink(id), 1_200) + if (!sidebarLink) { + void this.showPerplexityDebugToast( + `[Perplexity Debug] rename target not found in sidebar: ${id}`, + "perplexity-rename-sidebar-link", + ) + return { success: false, reason: "sidebar_link_not_found" } + } + + const container = this.findConversationItemContainer(sidebarLink) + const actionButton = await this.openThreadActionMenu({ + preferredContainer: container, + triggerScope: container || sidebarLink, + menuOpenedCheck: () => this.findOpenRenameMenuItem(sidebarLink) !== null, + }) + if (!actionButton) { + void this.showPerplexityDebugToast( + `[Perplexity Debug] rename action button not found: ${id}`, + "perplexity-rename-action-button", + ) + return { success: false, reason: "rename_action_button_not_found" } + } + + const renameItem = await this.waitForValue( + () => this.findOpenRenameMenuItem(actionButton), + DELETE_FLOW_TIMEOUT_MS, + ) + if (!renameItem) { + void this.showPerplexityDebugToast( + `[Perplexity Debug] rename menu item not found: ${id}`, + "perplexity-rename-menu-item", + ) + return { success: false, reason: "rename_menu_item_not_found" } + } + + this.simulateClick(renameItem) + + const dialog = await this.waitForValue(() => this.findRenameDialog(), 2_000) + if (!dialog) { + void this.showPerplexityDebugToast( + `[Perplexity Debug] rename dialog not found: ${id}`, + "perplexity-rename-dialog", + ) + return { success: false, reason: "rename_dialog_not_found" } } - return deleted + const editor = this.findRenameDialogEditor(dialog) + if (!editor) { + void this.showPerplexityDebugToast( + `[Perplexity Debug] rename editor not found: ${id}`, + "perplexity-rename-editor", + ) + return { success: false, reason: "rename_editor_not_found" } + } + + this.setRenameDialogValue(editor, normalizedTitle) + + const saveButton = await this.waitForValue(() => this.findRenameSaveButton(dialog), 1_500) + if (!saveButton) { + void this.showPerplexityDebugToast( + `[Perplexity Debug] rename save button not found: ${id}`, + "perplexity-rename-save-button", + ) + return { success: false, reason: "rename_save_button_not_found" } + } + + this.simulateClick(saveButton) + + const renamed = await this.waitForCondition(() => { + const link = this.findSidebarConversationLink(id) + if (!link) return false + const title = this.extractConversationTitle(link, id) + return ( + this.normalizeConversationTitle(title, id) === + this.normalizeConversationTitle(normalizedTitle, id) + ) + }, 3_000) + + if (!renamed) { + void this.showPerplexityDebugToast( + `[Perplexity Debug] rename was not observed on site: ${id}`, + "perplexity-rename-not-observed", + ) + return { success: false, reason: "rename_not_observed" } + } + + this.syncConversationListAfterRename(id, normalizedTitle) + return { success: true } } private findScrollableParent(element: Element | null): HTMLElement | null { @@ -859,8 +1339,7 @@ export class PerplexityAdapter extends SiteAdapter { this.threadListCache = this.threadListCache.filter((item) => item.id !== id) this.threadListCacheExpiresAt = Math.min(this.threadListCacheExpiresAt, Date.now() + 10_000) - document.querySelectorAll(SIDEBAR_LINK_SELECTOR).forEach((element) => { - const anchor = element as HTMLAnchorElement + this.getNativeSidebarConversationLinks().forEach((anchor) => { if (this.parseThreadSlugFromUrl(anchor.getAttribute("href") || anchor.href || "") !== id) return @@ -869,6 +1348,13 @@ export class PerplexityAdapter extends SiteAdapter { }) } + private syncConversationListAfterRename(id: string, title: string): void { + this.threadListCache = this.threadListCache.map((item) => + item.id === id ? { ...item, title } : item, + ) + this.threadListCacheExpiresAt = Math.min(this.threadListCacheExpiresAt, Date.now() + 10_000) + } + private parseThreadSlugFromUrl(url: string): string { try { const parsed = new URL(url, window.location.origin) @@ -883,6 +1369,7 @@ export class PerplexityAdapter extends SiteAdapter { private extractConversationInfo(element: Element): ConversationInfo | null { const anchor = element.closest("a") as HTMLAnchorElement | null if (!anchor) return null + if (!this.isNativeSidebarConversationLink(anchor)) return null const slug = this.parseThreadSlugFromUrl(anchor.href || anchor.getAttribute("href") || "") if (!slug) return null @@ -898,15 +1385,9 @@ export class PerplexityAdapter extends SiteAdapter { } private collectConversationListFromDom(): ConversationInfo[] { - const root = - (this.getSidebarScrollContainer() as ParentNode | null) || - document.querySelector(".group\\/sidebar") || - document.querySelector("aside") || - document - const links = root.querySelectorAll(SIDEBAR_LINK_SELECTOR) const result = new Map() - links.forEach((link) => { + this.getNativeSidebarConversationLinks().forEach((link) => { const info = this.extractConversationInfo(link) if (!info) return result.set(info.id, info) @@ -916,9 +1397,8 @@ export class PerplexityAdapter extends SiteAdapter { } private findSidebarConversationLink(id: string): HTMLAnchorElement | null { - const links = document.querySelectorAll(SIDEBAR_LINK_SELECTOR) - for (const link of Array.from(links)) { - const anchor = link as HTMLAnchorElement + const links = this.getNativeSidebarConversationLinks() + for (const anchor of links) { const slug = this.parseThreadSlugFromUrl(anchor.getAttribute("href") || anchor.href || "") if (slug === id) { return anchor @@ -928,10 +1408,86 @@ export class PerplexityAdapter extends SiteAdapter { return null } + private findPerplexityModelSelectorButton(): HTMLElement | null { + const editor = this.getTextareaElement() + const scopes = [ + editor?.closest("form"), + editor?.parentElement, + editor?.closest("main"), + document.querySelector("main"), + document.body, + ].filter(Boolean) as ParentNode[] + + let best: { element: HTMLElement; score: number } | null = null + + for (const scope of scopes) { + for (const selector of MODEL_SELECTOR_BUTTON_SELECTORS) { + const exactCandidates = scope.querySelectorAll(selector) + for (const candidate of Array.from(exactCandidates)) { + if (!(candidate instanceof HTMLElement)) continue + if ( + candidate.closest( + "[role='menu'], [role='listbox'], [data-radix-popper-content-wrapper], [data-radix-portal]", + ) + ) { + continue + } + if (this.isElementInsideOphel(candidate)) continue + if (!this.isVisibleElement(candidate) || this.isDisabledActionButton(candidate)) continue + if (!this.isPerplexityModelSelectorCandidate(candidate)) continue + const score = this.scorePerplexityModelSelectorCandidate(candidate, editor) + 200 + if (!best || score > best.score) { + best = { element: candidate, score } + } + } + } + + const candidates = scope.querySelectorAll( + 'button, [role="button"], [role="combobox"], [aria-haspopup="menu"], [aria-haspopup="listbox"], [aria-haspopup="dialog"]', + ) + for (const candidate of Array.from(candidates)) { + if (!(candidate instanceof HTMLElement)) continue + if ( + candidate.closest( + "[role='menu'], [role='listbox'], [data-radix-popper-content-wrapper], [data-radix-portal]", + ) + ) { + continue + } + if (this.isElementInsideOphel(candidate)) continue + if (!this.isVisibleElement(candidate) || this.isDisabledActionButton(candidate)) continue + if (candidate.matches(SUBMIT_BUTTON_SELECTOR) || candidate.matches(STOP_BUTTON_SELECTOR)) { + continue + } + if (!this.isPerplexityModelSelectorCandidate(candidate)) continue + + const score = this.scorePerplexityModelSelectorCandidate(candidate, editor) + if (!best || score > best.score) { + best = { element: candidate, score } + } + } + } + + return best?.element || null + } + + private isPerplexityModelSelectorCandidate(candidate: HTMLElement): boolean { + const signal = this.getUiSignalText(candidate) + if (!this.looksLikePerplexityModelSelector(candidate)) return false + if (MODEL_SELECTOR_EXCLUDE_PATTERNS.some((pattern) => pattern.test(signal))) return false + + const hasExplicitModelLabel = + signal.includes("模型") || + MODEL_SELECTOR_PRIMARY_HINTS.some((hint) => signal.includes(this.normalizeUiText(hint))) + + return hasExplicitModelLabel + } + private findConversationItemContainer(anchor: Element | null): HTMLElement | null { if (!(anchor instanceof HTMLElement)) return null - return (anchor.closest("li") || + return (anchor.closest(SIDEBAR_ITEM_CONTAINER_SELECTORS) || + anchor.closest("li") || anchor.closest("[role='listitem']") || anchor.closest(".group") || anchor.parentElement) as HTMLElement | null @@ -1053,115 +1609,797 @@ export class PerplexityAdapter extends SiteAdapter { return results } - private async openThreadActionMenu({ - preferredContainer, - triggerScope, - }: { - preferredContainer: HTMLElement | null - triggerScope: ParentNode - }): Promise { - if (preferredContainer) { - this.revealConversationActions(preferredContainer) - } + private getNativeSidebarConversationLinks(): HTMLAnchorElement[] { + const root = this.getNativeSidebarRoot() + if (!root) return [] - const actionButton = await this.waitForValue( - () => this.findThreadActionButton(preferredContainer, triggerScope), - 1500, - ) - if (!actionButton) return null + const links = root.querySelectorAll(SIDEBAR_LINK_SELECTOR) + const results: HTMLAnchorElement[] = [] + const seen = new Set() - this.simulateClick(actionButton) + for (const link of Array.from(links)) { + if (!(link instanceof HTMLAnchorElement)) continue + if (seen.has(link)) continue + if (!this.isNativeSidebarConversationLink(link)) continue + seen.add(link) + results.push(link) + } - const opened = await this.waitForCondition( - () => this.findOpenDeleteMenuItem() !== null, - DELETE_FLOW_TIMEOUT_MS, - ) - return opened ? actionButton : null + return results } - private revealConversationActions(container: HTMLElement): void { - const events = [ - new PointerEvent("pointerenter", { bubbles: true, composed: true }), - new MouseEvent("mouseenter", { bubbles: true, composed: true }), - new MouseEvent("mouseover", { bubbles: true, composed: true }), - ] + private getNativeSidebarRoot(): ParentNode | null { + const explicitRoots = [ + this.getSidebarScrollContainer(), + document.querySelector(".group\\/sidebar"), + document.querySelector("aside"), + document.querySelector("nav"), + ].filter(Boolean) as Element[] - events.forEach((event) => container.dispatchEvent(event)) + for (const root of explicitRoots) { + if (this.isElementInsideOphel(root)) continue + if ( + root.querySelector( + ".group\\/sidebar-submenu a[href^='/search/'], .group\\/sidebar-submenu a[href^='/page/']", + ) + ) { + return root + } + } + + const item = document.querySelector(".group\\/sidebar-submenu") + if (item && !this.isElementInsideOphel(item)) { + return item.parentElement || item + } + + return null } - private findThreadActionButton( - preferredContainer: HTMLElement | null, - triggerScope: ParentNode, - ): HTMLElement | null { - const scopes = [preferredContainer, triggerScope, document.body].filter(Boolean) as ParentNode[] + private isNativeSidebarConversationLink( + anchor: HTMLAnchorElement | null, + ): anchor is HTMLAnchorElement { + if (!(anchor instanceof HTMLAnchorElement)) return false + if (!anchor.isConnected) return false + if (this.isElementInsideOphel(anchor)) return false - for (const scope of scopes) { - for (const selector of THREAD_ACTION_BUTTON_SELECTORS) { - const candidates = scope.querySelectorAll(selector) - for (const candidate of Array.from(candidates)) { - if (!(candidate instanceof HTMLElement)) continue - if (!this.isVisibleElement(candidate)) continue - if (this.matchesUiLabel(candidate, THREAD_ACTION_LABELS)) { - return candidate - } - } - } + const href = anchor.getAttribute("href") || anchor.href || "" + const slug = this.parseThreadSlugFromUrl(href) + if (!slug) return false + + const row = anchor.closest(SIDEBAR_ITEM_CONTAINER_SELECTORS) + if (row && !this.isElementInsideOphel(row as Element)) { + return true } - return null + const root = this.getNativeSidebarRoot() + return Boolean(root && root.contains(anchor)) } - private async confirmDeleteFromOpenMenu(): Promise { - const deleteItem = await this.waitForValue( - () => this.findOpenDeleteMenuItem(), - DELETE_FLOW_TIMEOUT_MS, + private isElementInsideOphel(element: Element | null): boolean { + return Boolean(element?.closest(".gh-root")) + } + + private findPerplexityModelMenuRoots(anchor: HTMLElement | null): HTMLElement[] { + const candidates = document.querySelectorAll( + "[role='menu'], [role='listbox'], [data-radix-popper-content-wrapper], [data-radix-portal], [data-state='open']", ) - if (!deleteItem) return false + const roots: Array<{ element: HTMLElement; score: number }> = [] + const seen = new Set() - this.simulateClick(deleteItem) + for (const candidate of Array.from(candidates)) { + if (!(candidate instanceof HTMLElement)) continue + if (seen.has(candidate)) continue + if (!this.isVisibleElement(candidate)) continue + if (this.isElementInsideOphel(candidate)) continue - const confirmButton = await this.waitForValue( - () => this.findConfirmationButton(), - DELETE_FLOW_TIMEOUT_MS, - ) - if (!confirmButton) return false + const score = + (candidate.getAttribute("role") === "menu" ? 80 : 0) + + (candidate.getAttribute("role") === "listbox" ? 60 : 0) + + this.getOverlayProximityScore(candidate, anchor) - this.simulateClick(confirmButton) + roots.push({ element: candidate, score }) + seen.add(candidate) + } - await this.sleep(300) - return true + roots.sort((left, right) => right.score - left.score) + return roots.map(({ element }) => element) } - private findOpenDeleteMenuItem(): HTMLElement | null { - const menus = document.querySelectorAll("[role='menu'], [data-radix-popper-content-wrapper]") - for (const menu of Array.from(menus)) { - const buttons = menu.querySelectorAll("button, [role='menuitem'], [role='menuitemradio']") - for (const button of Array.from(buttons)) { - if (!(button instanceof HTMLElement)) continue - if (this.matchesUiLabel(button, DELETE_MENU_ITEM_LABELS)) { - return button - } + private collectVisibleModelMenuItems(anchor: HTMLElement | null = null): HTMLElement[] { + const menus = this.findPerplexityModelMenuRoots(anchor) + const items: HTMLElement[] = [] + const seen = new Set() + + const addCandidate = (candidate: HTMLElement) => { + if (!this.isVisibleElement(candidate)) return + if (seen.has(candidate)) return + if (this.isElementInsideOphel(candidate)) return + if (!this.looksLikePerplexityModelMenuItem(candidate, anchor)) return + seen.add(candidate) + items.push(candidate) + } + + const pushCandidates = (root: ParentNode, selector?: string) => { + const candidates = root.querySelectorAll( + selector || + "button, [role='button'], [role='menuitemradio'], [role='menuitemcheckbox'], [role='menuitem'], [role='option'], [data-radix-collection-item], [tabindex]", + ) + for (const candidate of Array.from(candidates)) { + if (!(candidate instanceof HTMLElement)) continue + addCandidate(candidate) } } - return null - } + for (const menu of menus) { + pushCandidates(menu) + } + + if (items.length === 0) { + // Some Perplexity builds render the floating panel as a generic fixed overlay + // while only the individual rows expose role="menuitemradio". + pushCandidates( + document.body, + "[role='menuitemradio'], [role='menuitemcheckbox'], [role='menuitem'], [role='option'], [data-radix-collection-item], [aria-checked]", + ) + } + + if (items.length === 0) { + // Last-resort fallback aligned to the observed Perplexity structure: + // visible radio items inside the floating model menu. + const directItems = document.querySelectorAll( + "[role='menuitemradio'], [role='menuitemcheckbox'], [role='option']", + ) + for (const candidate of Array.from(directItems)) { + if (!(candidate instanceof HTMLElement)) continue + addCandidate(candidate) + } + } + + return items.sort( + (left, right) => + this.scorePerplexityModelMenuItem(right, anchor) - + this.scorePerplexityModelMenuItem(left, anchor), + ) + } + + private collectDirectVisibleModelMenuItems(anchor: HTMLElement | null = null): HTMLElement[] { + const items = Array.from( + document.querySelectorAll( + "[role='menuitemradio'], [role='menuitemcheckbox'], [role='option']", + ), + ).filter((item): item is HTMLElement => item instanceof HTMLElement) + + return items + .filter((item) => this.isVisibleElement(item)) + .filter((item) => !this.isElementInsideOphel(item)) + .filter((item) => this.looksLikePerplexityModelMenuItem(item, anchor)) + .sort( + (left, right) => + this.scorePerplexityModelMenuItem(right, anchor) - + this.scorePerplexityModelMenuItem(left, anchor), + ) + } + + private findPerplexityModelSelectorButtonStrict(): HTMLElement | null { + const editor = this.getTextareaElement() + const editorRect = editor?.getBoundingClientRect() || null + const scopes = [ + editor?.closest("form"), + editor?.parentElement, + editor?.closest("main"), + document.querySelector("main"), + document.body, + ].filter(Boolean) as ParentNode[] + + let best: { element: HTMLElement; score: number } | null = null + const seen = new Set() + + for (const scope of scopes) { + const candidates = scope.querySelectorAll( + 'button[aria-haspopup="menu"], [role="button"][aria-haspopup="menu"], [role="combobox"]', + ) + for (const candidate of Array.from(candidates)) { + if (!(candidate instanceof HTMLElement)) continue + if (seen.has(candidate)) continue + seen.add(candidate) + if ( + candidate.closest( + "[role='menu'], [role='listbox'], [data-radix-popper-content-wrapper], [data-radix-portal]", + ) + ) { + continue + } + if (this.isElementInsideOphel(candidate)) continue + if (!this.isVisibleElement(candidate) || this.isDisabledActionButton(candidate)) continue + if (candidate.matches(SUBMIT_BUTTON_SELECTOR) || candidate.matches(STOP_BUTTON_SELECTOR)) + continue + + const signal = this.getUiSignalText(candidate) + if (MODEL_SELECTOR_EXCLUDE_PATTERNS.some((pattern) => pattern.test(signal))) { + continue + } + + const rect = candidate.getBoundingClientRect() + let score = this.scorePerplexityModelSelectorCandidate(candidate, editor) + 200 + + if (editor && candidate.closest("form") === editor.closest("form")) score += 500 + if (editorRect) { + const distanceY = Math.abs(rect.top - editorRect.top) + const distanceX = Math.abs(rect.right - editorRect.right) + score -= distanceY * 0.8 + score -= distanceX * 0.2 + if (rect.top < editorRect.top - 200) score -= 400 + if (rect.bottom > editorRect.bottom + 160) score -= 150 + if (rect.left >= editorRect.left - 120 && rect.right <= editorRect.right + 80) + score += 120 + } + + if (signal.includes("模型")) score += 80 + if ( + MODEL_SELECTOR_PRIMARY_HINTS.some((hint) => signal.includes(this.normalizeUiText(hint))) + ) { + score += 180 + } + + if (!best || score > best.score) { + best = { element: candidate, score } + } + } + } + + return best?.element || null + } + + private collectPerplexityOpenModelMenuItemsStrict(anchor: HTMLElement | null): HTMLElement[] { + const roots = Array.from( + document.querySelectorAll( + "[role='menu'][data-state='open'], [role='menu'], [data-radix-portal] [role='menu']", + ), + ).filter( + (root): root is HTMLElement => + root instanceof HTMLElement && + this.isVisibleElement(root) && + !this.isElementInsideOphel(root), + ) + + const items: HTMLElement[] = [] + const seen = new Set() + + for (const root of roots) { + const candidates = root.querySelectorAll("[role='menuitemradio'], [role='menuitemcheckbox']") + for (const candidate of Array.from(candidates)) { + if (!(candidate instanceof HTMLElement)) continue + if (!this.isVisibleElement(candidate)) continue + if (this.isElementInsideOphel(candidate)) continue + if (seen.has(candidate)) continue + seen.add(candidate) + items.push(candidate) + } + } + + if (items.length === 0) { + return this.collectDirectVisibleModelMenuItems(anchor) + } + + return items.sort( + (left, right) => + this.scorePerplexityModelMenuItem(right, anchor) - + this.scorePerplexityModelMenuItem(left, anchor), + ) + } + + private findPerplexityModelMenuItemStrict( + menuItems: HTMLElement[], + target: string, + ): HTMLElement | null { + const normalizedTarget = this.normalizeUiText(target) + const normalizedItems = menuItems.map((item) => { + const text = this.normalizeUiText(item.textContent || "") + return { item, text } + }) + + const exact = normalizedItems.find(({ text }) => text === normalizedTarget) + if (exact) return exact.item + + const startsWith = normalizedItems.find(({ text }) => text.startsWith(normalizedTarget)) + if (startsWith) return startsWith.item + + const includes = normalizedItems.find(({ text }) => text.includes(normalizedTarget)) + if (includes) return includes.item + + return null + } + + private findBestPerplexityModelMenuItem( + menuItems: HTMLElement[], + target: string, + ): HTMLElement | null { + const normalizedTarget = this.normalizeUiText(target) + const normalizedItems = menuItems.map((item) => { + const text = this.normalizeUiText(item.textContent || "") + const firstLine = text.split("\n")[0]?.trim() || text + return { item, text, firstLine } + }) + + const exact = normalizedItems.find( + ({ text, firstLine }) => firstLine === normalizedTarget || text === normalizedTarget, + ) + if (exact) return exact.item + + const suffix = normalizedItems.find(({ firstLine }) => firstLine.endsWith(normalizedTarget)) + if (suffix) return suffix.item + + const startsWith = normalizedItems.find(({ firstLine }) => + firstLine.startsWith(normalizedTarget), + ) + if (startsWith) return startsWith.item + + const fuzzy = normalizedItems.find(({ text }) => text.includes(normalizedTarget)) + return fuzzy?.item || null + } + + private async openThreadActionMenu({ + preferredContainer, + triggerScope, + menuOpenedCheck, + }: { + preferredContainer: HTMLElement | null + triggerScope: ParentNode + menuOpenedCheck?: (actionButton: HTMLElement) => boolean + }): Promise { + if (preferredContainer) { + this.revealConversationActions(preferredContainer) + } + + const actionButton = await this.waitForValue( + () => this.findThreadActionButton(preferredContainer, triggerScope), + 1500, + ) + if (!actionButton) return null + + this.simulateClick(actionButton) + + const opened = await this.waitForCondition( + () => + menuOpenedCheck?.(actionButton) || + this.isThreadActionMenuOpen(actionButton) || + this.findOpenDeleteMenuItem(actionButton) !== null, + DELETE_FLOW_TIMEOUT_MS, + ) + return opened ? actionButton : null + } + + private revealConversationActions(container: HTMLElement): void { + const events = [ + new PointerEvent("pointerenter", { bubbles: true, composed: true }), + new MouseEvent("mouseenter", { bubbles: true, composed: true }), + new MouseEvent("mouseover", { bubbles: true, composed: true }), + ] + + events.forEach((event) => container.dispatchEvent(event)) + } + + private findThreadActionButton( + preferredContainer: HTMLElement | null, + triggerScope: ParentNode, + ): HTMLElement | null { + const scopes = [preferredContainer, triggerScope].filter(Boolean) as ParentNode[] + + for (const scope of scopes) { + for (const selector of THREAD_ACTION_BUTTON_SELECTORS) { + const candidates = scope.querySelectorAll(selector) + for (const candidate of Array.from(candidates)) { + if (!(candidate instanceof HTMLElement)) continue + if (!this.isVisibleElement(candidate)) continue + if (this.matchesUiLabel(candidate, THREAD_ACTION_SIGNAL_LABELS)) { + return candidate + } + } + } + } + + return this.findScoredThreadActionButton( + preferredContainer, + triggerScope, + !preferredContainer && triggerScope === document.body, + ) + } + + private async confirmDeleteFromOpenMenu( + id: string, + expectSessionChange: boolean, + anchor: HTMLElement | null = null, + ): Promise<{ success: boolean; reason?: string }> { + const deleteItem = await this.waitForValue( + () => this.findOpenDeleteMenuItem(anchor), + DELETE_FLOW_TIMEOUT_MS, + ) + if (!deleteItem) { + void this.showPerplexityDebugToast( + `[Perplexity Debug] delete menu item not found: ${id}`, + "perplexity-delete-menu-item", + ) + return { success: false, reason: "delete_menu_item_not_found" } + } + + this.simulateClick(deleteItem) + + const confirmButton = await this.waitForValue(() => this.findConfirmationButton(), 1_500) + + if (confirmButton) { + this.simulateClick(confirmButton) + } + + const deleted = await this.waitForConversationDeletion(id, expectSessionChange) + if (!deleted) { + void this.showPerplexityDebugToast( + `[Perplexity Debug] delete was not observed on site: ${id}${confirmButton ? "" : " | confirm button not found"}`, + "perplexity-delete-not-observed", + ) + return { + success: false, + reason: confirmButton + ? "deletion_not_observed" + : "confirm_button_not_found_or_delete_not_observed", + } + } + + return { success: true } + } + + private findOpenDeleteMenuItem(anchor: HTMLElement | null = null): HTMLElement | null { + const candidates = document.querySelectorAll( + "button, [role='button'], [role='menuitem'], [role='menuitemradio'], [role='option'], [data-radix-collection-item], [tabindex]", + ) + let best: { element: HTMLElement; score: number } | null = null + + for (const candidate of Array.from(candidates)) { + if (!(candidate instanceof HTMLElement)) continue + if (!this.isVisibleElement(candidate)) continue + if (candidate.closest(".gh-root")) continue + + const signal = this.getUiSignalText(candidate) + const classSignal = this.normalizeUiText(candidate.className || "") + const matchesDelete = + this.matchesUiLabel(candidate, DELETE_MENU_ITEM_LABELS) || + /(danger|destructive|theme-error)/.test(classSignal) + + if (!matchesDelete) continue + + let score = 0 + if (this.matchesUiLabel(candidate, DELETE_MENU_ITEM_LABELS)) score += 120 + if (/(danger|destructive|theme-error)/.test(classSignal)) score += 60 + if (candidate.getAttribute("role")?.includes("menuitem")) score += 50 + if (candidate.hasAttribute("data-radix-collection-item")) score += 25 + if (anchor) score += this.getOverlayProximityScore(candidate, anchor) + if (/rename|重命名/.test(signal)) score = Number.NEGATIVE_INFINITY + if (!Number.isFinite(score)) continue + + if (!best || score > best.score) { + best = { element: candidate, score } + } + } + + return best?.element || null + } + + private findOpenRenameMenuItem(anchor: HTMLElement | null = null): HTMLElement | null { + const candidates = document.querySelectorAll( + "button, [role='button'], [role='menuitem'], [role='menuitemradio'], [role='option'], [data-radix-collection-item], [tabindex]", + ) + let best: { element: HTMLElement; score: number } | null = null + + for (const candidate of Array.from(candidates)) { + if (!(candidate instanceof HTMLElement)) continue + if (!this.isVisibleElement(candidate)) continue + if (candidate.closest(".gh-root")) continue + if (!this.matchesUiLabel(candidate, RENAME_MENU_ITEM_LABELS)) continue + + let score = 0 + if (candidate.getAttribute("role")?.includes("menuitem")) score += 80 + if (candidate.hasAttribute("data-radix-collection-item")) score += 30 + if (anchor) score += this.getOverlayProximityScore(candidate, anchor) + + if (!best || score > best.score) { + best = { element: candidate, score } + } + } + + return best?.element || null + } private findConfirmationButton(): HTMLElement | null { const dialogs = document.querySelectorAll("[role='dialog'], [data-radix-portal]") + let best: { element: HTMLElement; score: number } | null = null for (const dialog of Array.from(dialogs)) { const buttons = dialog.querySelectorAll("button, [role='button']") for (const button of Array.from(buttons)) { if (!(button instanceof HTMLElement)) continue + if (!this.isVisibleElement(button)) continue + if (this.matchesUiLabel(button, CANCEL_BUTTON_LABELS)) continue if (this.matchesUiLabel(button, CONFIRM_BUTTON_LABELS)) { - return button + const score = + (/(danger|destructive|theme-error)/.test(this.normalizeUiText(button.className || "")) + ? 60 + : 0) + (button.closest("[role='dialog']") ? 30 : 0) + if (!best || score > best.score) { + best = { element: button, score } + } } } } + if (best) return best.element + + const fallbackButtons = document.querySelectorAll("button, [role='button']") + for (const button of Array.from(fallbackButtons)) { + if (!(button instanceof HTMLElement)) continue + if (!this.isVisibleElement(button)) continue + if (button.closest(".gh-root")) continue + if (this.matchesUiLabel(button, CANCEL_BUTTON_LABELS)) continue + if (this.matchesUiLabel(button, CONFIRM_BUTTON_LABELS)) { + return button + } + } + + return null + } + + private findRenameDialog(): HTMLElement | null { + const candidates = document.querySelectorAll( + "[role='dialog'], [data-radix-portal], .fixed.inset-0, .fixed.inset-x-0, .fixed", + ) + let best: { element: HTMLElement; score: number } | null = null + + for (const candidate of Array.from(candidates)) { + if (!(candidate instanceof HTMLElement)) continue + if (!this.isVisibleElement(candidate)) continue + if (candidate.closest(".gh-root")) continue + + const signal = this.getUiSignalText(candidate) + const hasTitleHint = RENAME_DIALOG_TITLE_LABELS.some((label) => + signal.includes(this.normalizeUiText(label)), + ) + const hasSaveButton = this.findRenameSaveButton(candidate) !== null + const hasEditor = this.findRenameDialogEditor(candidate) !== null + if (!hasEditor || (!hasTitleHint && !hasSaveButton)) continue + + let score = 0 + if (hasTitleHint) score += 100 + if (hasSaveButton) score += 60 + if (hasEditor) score += 60 + + if (!best || score > best.score) { + best = { element: candidate, score } + } + } + + return best?.element || null + } + + private findRenameDialogEditor(dialog: ParentNode): HTMLElement | null { + const candidates = dialog.querySelectorAll( + "textarea, input[type='text'], [contenteditable='true'][role='textbox'], [contenteditable='true']", + ) + + for (const candidate of Array.from(candidates)) { + if (!(candidate instanceof HTMLElement)) continue + if (!this.isVisibleElement(candidate)) continue + if (candidate.matches("#ask-input")) continue + return candidate + } + + return null + } + + private findRenameSaveButton(dialog: ParentNode): HTMLElement | null { + const buttons = dialog.querySelectorAll("button, [role='button']") + for (const button of Array.from(buttons)) { + if (!(button instanceof HTMLElement)) continue + if (!this.isVisibleElement(button)) continue + if (this.matchesUiLabel(button, SAVE_BUTTON_LABELS)) { + return button + } + } + return null } + private setRenameDialogValue(editor: HTMLElement, value: string): void { + editor.focus() + + if (editor instanceof HTMLTextAreaElement || editor instanceof HTMLInputElement) { + this.setTextEntryValue(editor, value) + editor.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true, data: value })) + editor.dispatchEvent(new Event("change", { bubbles: true })) + if (editor instanceof HTMLTextAreaElement) { + editor.setSelectionRange(value.length, value.length) + } + return + } + + this.selectEditorContents(editor) + try { + document.execCommand("delete", false) + } catch { + // ignore + } + + if (this.dispatchPasteEvent(editor, value)) { + this.placeCaretAtEnd(editor) + return + } + + editor.textContent = value + this.dispatchEditorInputEvents(editor, value, "insertText") + this.placeCaretAtEnd(editor) + } + + private findVisibleDialog(): HTMLElement | null { + const dialogs = document.querySelectorAll("[role='dialog'], [data-radix-portal]") + for (const dialog of Array.from(dialogs)) { + if (dialog instanceof HTMLElement && this.isVisibleElement(dialog)) { + return dialog + } + } + + return null + } + + private async waitForConversationDeletion( + id: string, + expectSessionChange: boolean, + ): Promise { + const start = Date.now() + let apiChecked = false + + while (Date.now() - start < DELETE_FLOW_TIMEOUT_MS) { + if (this.isConversationDeletionSettled(id, expectSessionChange)) { + return true + } + + if (!apiChecked && Date.now() - start >= 1_000) { + apiChecked = true + if (await this.verifyConversationMissingViaApi(id)) { + return true + } + } + + await this.sleep(120) + } + + return this.verifyConversationMissingViaApi(id) + } + + private isConversationDeletionSettled(id: string, expectSessionChange: boolean): boolean { + const sessionChanged = !expectSessionChange || this.getSessionId() !== id + const sidebarLink = this.findSidebarConversationLink(id) + const sidebarRemoved = !sidebarLink || !sidebarLink.isConnected + const dialogClosed = this.findVisibleDialog() === null + + return sessionChanged && dialogClosed && sidebarRemoved + } + + private async verifyConversationMissingViaApi(id: string): Promise { + try { + const conversations = await this.fetchThreadsViaApi() + return !conversations.some((item) => item.id === id) + } catch { + return false + } + } + + private findScoredThreadActionButton( + preferredContainer: HTMLElement | null, + triggerScope: ParentNode, + includeBodyFallback = false, + ): HTMLElement | null { + const scopes = [preferredContainer, triggerScope] + .concat(includeBodyFallback ? [document.body] : []) + .filter(Boolean) as ParentNode[] + let best: { element: HTMLElement; score: number } | null = null + + for (const scope of scopes) { + const candidates = scope.querySelectorAll( + 'button, [role="button"], [aria-haspopup="menu"], [aria-haspopup="dialog"], [aria-haspopup="listbox"]', + ) + for (const candidate of Array.from(candidates)) { + if (!(candidate instanceof HTMLElement)) continue + const score = this.scoreThreadActionButtonCandidate(candidate, preferredContainer) + if (!Number.isFinite(score)) continue + if (!best || score > best.score) { + best = { element: candidate, score } + } + } + } + + return best?.element || null + } + + private isPerplexityModelMenuOpen(selectorBtn: HTMLElement | null): boolean { + if (!selectorBtn) return false + + const expanded = (selectorBtn.getAttribute("aria-expanded") || "").toLowerCase() + const state = (selectorBtn.getAttribute("data-state") || "").toLowerCase() + if (expanded === "true" || state === "open") { + return true + } + + return this.collectVisibleModelMenuItems(selectorBtn).length > 0 + } + + private isThreadActionMenuOpen(actionButton: HTMLElement | null): boolean { + if (!actionButton) return false + + const expanded = (actionButton.getAttribute("aria-expanded") || "").toLowerCase() + const state = (actionButton.getAttribute("data-state") || "").toLowerCase() + if (expanded === "true" || state === "open") { + return true + } + + return this.findOpenDeleteMenuItem(actionButton) !== null + } + + private getOverlayProximityScore(candidate: HTMLElement, anchor: HTMLElement | null): number { + if (!anchor) return 0 + + const candidateRect = candidate.getBoundingClientRect() + const anchorRect = anchor.getBoundingClientRect() + const candidateCenterX = candidateRect.left + candidateRect.width / 2 + const candidateCenterY = candidateRect.top + candidateRect.height / 2 + const anchorCenterX = anchorRect.left + anchorRect.width / 2 + const anchorCenterY = anchorRect.top + anchorRect.height / 2 + const distanceX = Math.abs(candidateCenterX - anchorCenterX) + const distanceY = Math.abs(candidateCenterY - anchorCenterY) + + let score = 0 + score -= distanceX * 0.2 + score -= distanceY * 0.12 + if (candidateRect.bottom <= anchorRect.top + 16) score += 50 + if (candidateRect.top >= anchorRect.bottom - 16) score += 35 + if ( + candidateRect.left >= anchorRect.left - 80 && + candidateRect.right <= anchorRect.right + 280 + ) { + score += 45 + } + + return score + } + + private scoreThreadActionButtonCandidate( + candidate: HTMLElement, + preferredContainer: HTMLElement | null, + ): number { + if (!this.isVisibleElement(candidate) || this.isDisabledActionButton(candidate)) { + return Number.NEGATIVE_INFINITY + } + if (this.isElementInsideOphel(candidate)) return Number.NEGATIVE_INFINITY + if (candidate.matches("input, textarea, label")) return Number.NEGATIVE_INFINITY + if (candidate.matches(SUBMIT_BUTTON_SELECTOR) || candidate.matches(STOP_BUTTON_SELECTOR)) { + return Number.NEGATIVE_INFINITY + } + + const signal = this.getUiSignalText(candidate) + let score = 0 + + if (candidate.getAttribute("aria-haspopup")) score += 120 + if (THREAD_ACTION_SIGNAL_LABELS.some((label) => signal.includes(this.normalizeUiText(label)))) { + score += 100 + } + if (/(ellipsis|menu|more|更多|菜单|\.{3}|…)/.test(signal)) score += 80 + if (preferredContainer && candidate.closest("li") === preferredContainer.closest("li")) + score += 40 + if (preferredContainer && candidate.closest(".h-headerHeight.fixed.z-10")) score += 25 + if (candidate.matches("button, [role='button']")) score += 10 + + const rect = candidate.getBoundingClientRect() + score += Math.max(0, rect.right) * 0.05 + score -= Math.max(0, rect.top) * 0.02 + + return score + } + private findUserContentRoot(element: Element): HTMLElement | null { const candidates = Array.from(element.querySelectorAll(USER_QUERY_CONTENT_SELECTOR)).filter( (candidate) => candidate instanceof HTMLElement, @@ -1781,13 +3019,7 @@ export class PerplexityAdapter extends SiteAdapter { } private matchesUiLabel(element: HTMLElement, labels: string[]): boolean { - const text = this.normalizeUiText( - [ - element.textContent || "", - element.getAttribute("aria-label") || "", - element.getAttribute("title") || "", - ].join(" "), - ) + const text = this.getUiSignalText(element) return labels.some((label) => { const normalized = this.normalizeUiText(label) @@ -1799,6 +3031,366 @@ export class PerplexityAdapter extends SiteAdapter { return value.replace(/\s+/g, " ").trim().toLowerCase() } + protected simulateClick(element: HTMLElement): void { + const rect = element.getBoundingClientRect() + const clientX = rect.left + Math.max(1, Math.min(rect.width / 2, Math.max(1, rect.width - 1))) + const clientY = rect.top + Math.max(1, Math.min(rect.height / 2, Math.max(1, rect.height - 1))) + const mouseInit: MouseEventInit = { + bubbles: true, + cancelable: true, + composed: true, + view: window, + button: 0, + buttons: 1, + clientX, + clientY, + } + const pointerInit: PointerEventInit = { + ...mouseInit, + pointerId: 1, + pointerType: "mouse", + isPrimary: true, + } + + element.focus?.() + element.dispatchEvent(new PointerEvent("pointerenter", pointerInit)) + element.dispatchEvent(new PointerEvent("pointerover", pointerInit)) + element.dispatchEvent(new MouseEvent("mouseenter", mouseInit)) + element.dispatchEvent(new MouseEvent("mouseover", mouseInit)) + element.dispatchEvent(new PointerEvent("pointerdown", pointerInit)) + element.dispatchEvent(new MouseEvent("mousedown", mouseInit)) + element.dispatchEvent(new PointerEvent("pointerup", pointerInit)) + element.dispatchEvent(new MouseEvent("mouseup", mouseInit)) + element.dispatchEvent(new MouseEvent("click", mouseInit)) + } + + private getUiSignalText(element: HTMLElement): string { + return this.normalizeUiText( + [ + element.textContent || "", + element.getAttribute("aria-label") || "", + element.getAttribute("title") || "", + element.getAttribute("data-testid") || "", + element.getAttribute("data-test-id") || "", + element.id || "", + element.className || "", + ].join(" "), + ) + } + + private looksLikePerplexityModelSelector(element: HTMLElement): boolean { + const signal = this.getUiSignalText(element) + + if (!signal) return false + if (this.matchesUiLabel(element, THREAD_ACTION_SIGNAL_LABELS)) return false + if (/(share|分享|attach|附件|upload|上传|search|搜索|submit|send)/.test(signal)) return false + + if ( + MODEL_SELECTOR_PRIMARY_HINTS.some((keyword) => signal.includes(this.normalizeUiText(keyword))) + ) { + return true + } + + if (signal.includes("模型")) { + return true + } + + const hasPopup = ["menu", "listbox", "dialog"].includes( + (element.getAttribute("aria-haspopup") || "").toLowerCase(), + ) + return hasPopup && signal.length <= 32 + } + + private scorePerplexityModelSelectorCandidate( + element: HTMLElement, + editor: HTMLElement | null, + ): number { + let score = 0 + const signal = this.getUiSignalText(element) + + const hasPrimaryHint = MODEL_SELECTOR_PRIMARY_HINTS.some((keyword) => + signal.includes(this.normalizeUiText(keyword)), + ) + const isGenericModelLabel = + signal === "模型" || signal.endsWith(" 模型") || signal.includes("aria-label 模型") + + if (hasPrimaryHint) score += 220 + else if (signal.includes("模型")) score += 60 + if (element.getAttribute("aria-haspopup")) score += 50 + if (editor && element.closest("form") === editor.closest("form")) score += 60 + if (isGenericModelLabel) score -= 40 + + const rect = element.getBoundingClientRect() + score += Math.max(0, rect.top) + score -= Math.abs(rect.right - window.innerWidth) * 0.15 + + return score + } + + private looksLikePerplexityModelMenuItem( + element: HTMLElement, + anchor: HTMLElement | null = null, + ): boolean { + const signal = this.getUiSignalText(element) + if (!signal) return false + if (this.matchesUiLabel(element, CANCEL_BUTTON_LABELS)) return false + if (anchor && (element === anchor || anchor.contains(element) || element.contains(anchor))) { + return false + } + + const hasModelHint = MODEL_MENU_HINTS.some((keyword) => + signal.includes(this.normalizeUiText(keyword)), + ) + if (!hasModelHint) return false + + if (element.getAttribute("role")?.includes("menuitem")) return true + if (element.getAttribute("role") === "option") return true + if (element.hasAttribute("data-radix-collection-item")) return true + if ( + element.closest( + "[role='menu'], [role='listbox'], [data-radix-popper-content-wrapper], [data-radix-portal]", + ) + ) { + return true + } + + return anchor ? this.getOverlayProximityScore(element, anchor) > -120 : true + } + + private scorePerplexityModelMenuItem( + element: HTMLElement, + anchor: HTMLElement | null = null, + ): number { + const signal = this.getUiSignalText(element) + let score = 0 + + if (element.getAttribute("role")?.includes("menuitem")) score += 80 + if (element.getAttribute("role") === "option") score += 60 + if (element.hasAttribute("data-radix-collection-item")) score += 35 + if (/gpt|claude|gemini|sonar|nemotron|best|最佳/.test(signal)) score += 100 + if (/max|pro|sonnet|opus|super|thinking|reasoning/.test(signal)) score += 15 + score += this.getOverlayProximityScore(element, anchor) + + const rect = element.getBoundingClientRect() + score -= Math.max(0, rect.width - 360) * 0.05 + + return score + } + + private async activatePerplexityModelSelector(button: HTMLElement): Promise { + const expandedBefore = (button.getAttribute("aria-expanded") || "").toLowerCase() + const stateBefore = (button.getAttribute("data-state") || "").toLowerCase() + + button.click() + await this.sleep(120) + + const expandedAfter = (button.getAttribute("aria-expanded") || "").toLowerCase() + const stateAfter = (button.getAttribute("data-state") || "").toLowerCase() + if ( + expandedAfter === "true" || + stateAfter === "open" || + expandedAfter !== expandedBefore || + stateAfter !== stateBefore || + this.findPerplexityModelMenuRoots(button).length > 0 + ) { + return + } + + // Fallback for builds that rely on pointer events rather than click. + this.simulateClick(button) + } + + private activatePerplexityModelMenuItem(item: HTMLElement): void { + item.click() + const checkedAfterClick = (item.getAttribute("aria-checked") || "").toLowerCase() + if (checkedAfterClick === "true") { + return + } + + this.simulateClick(item) + } + + private async togglePerplexityModelSelector(button: HTMLElement): Promise { + const expandedBefore = (button.getAttribute("aria-expanded") || "").toLowerCase() + const stateBefore = (button.getAttribute("data-state") || "").toLowerCase() + const wasOpen = + expandedBefore === "true" || + stateBefore === "open" || + this.findPerplexityModelMenuRoots(button).length > 0 + + button.click() + await this.sleep(110) + + const expandedAfter = (button.getAttribute("aria-expanded") || "").toLowerCase() + const stateAfter = (button.getAttribute("data-state") || "").toLowerCase() + const isOpenNow = + expandedAfter === "true" || + stateAfter === "open" || + this.findPerplexityModelMenuRoots(button).length > 0 + + if (wasOpen !== isOpenNow) { + return + } + + this.simulateClick(button) + } + + private openPerplexityModelSelector(): boolean { + const button = this.findPerplexityModelSelectorButton() + if (!button) return false + if (this.isModelSelectorOpen()) return true + void this.activatePerplexityModelSelector(button) + return true + } + + private async closePerplexityModelSelector(button: HTMLElement | null): Promise { + if (!button) return + + const isOpen = () => { + const expanded = (button.getAttribute("aria-expanded") || "").toLowerCase() + const state = (button.getAttribute("data-state") || "").toLowerCase() + return ( + expanded === "true" || + state === "open" || + this.findPerplexityModelMenuRoots(button).length > 0 + ) + } + + if (!isOpen()) return + + document.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Escape", + code: "Escape", + bubbles: true, + cancelable: true, + }), + ) + await this.sleep(80) + if (!isOpen()) return + + document.body.click() + await this.sleep(80) + if (!isOpen()) return + + button.click() + } + + private getCheckedModelItemTexts(): string[] { + const checkedItems = document.querySelectorAll( + "[role='menuitemradio'][aria-checked='true'], [role='option'][aria-selected='true'], [role='menuitemcheckbox'][aria-checked='true']", + ) + + return Array.from(checkedItems) + .filter( + (item): item is HTMLElement => item instanceof HTMLElement && this.isVisibleElement(item), + ) + .map((item) => this.normalizeUiText(item.textContent || "")) + .filter(Boolean) + } + + private doesCurrentModelMatchTarget(target: string): boolean { + const normalizedTarget = this.normalizeUiText(target) + const editor = this.getTextareaElement() + const scopes = [ + editor?.closest("form"), + editor?.parentElement, + editor?.closest("main"), + document.querySelector("main"), + ].filter(Boolean) as ParentNode[] + + for (const scope of scopes) { + const candidates = scope.querySelectorAll( + "button[aria-haspopup='menu'], [role='button'][aria-haspopup='menu'], [role='combobox']", + ) + for (const candidate of Array.from(candidates)) { + if (!(candidate instanceof HTMLElement)) continue + if (!this.isVisibleElement(candidate) || this.isElementInsideOphel(candidate)) continue + const signal = this.getUiSignalText(candidate) + if ( + !MODEL_SELECTOR_PRIMARY_HINTS.some((hint) => signal.includes(this.normalizeUiText(hint))) + ) { + continue + } + if (signal.includes(normalizedTarget)) { + return true + } + } + } + + return false + } + + private getCurrentPerplexityModelText(): string { + const looseButton = this.findPerplexityModelSelectorButton() + if (looseButton) { + return this.normalizeUiText(super.getModelLockCheckText(looseButton)) + } + + const strictButton = this.findPerplexityModelSelectorButtonStrict() + if (strictButton) { + return this.normalizeUiText( + strictButton.textContent || strictButton.getAttribute("aria-label") || "", + ) + } + + return "" + } + + private getDebugRect(element: HTMLElement): { x: number; y: number; w: number; h: number } { + const rect = element.getBoundingClientRect() + return { + x: Math.round(rect.x), + y: Math.round(rect.y), + w: Math.round(rect.width), + h: Math.round(rect.height), + } + } + + private logPerplexityModelLockDebug(stage: string, payload: Record): void { + if (!this.isPerplexityLockDebugEnabled()) return + try { + console.info("[Perplexity Lock Debug]", stage, payload) + } catch { + // ignore debug log failures + } + } + + private isPerplexityLockDebugEnabled(): boolean { + try { + return localStorage.getItem("ophel:perplexity-lock-debug") === "1" + } catch { + return false + } + } + + private isTargetModelChecked(target: string): boolean { + const normalizedTarget = this.normalizeUiText(target) + const checkedItems = document.querySelectorAll( + "[role='menuitemradio'][aria-checked='true'], [role='option'][aria-selected='true'], [role='menuitemcheckbox'][aria-checked='true']", + ) + + for (const item of Array.from(checkedItems)) { + if (!(item instanceof HTMLElement)) continue + if (!this.isVisibleElement(item)) continue + const text = this.normalizeUiText(item.textContent || "") + if (text.includes(normalizedTarget)) { + return true + } + } + + return false + } + + private async showPerplexityDebugToast(message: string, key: string): Promise { + try { + const { showToastThrottled } = await import("~utils/toast") + showToastThrottled(message, 3000, { maxWidth: 520 }, 1800, key) + } catch { + // ignore debug toast failures + } + } + private async waitForValue(getter: () => T | null, timeoutMs: number): Promise { const start = Date.now() diff --git a/src/components/ConversationsTab.tsx b/src/components/ConversationsTab.tsx index a03f02043..e7b08a6ca 100644 --- a/src/components/ConversationsTab.tsx +++ b/src/components/ConversationsTab.tsx @@ -1041,12 +1041,19 @@ export const ConversationsTab: React.FC = ({ try { const result = await manager.deleteConversations(Array.from(selectedIds)) if (result.localDeletedCount === 0) { - showToast(t("deleteError") || "删除失败") + const firstReason = + result.results.find((item) => item.reason)?.reason || undefined + showToast( + `${t("deleteError") || "删除失败"}${firstReason ? `: ${firstReason}` : ""}`, + ) return } if (result.remoteAttemptedCount > 0 && result.remoteFailedCount > 0) { + const firstReason = + result.results.find((item) => !item.remoteSuccess && item.reason) + ?.reason || undefined showToast( - `已删除 ${result.localDeletedCount} 个,本地成功,云端失败 ${result.remoteFailedCount} 个`, + `已删除 ${result.localDeletedCount} 个,本地成功,云端失败 ${result.remoteFailedCount} 个${firstReason ? `: ${firstReason}` : ""}`, ) } clearSelection() @@ -1241,11 +1248,15 @@ export const ConversationsTab: React.FC = ({ try { const result = await manager.deleteConversation(menu.conv.id) if (!result.localDeleted) { - showToast(t("deleteError") || "删除失败") + showToast( + `${t("deleteError") || "删除失败"}${result.reason ? `: ${result.reason}` : ""}`, + ) return } if (result.remoteAttempted && !result.remoteSuccess) { - showToast("已从面板删除,但云端删除失败") + showToast( + `已从面板删除,但云端删除失败${result.reason ? `: ${result.reason}` : ""}`, + ) } await loadData() } finally { diff --git a/src/core/assistant-mermaid-renderer.ts b/src/core/assistant-mermaid-renderer.ts index ff54f1728..1a0525ef2 100644 --- a/src/core/assistant-mermaid-renderer.ts +++ b/src/core/assistant-mermaid-renderer.ts @@ -1,7 +1,7 @@ import { normalizeAssistantMermaidSource, type SiteAdapter } from "~adapters/base" import { DOMToolkit } from "~utils/dom-toolkit" import { t } from "~utils/i18n" -import { showToast } from "~utils/toast" +import { showToast, showToastThrottled } from "~utils/toast" import { setSafeScriptSrc } from "~utils/trusted-types" const STYLE_ID = "gh-assistant-mermaid-style" @@ -492,7 +492,25 @@ export class AssistantMermaidRenderer { } private getAssistantSelector(): string | null { - return this.adapter.getExportConfig()?.assistantResponseSelector || null + const exportSelector = this.adapter.getExportConfig()?.assistantResponseSelector || null + if (exportSelector && !exportSelector.includes("data-gh-")) { + return exportSelector + } + + const chatSelectors = this.adapter.getChatContentSelectors?.() || [] + const userQuerySelector = this.adapter.getUserQuerySelector?.() + const assistantSelectors = chatSelectors.filter( + (selector) => Boolean(selector) && selector !== userQuerySelector, + ) + if (assistantSelectors.length > 0) { + return assistantSelectors[assistantSelectors.length - 1] || exportSelector + } + + if (chatSelectors.length > 0) { + return chatSelectors[chatSelectors.length - 1] || exportSelector + } + + return exportSelector } private initClickHandler() { @@ -720,6 +738,17 @@ export class AssistantMermaidRenderer { } console.warn("[AssistantMermaidRenderer] Mermaid render skipped:", error) + if (this.adapter.getSiteId() === "perplexity") { + const message = + error instanceof Error ? error.message : typeof error === "string" ? error : "unknown" + showToastThrottled( + `[Perplexity Debug] Mermaid render skipped: ${message}`, + 2600, + { maxWidth: 520 }, + 2000, + "perplexity-mermaid-skip", + ) + } if (shouldRetryMermaidRender(error)) { this.processedBlocks.delete(block) this.cleanupPanel(block) diff --git a/src/core/conversation/manager.ts b/src/core/conversation/manager.ts index d9b4183fa..688fcdaad 100644 --- a/src/core/conversation/manager.ts +++ b/src/core/conversation/manager.ts @@ -3,6 +3,7 @@ import type { ConversationDeleteTarget, ConversationInfo, ConversationObserverConfig, + ConversationRenameTarget, ExportLifecycleContext, SiteDeleteConversationResult, } from "~adapters/base" @@ -60,6 +61,7 @@ export class ConversationManager { private observerContainer: Node | null = null private titleWatcher: any = null // DOMToolkit watcher instance private pollInterval: NodeJS.Timeout | null = null + private currentConversationSyncTimer: NodeJS.Timeout | null = null private geminiMigrationTimer: NodeJS.Timeout | null = null private geminiMigrationRetryCount = 0 @@ -137,6 +139,7 @@ export class ConversationManager { } this.startSidebarObserver() + this.startCurrentConversationSync() } // Gemini 老数据迁移:数字 cid(0/1/2...) -> 当前邮箱 cid @@ -317,6 +320,7 @@ export class ConversationManager { destroy() { this.stopGeminiMigrationRetry() this.stopSidebarObserver() + this.stopCurrentConversationSync() } updateSettings(settings: { syncUnpin: boolean; syncDelete?: boolean }) { @@ -371,7 +375,7 @@ export class ConversationManager { startObserverRetry() - if (config.shadow) { + if (config.shadow || config.enablePolling) { this.startPolling() } } @@ -470,6 +474,7 @@ export class ConversationManager { private startPolling() { if (this.pollInterval) return + const intervalMs = this.observerConfig?.pollIntervalMs || 3000 this.pollInterval = setInterval(() => { if (!this.observerConfig) return const config = this.observerConfig @@ -500,7 +505,7 @@ export class ConversationManager { } }) } - }, 3000) + }, intervalMs) } private stopPolling() { @@ -510,6 +515,74 @@ export class ConversationManager { } } + private startCurrentConversationSync() { + if (this.currentConversationSyncTimer) return + + const syncCurrent = () => { + const info = this.siteAdapter.getCurrentConversationInfo() + if (!info?.id) return + + const existing = this.conversations[info.id] + if (!existing && !info.title?.trim()) { + return + } + if (!existing) { + getConversationsStore().addConversation({ + id: info.id, + siteId: this.siteAdapter.getSiteId(), + cid: info.cid, + title: info.title || t("untitledConversation"), + url: info.url || window.location.href, + folderId: this.lastUsedFolderId || "inbox", + pinned: info.isPinned || false, + createdAt: Date.now(), + updatedAt: Date.now(), + }) + this.notifyDataChange() + return + } + + const updates: Partial = {} + let needsUpdate = false + + if (this.shouldUpdateConversationTitle(existing.title, info.title, existing.siteId)) { + updates.title = info.title + needsUpdate = true + } + if (info.url && info.url !== existing.url) { + updates.url = info.url + needsUpdate = true + } + if (info.cid !== undefined && info.cid !== existing.cid) { + updates.cid = info.cid + needsUpdate = true + } + if (info.isPinned !== undefined && info.isPinned !== existing.pinned) { + if (info.isPinned) { + updates.pinned = true + needsUpdate = true + } else if (this.syncUnpin) { + updates.pinned = false + needsUpdate = true + } + } + + if (needsUpdate) { + getConversationsStore().updateConversation(info.id, updates) + this.notifyDataChange() + } + } + + syncCurrent() + this.currentConversationSyncTimer = setInterval(syncCurrent, 1500) + } + + private stopCurrentConversationSync() { + if (!this.currentConversationSyncTimer) return + clearInterval(this.currentConversationSyncTimer) + this.currentConversationSyncTimer = null + } + private monitorConversationTitle(el: HTMLElement, id: string) { if (el.dataset.ghTitleObserver) return el.dataset.ghTitleObserver = "true" @@ -760,9 +833,24 @@ export class ConversationManager { return getConversationsStore().togglePin(convId) } - renameConversation(convId: string, newTitle: string) { - if (newTitle) { - getConversationsStore().updateConversation(convId, { title: newTitle }) + async renameConversation(convId: string, newTitle: string) { + const normalizedTitle = newTitle.trim() + if (!normalizedTitle) return + + const conv = this.conversations[convId] + if (conv) { + getConversationsStore().updateConversation(convId, { title: normalizedTitle }) + this.notifyDataChange() + } + + const target: ConversationRenameTarget = { + id: convId, + title: conv?.title, + url: conv?.url, + } + const result = await this.siteAdapter.renameConversationOnSite(target, normalizedTitle) + if (!result.success && result.method !== "none") { + showToast(`已在面板重命名,但云端重命名失败${result.reason ? `: ${result.reason}` : ""}`) } } diff --git a/src/core/model-locker.ts b/src/core/model-locker.ts index 5db9f3fa3..d1a3fb01d 100644 --- a/src/core/model-locker.ts +++ b/src/core/model-locker.ts @@ -18,8 +18,17 @@ export class ModelLocker { private adapter: SiteAdapter private config: ModelLockSiteConfig private isLocked = false + private isLocking = false + private persistentMonitorTimer: ReturnType | null = null + private persistentMutationObserver: MutationObserver | null = null + private persistentMutationDebounceTimer: ReturnType | null = null private verifyTimer: ReturnType | null = null private configDebounceTimer: ReturnType | null = null + private startTimers: ReturnType[] = [] + private lockTimeoutTimer: ReturnType | null = null + private relockWatchTimer: ReturnType | null = null + private relockWatchStopTimer: ReturnType | null = null + private relockSequenceId = 0 constructor(adapter: SiteAdapter, config: ModelLockSiteConfig) { this.adapter = adapter @@ -50,29 +59,155 @@ export class ModelLocker { start(delay = 1500) { if (!this.config.enabled || !this.config.keyword) return + if (this.adapter.usesPersistentModelLockMonitor()) { + this.ensurePersistentMonitor() + this.scheduleCheck(delay) + return + } if (this.isLocked) return // 延迟后开始锁定(初始化时需要延迟等待页面加载,手动触发时可直接执行) - setTimeout(() => { + const timer = setTimeout(() => { + this.startTimers = this.startTimers.filter((item) => item !== timer) if (this.isLocked) return // 再次检查,避免重复锁定 - - this.adapter.lockModel(this.config.keyword, () => { - // 锁定成功后,启动持续监控(防止页面初始化后又改回默认值) - this.startVerification() - }) + if (this.isLocking) { + this.start(260) + return + } + this.runLockAttempt(this.relockSequenceId) }, delay) + this.startTimers.push(timer) } /** * 路由切换后重新锁定 */ - relock(delay = 300) { + relock(delay = 80) { if (!this.config.enabled || !this.config.keyword) return + if (this.adapter.usesPersistentModelLockMonitor()) { + this.stop() + this.isLocked = false + this.relockSequenceId += 1 + this.ensurePersistentMonitor() + this.scheduleCheck(delay) + return + } + // 清理旧的定时器与状态,避免旧页面残留影响新页面 this.stop() this.isLocked = false - this.start(delay) + this.relockSequenceId += 1 + + // 路由切换后页面的模型标签经常会延迟刷新,分多次快速尝试能更早收敛。 + ;[delay, delay + 250, delay + 700, delay + 1400, delay + 2400].forEach((attemptDelay) => { + this.start(attemptDelay) + }) + this.startRelockWatch(this.relockSequenceId, delay + 120) + } + + private runLockAttempt(sequenceId: number) { + if (sequenceId !== this.relockSequenceId) return + if (!this.config.enabled || !this.config.keyword) return + if (this.isLocked || this.isLocking) return + if (!this.adapter.isModelLockUiReady()) return + if (this.adapter.isModelSelectorOpen()) return + if (this.isCurrentModelTarget()) { + this.isLocked = true + return + } + + this.isLocking = true + if (this.lockTimeoutTimer) { + clearTimeout(this.lockTimeoutTimer) + this.lockTimeoutTimer = null + } + + this.lockTimeoutTimer = setTimeout(() => { + this.isLocking = false + this.lockTimeoutTimer = null + if (!this.isLocked && sequenceId === this.relockSequenceId) { + this.start(320) + } + }, 1800) + + this.adapter.lockModel(this.config.keyword, () => { + if (sequenceId !== this.relockSequenceId) return + this.isLocking = false + if (this.lockTimeoutTimer) { + clearTimeout(this.lockTimeoutTimer) + this.lockTimeoutTimer = null + } + // 先立即标记为已锁定,避免在验证窗口内又被持续监测反复触发。 + this.isLocked = true + // 锁定成功后,启动持续监控(防止页面初始化后又改回默认值) + this.startVerification() + }) + } + + private ensurePersistentMonitor() { + if (this.persistentMonitorTimer && this.persistentMutationObserver) return + + const intervalMs = this.adapter.getModelLockMonitorInterval() + if (!this.persistentMonitorTimer) { + this.persistentMonitorTimer = setInterval(() => { + this.evaluatePersistentMonitor(this.relockSequenceId) + }, intervalMs) + } + + if (!this.persistentMutationObserver && typeof MutationObserver !== "undefined") { + const root = this.adapter.getModelLockMonitorRoot() + if (root) { + this.persistentMutationObserver = new MutationObserver(() => { + if (this.persistentMutationDebounceTimer) { + clearTimeout(this.persistentMutationDebounceTimer) + } + this.persistentMutationDebounceTimer = setTimeout(() => { + this.persistentMutationDebounceTimer = null + this.evaluatePersistentMonitor(this.relockSequenceId) + }, this.adapter.getModelLockMutationDebounce()) + }) + + this.persistentMutationObserver.observe(root, { + childList: true, + subtree: true, + characterData: true, + attributes: true, + attributeFilter: [ + "aria-label", + "aria-expanded", + "aria-checked", + "aria-selected", + "data-state", + "class", + "style", + ], + }) + } + } + } + + private scheduleCheck(delay: number) { + const timer = setTimeout(() => { + this.startTimers = this.startTimers.filter((item) => item !== timer) + this.evaluatePersistentMonitor(this.relockSequenceId) + }, delay) + this.startTimers.push(timer) + } + + private evaluatePersistentMonitor(sequenceId: number) { + if (sequenceId !== this.relockSequenceId) return + if (!this.config.enabled || !this.config.keyword) return + if (this.isLocking) return + if (!this.adapter.isModelLockUiReady()) return + if (this.adapter.isModelSelectorOpen()) return + if (this.isCurrentModelTarget()) { + this.isLocked = true + return + } + + this.isLocked = false + this.runLockAttempt(sequenceId) } /** @@ -87,12 +222,26 @@ export class ModelLocker { let verifyAttempts = 0 let consecutiveSuccess = 0 // 连续成功计数 - const maxVerifyAttempts = 3 - const verifyInterval = 1500 + const maxVerifyAttempts = 5 + const verifyInterval = 600 this.verifyTimer = setInterval(() => { verifyAttempts++ + if (!this.adapter.isModelLockUiReady()) { + if (verifyAttempts >= maxVerifyAttempts) { + this.finishVerification() + } + return + } + + if (this.adapter.isModelSelectorOpen()) { + if (verifyAttempts >= maxVerifyAttempts) { + this.finishVerification() + } + return + } + // 检查当前模型是否仍然是目标模型 const config = this.adapter.getModelSwitcherConfig(this.config.keyword) if (!config) { @@ -119,6 +268,7 @@ export class ModelLocker { if (currentText.includes(target)) { // 当前是目标模型 + this.isLocked = true consecutiveSuccess++ // 连续 2 次成功,认为已稳定,提前结束 if (consecutiveSuccess >= 2 || verifyAttempts >= maxVerifyAttempts) { @@ -128,12 +278,10 @@ export class ModelLocker { // 模型被改回去了 consecutiveSuccess = 0 // 只在前 2 次尝试时重新锁定,避免长时间干扰用户 - if (verifyAttempts <= 2) { + if (verifyAttempts <= 2 && !this.isLocking) { this.finishVerification() - // 重新调用 lockModel - this.adapter.lockModel(this.config.keyword, () => { - this.startVerification() - }) + this.isLocked = false + this.runLockAttempt(this.relockSequenceId) } else { // 超过 2 次还被改,可能是用户手动修改,放弃 this.finishVerification() @@ -150,17 +298,112 @@ export class ModelLocker { } } + private startRelockWatch(sequenceId: number, initialDelay = 0) { + if (this.relockWatchTimer) { + clearInterval(this.relockWatchTimer) + this.relockWatchTimer = null + } + if (this.relockWatchStopTimer) { + clearTimeout(this.relockWatchStopTimer) + this.relockWatchStopTimer = null + } + + const startWatcher = () => { + this.relockWatchTimer = setInterval(() => { + if (sequenceId !== this.relockSequenceId) { + this.stopRelockWatch() + return + } + if (!this.config.enabled || !this.config.keyword) { + this.stopRelockWatch() + return + } + if (this.isCurrentModelTarget()) { + this.isLocked = true + this.stopRelockWatch() + return + } + if (!this.isLocking) { + this.runLockAttempt(sequenceId) + } + }, 250) + } + + if (initialDelay > 0) { + const starter = setTimeout(() => { + this.startTimers = this.startTimers.filter((item) => item !== starter) + if (sequenceId !== this.relockSequenceId) return + startWatcher() + }, initialDelay) + this.startTimers.push(starter) + } else { + startWatcher() + } + + this.relockWatchStopTimer = setTimeout(() => { + this.stopRelockWatch() + }, 10000) + } + + private stopRelockWatch() { + if (this.relockWatchTimer) { + clearInterval(this.relockWatchTimer) + this.relockWatchTimer = null + } + if (this.relockWatchStopTimer) { + clearTimeout(this.relockWatchStopTimer) + this.relockWatchStopTimer = null + } + } + stop() { // 停止防抖定时器 if (this.configDebounceTimer) { clearTimeout(this.configDebounceTimer) this.configDebounceTimer = null } + if (this.startTimers.length > 0) { + this.startTimers.forEach((timer) => clearTimeout(timer)) + this.startTimers = [] + } + this.stopRelockWatch() + if (this.persistentMonitorTimer) { + clearInterval(this.persistentMonitorTimer) + this.persistentMonitorTimer = null + } + if (this.persistentMutationObserver) { + this.persistentMutationObserver.disconnect() + this.persistentMutationObserver = null + } + if (this.persistentMutationDebounceTimer) { + clearTimeout(this.persistentMutationDebounceTimer) + this.persistentMutationDebounceTimer = null + } + if (this.lockTimeoutTimer) { + clearTimeout(this.lockTimeoutTimer) + this.lockTimeoutTimer = null + } // 停止验证定时器 if (this.verifyTimer) { clearInterval(this.verifyTimer) this.verifyTimer = null } + this.isLocking = false this.isLocked = true } + + private isCurrentModelTarget(): boolean { + if (!this.adapter.isModelLockUiReady()) return false + if (this.adapter.isModelSelectorOpen()) return false + + const config = this.adapter.getModelSwitcherConfig(this.config.keyword) + if (!config) return false + + const selectorBtn = this.adapter.findElementBySelectors(config.selectorButtonSelectors) + if (!selectorBtn) return false + + const currentText = this.adapter.getModelLockCheckText(selectorBtn).toLowerCase().trim() + const target = config.targetModelKeyword.toLowerCase().trim() + return Boolean(currentText) && currentText.includes(target) + } } diff --git a/src/core/modules-init.ts b/src/core/modules-init.ts index 04d04710b..04503b547 100644 --- a/src/core/modules-init.ts +++ b/src/core/modules-init.ts @@ -637,7 +637,7 @@ export function initUrlChangeObserver(ctx: ModulesContext): void { const currentPathname = window.location.pathname if (currentPathname !== lastPathname) { lastPathname = currentPathname - console.warn("[Ophel] URL changed, reinitializing modules...") + console.info("[Ophel] URL changed, reinitializing modules...") // 1. 阅读历史:停止录制 → 延迟恢复并重启 if (readingHistoryRestoreTimeoutId) { @@ -681,7 +681,7 @@ export function initUrlChangeObserver(ctx: ModulesContext): void { modules.usageCounterManager?.handleUrlChange() // 6. 模型锁定重新触发(新对话/新页面可能重置模型) - modules.modelLocker?.relock(300) + modules.modelLocker?.relock(80) } } diff --git a/src/stores/chrome-adapter.ts b/src/stores/chrome-adapter.ts index 75d3fd4f4..e2c05aa51 100644 --- a/src/stores/chrome-adapter.ts +++ b/src/stores/chrome-adapter.ts @@ -89,6 +89,11 @@ const userscriptStorageAdapter: StateStorage = { const extensionStorageAdapter: StateStorage = { getItem: async (name: string): Promise => { return new Promise((resolve) => { + if (typeof chrome === "undefined" || !chrome.storage?.local) { + resolve(null) + return + } + chrome.storage.local.get(name, (result) => { const value = result[name] if (value === undefined) { @@ -109,6 +114,11 @@ const extensionStorageAdapter: StateStorage = { setItem: async (name: string, value: string): Promise => { return new Promise((resolve) => { + if (typeof chrome === "undefined" || !chrome.storage?.local) { + resolve() + return + } + // 存储 JSON 字符串 chrome.storage.local.set({ [name]: value }, () => { resolve() @@ -118,6 +128,11 @@ const extensionStorageAdapter: StateStorage = { removeItem: async (name: string): Promise => { return new Promise((resolve) => { + if (typeof chrome === "undefined" || !chrome.storage?.local) { + resolve() + return + } + chrome.storage.local.remove(name, () => { resolve() }) From f400d7cac0ea6060d31c5875d6a9b01e442c6c19 Mon Sep 17 00:00:00 2001 From: KanameMadoka520 <2441883200@qq.com> Date: Sat, 25 Apr 2026 01:22:27 +0800 Subject: [PATCH 16/27] fix: sync perplexity inbox actions with native threads Co-authored-by: LainElaina <1796137367@qq.com> --- src/adapters/perplexity.ts | 579 +++++++++++++++++++++++++++---- src/components/App.tsx | 20 ++ src/core/conversation/manager.ts | 231 +++++++++--- 3 files changed, 711 insertions(+), 119 deletions(-) diff --git a/src/adapters/perplexity.ts b/src/adapters/perplexity.ts index 14234a450..296280609 100644 --- a/src/adapters/perplexity.ts +++ b/src/adapters/perplexity.ts @@ -185,7 +185,17 @@ const EXPORT_ASSISTANT_SELECTOR = `[${EXPORT_ROLE_ATTR}="assistant"]` interface PerplexityThreadListEntry { slug?: unknown + thread_url_slug?: unknown + link?: unknown title?: unknown + uuid?: unknown + context_uuid?: unknown + id?: unknown +} + +interface PerplexityThreadIdentifiers { + slug: string + apiIds: string[] } function getPerplexityOrigin(): string { @@ -256,13 +266,8 @@ export class PerplexityAdapter extends SiteAdapter { getConversationTitle(): string | null { const sessionId = this.getSessionId() if (sessionId) { - const sidebarLink = this.findSidebarConversationLink(sessionId) - if (sidebarLink) { - const sidebarTitle = this.extractConversationTitle(sidebarLink, sessionId) - if (sidebarTitle) { - return sidebarTitle - } - } + const sidebarTitle = this.getSidebarConversationTitle(sessionId) + if (sidebarTitle) return sidebarTitle } const titleElement = document.querySelector(THREAD_TITLE_SELECTOR) @@ -282,7 +287,7 @@ export class PerplexityAdapter extends SiteAdapter { return { id, - title: this.getConversationTitle() || "", + title: this.getConversationTitle() || id, url: window.location.href, cid: this.getCurrentCid() || undefined, } @@ -493,20 +498,57 @@ export class PerplexityAdapter extends SiteAdapter { enablePolling: true, pollIntervalMs: 1500, extractInfo: (element) => this.extractConversationInfo(element), - getTitleElement: (element) => element.querySelector("span, div[dir='auto']") || element, + getTitleElement: (element) => this.findConversationItemContainer(element) || element, } } + async deleteConversationsOnSite( + targets: ConversationDeleteTarget[], + ): Promise { + const results: SiteDeleteConversationResult[] = [] + const deletedSlugs = new Set() + + for (const target of targets) { + const slug = + this.parseThreadSlugFromUrl(target.url || "") || + this.parseThreadSlugFromUrl(target.id) || + target.id + const result = await this.deleteConversationOnSite(target) + results.push(result) + + if (result.success && slug) { + deletedSlugs.add(slug) + } + } + + this.navigateAwayIfCurrentThreadWasDeleted(deletedSlugs) + return results + } + async deleteConversationOnSite( target: ConversationDeleteTarget, ): Promise { - const result = await this.deleteConversationViaUi(target.id) + const identifiers = await this.resolveThreadIdentifiers(target.id, target.url) + const apiResult = await this.deleteConversationViaNativeApi(target.id, identifiers) + if (apiResult.success) { + this.syncConversationListAfterDelete(identifiers.slug) + if (identifiers.slug !== target.id) { + this.syncConversationListAfterDelete(target.id) + } + return { + id: target.id, + success: true, + method: "api", + } + } + + const result = await this.deleteConversationViaUi(identifiers.slug) return { id: target.id, success: result.success, - method: result.success ? "ui" : "none", - reason: result.success ? undefined : result.reason, + method: result.success ? "ui" : apiResult.method, + reason: result.success ? undefined : result.reason || apiResult.reason, } } @@ -514,11 +556,24 @@ export class PerplexityAdapter extends SiteAdapter { target: { id: string; title?: string; url?: string }, newTitle: string, ): Promise<{ success: boolean; method: "api" | "ui" | "none"; reason?: string }> { - const result = await this.renameConversationViaUi(target.id, newTitle) + const identifiers = await this.resolveThreadIdentifiers(target.id, target.url) + const apiResult = await this.renameConversationViaNativeApi(identifiers, newTitle) + if (apiResult.success) { + this.syncConversationListAfterRename(identifiers.slug, newTitle) + if (identifiers.slug !== target.id) { + this.syncConversationListAfterRename(target.id, newTitle) + } + return { + success: true, + method: "api", + } + } + + const result = await this.renameConversationViaUi(identifiers.slug, newTitle) return { success: result.success, method: "ui", - reason: result.success ? undefined : result.reason, + reason: result.success ? undefined : result.reason || apiResult.reason, } } @@ -1159,12 +1214,208 @@ export class PerplexityAdapter extends SiteAdapter { } } + private async resolveThreadIdentifiers( + id: string, + targetUrl?: string, + ): Promise { + const slug = + this.parseThreadSlugFromUrl(targetUrl || "") || this.parseThreadSlugFromUrl(id) || id.trim() + + const apiIds = new Set() + this.addThreadApiIdCandidate(apiIds, slug) + this.addThreadApiIdCandidate(apiIds, id) + + const metadata = await this.fetchThreadMetadata(slug) + if (metadata) { + this.collectThreadApiIdsFromPayload(metadata).forEach((candidate) => + this.addThreadApiIdCandidate(apiIds, candidate), + ) + } + + return { + slug, + apiIds: Array.from(apiIds), + } + } + + private async deleteConversationViaNativeApi( + resultId: string, + identifiers: PerplexityThreadIdentifiers, + ): Promise { + let lastReason = "delete_api_not_attempted" + + for (const apiId of identifiers.apiIds) { + const attempts: Array<{ endpoint: string; body: Record }> = [ + { + endpoint: "/rest/thread", + body: { + entry_uuids: [apiId], + read_write_token: "", + }, + }, + { + endpoint: "/rest/thread/delete_thread_by_entry_uuid", + body: { + entry_uuid: apiId, + read_write_token: "", + }, + }, + ] + + for (const attempt of attempts) { + try { + const response = await fetch(buildPerplexityUrl(attempt.endpoint), { + method: "DELETE", + credentials: "include", + headers: this.buildPerplexityJsonHeaders(), + body: JSON.stringify(attempt.body), + }) + + if (response.ok) { + return { id: resultId, success: true, method: "api" } + } + + lastReason = this.toPerplexityApiHttpReason("delete", response.status) + } catch { + lastReason = "delete_api_request_failed" + } + } + } + + return { + id: resultId, + success: false, + method: "api", + reason: lastReason, + } + } + + private async renameConversationViaNativeApi( + identifiers: PerplexityThreadIdentifiers, + newTitle: string, + ): Promise<{ success: boolean; reason?: string }> { + const normalizedTitle = newTitle.trim() + if (!normalizedTitle) { + return { success: false, reason: "empty_title" } + } + + let lastReason = "rename_api_not_attempted" + for (const apiId of identifiers.apiIds) { + try { + const response = await fetch(buildPerplexityUrl("/rest/thread/set_thread_title"), { + method: "POST", + credentials: "include", + headers: this.buildPerplexityJsonHeaders(), + body: JSON.stringify({ + context_uuid: apiId, + title: normalizedTitle, + read_write_token: "", + }), + }) + + if (response.ok) { + return { success: true } + } + + lastReason = this.toPerplexityApiHttpReason("rename", response.status) + } catch { + lastReason = "rename_api_request_failed" + } + } + + return { success: false, reason: lastReason } + } + + private buildPerplexityJsonHeaders(): Record { + return { + Accept: "application/json", + "Content-Type": "application/json", + } + } + + private toPerplexityApiHttpReason(action: "delete" | "rename", status: number): string { + switch (status) { + case 401: + case 403: + return `${action}_api_unauthorized` + case 404: + return `${action}_api_not_found` + case 429: + return `${action}_api_rate_limited` + default: + return `${action}_api_http_${status || 0}` + } + } + + private async fetchThreadMetadata(identifier: string): Promise { + if (!identifier) return null + + try { + const url = new URL(`/rest/thread/${encodeURIComponent(identifier)}`, getPerplexityOrigin()) + url.searchParams.set("with_parent_info", "false") + url.searchParams.set("with_schematized_response", "true") + url.searchParams.set("version", "2.18") + url.searchParams.set("source", "default") + url.searchParams.set("limit", "0") + url.searchParams.set("offset", "0") + url.searchParams.set("from_first", "false") + + const response = await fetch(url.toString(), { + method: "GET", + credentials: "include", + headers: { + Accept: "application/json", + }, + }) + if (!response.ok) return null + return response.json() + } catch { + return null + } + } + + private collectThreadApiIdsFromPayload(payload: unknown): string[] { + const candidates: string[] = [] + const seen = new Set() + const keyPattern = + /^(context_uuid|contextuuid|uuid|entry_uuid|entryuuid|backend_uuid|backenduuid|id)$/i + + const walk = (value: unknown, depth: number): void => { + if (!value || depth > 5 || seen.has(value)) return + if (typeof value !== "object") return + seen.add(value) + + if (Array.isArray(value)) { + value.slice(0, 20).forEach((item) => walk(item, depth + 1)) + return + } + + Object.entries(value as Record).forEach(([key, child]) => { + if (typeof child === "string" && keyPattern.test(key)) { + candidates.push(child) + } + walk(child, depth + 1) + }) + } + + walk(payload, 0) + return candidates + } + + private addThreadApiIdCandidate(candidates: Set, value: string | null | undefined): void { + const normalized = (value || "").trim() + if (!normalized || normalized === "new") return + if (/[\s/?#]/.test(normalized)) return + candidates.add(normalized) + } + private async deleteConversationViaUi( id: string, ): Promise<{ success: boolean; reason?: string }> { let sidebarReason: string | undefined - const sidebarLink = await this.waitForValue(() => this.findSidebarConversationLink(id), 600) + const sidebarLink = await this.findSidebarConversationLinkWithScroll(id, 1_200) if (sidebarLink) { + this.ensureElementVisible(sidebarLink) const container = this.findConversationItemContainer(sidebarLink) const actionButton = await this.openThreadActionMenu({ preferredContainer: container, @@ -1184,20 +1435,11 @@ export class PerplexityAdapter extends SiteAdapter { } } - if (this.getSessionId() !== id && !this.navigateToConversation(id)) { - return { success: false, reason: sidebarReason || "navigate_failed" } - } - - const ready = await this.waitForCondition( - () => this.getSessionId() === id, - DELETE_FLOW_TIMEOUT_MS, - ) - if (!ready) { - void this.showPerplexityDebugToast( - `[Perplexity Debug] delete target did not become active: ${id}`, - "perplexity-delete-session-not-ready", - ) - return { success: false, reason: sidebarReason || "session_not_ready" } + if (this.getSessionId() !== id) { + return { + success: false, + reason: sidebarReason || "target_not_visible_and_api_failed", + } } const actionButton = await this.openThreadActionMenu({ @@ -1232,15 +1474,57 @@ export class PerplexityAdapter extends SiteAdapter { return { success: false, reason: "empty_title" } } - const sidebarLink = await this.waitForValue(() => this.findSidebarConversationLink(id), 1_200) - if (!sidebarLink) { + const sidebarResult = await this.tryRenameConversationFromSidebar(id, normalizedTitle) + if (sidebarResult.success) { + this.syncConversationListAfterRename(id, normalizedTitle) + return { success: true } + } + + if (this.getSessionId() !== id) { + return { + success: false, + reason: sidebarResult.reason || "target_not_visible_and_api_failed", + } + } + + const actionButton = await this.openThreadActionMenu({ + preferredContainer: this.getConversationHeaderContainer(), + triggerScope: document.body, + menuOpenedCheck: (button) => this.findOpenRenameMenuItem(button) !== null, + }) + if (!actionButton) { void this.showPerplexityDebugToast( - `[Perplexity Debug] rename target not found in sidebar: ${id}`, - "perplexity-rename-sidebar-link", + `[Perplexity Debug] rename action button not found on active conversation: ${id}`, + "perplexity-rename-active-action-button", ) + return { + success: false, + reason: sidebarResult.reason || "rename_action_button_not_found", + } + } + + const renameResult = await this.completeRenameFromOpenMenu(id, normalizedTitle, actionButton) + if (renameResult.success) { + this.syncConversationListAfterRename(id, normalizedTitle) + return { success: true } + } + + return { + success: false, + reason: renameResult.reason || sidebarResult.reason || "rename_not_observed", + } + } + + private async tryRenameConversationFromSidebar( + id: string, + normalizedTitle: string, + ): Promise<{ success: boolean; reason?: string }> { + const sidebarLink = await this.findSidebarConversationLinkWithScroll(id, 2_500) + if (!sidebarLink) { return { success: false, reason: "sidebar_link_not_found" } } + this.ensureElementVisible(sidebarLink) const container = this.findConversationItemContainer(sidebarLink) const actionButton = await this.openThreadActionMenu({ preferredContainer: container, @@ -1255,6 +1539,14 @@ export class PerplexityAdapter extends SiteAdapter { return { success: false, reason: "rename_action_button_not_found" } } + return this.completeRenameFromOpenMenu(id, normalizedTitle, actionButton) + } + + private async completeRenameFromOpenMenu( + id: string, + normalizedTitle: string, + actionButton: HTMLElement, + ): Promise<{ success: boolean; reason?: string }> { const renameItem = await this.waitForValue( () => this.findOpenRenameMenuItem(actionButton), DELETE_FLOW_TIMEOUT_MS, @@ -1300,15 +1592,10 @@ export class PerplexityAdapter extends SiteAdapter { this.simulateClick(saveButton) - const renamed = await this.waitForCondition(() => { - const link = this.findSidebarConversationLink(id) - if (!link) return false - const title = this.extractConversationTitle(link, id) - return ( - this.normalizeConversationTitle(title, id) === - this.normalizeConversationTitle(normalizedTitle, id) - ) - }, 3_000) + const renamed = await this.waitForCondition( + () => this.hasConversationTitleSignal(id, normalizedTitle), + 3_000, + ) if (!renamed) { void this.showPerplexityDebugToast( @@ -1318,7 +1605,6 @@ export class PerplexityAdapter extends SiteAdapter { return { success: false, reason: "rename_not_observed" } } - this.syncConversationListAfterRename(id, normalizedTitle) return { success: true } } @@ -1337,7 +1623,7 @@ export class PerplexityAdapter extends SiteAdapter { private syncConversationListAfterDelete(id: string): void { this.threadListCache = this.threadListCache.filter((item) => item.id !== id) - this.threadListCacheExpiresAt = Math.min(this.threadListCacheExpiresAt, Date.now() + 10_000) + this.threadListCacheExpiresAt = 0 this.getNativeSidebarConversationLinks().forEach((anchor) => { if (this.parseThreadSlugFromUrl(anchor.getAttribute("href") || anchor.href || "") !== id) @@ -1348,11 +1634,27 @@ export class PerplexityAdapter extends SiteAdapter { }) } + private navigateAwayIfCurrentThreadWasDeleted(deletedSlugs: Set): void { + if (deletedSlugs.size === 0) return + + window.setTimeout(() => { + const currentSlug = this.getSessionId() + if (!currentSlug || !deletedSlugs.has(currentSlug)) return + + window.location.assign(buildPerplexityUrl("/")) + }, 150) + } + private syncConversationListAfterRename(id: string, title: string): void { this.threadListCache = this.threadListCache.map((item) => item.id === id ? { ...item, title } : item, ) this.threadListCacheExpiresAt = Math.min(this.threadListCacheExpiresAt, Date.now() + 10_000) + + const link = this.findSidebarConversationLink(id) + if (link) { + this.applyConversationTitleToDom(link, id, title) + } } private parseThreadSlugFromUrl(url: string): string { @@ -1408,6 +1710,52 @@ export class PerplexityAdapter extends SiteAdapter { return null } + private getSidebarConversationTitle(id: string): string | null { + const sidebarLink = this.findSidebarConversationLink(id) + if (!sidebarLink) return null + + const sidebarTitle = this.extractConversationTitle(sidebarLink, id) + return sidebarTitle || null + } + + private async findSidebarConversationLinkWithScroll( + id: string, + timeoutMs: number, + ): Promise { + const immediate = this.findSidebarConversationLink(id) + if (immediate) return immediate + + const scrollRoot = this.getSidebarScrollContainer() + if (!(scrollRoot instanceof HTMLElement)) { + return this.waitForValue(() => this.findSidebarConversationLink(id), timeoutMs) + } + + const startedAt = Date.now() + const originalTop = scrollRoot.scrollTop + let nextRatio = 0 + + while (Date.now() - startedAt < timeoutMs) { + const found = this.findSidebarConversationLink(id) + if (found) { + this.ensureElementVisible(found) + return found + } + + const maxTop = Math.max(0, scrollRoot.scrollHeight - scrollRoot.clientHeight) + const nextTop = Math.round(maxTop * nextRatio) + scrollRoot.scrollTop = nextTop + scrollRoot.dispatchEvent(new Event("scroll", { bubbles: true })) + nextRatio += 0.2 + if (nextRatio > 1) nextRatio = 0 + + await this.sleep(100) + } + + scrollRoot.scrollTop = originalTop + scrollRoot.dispatchEvent(new Event("scroll", { bubbles: true })) + return this.findSidebarConversationLink(id) + } + private findPerplexityModelSelectorButton(): HTMLElement | null { const editor = this.getTextareaElement() const scopes = [ @@ -1584,7 +1932,7 @@ export class PerplexityAdapter extends SiteAdapter { if (!Array.isArray(data) || data.length === 0) break for (const entry of data) { - const slug = typeof entry?.slug === "string" ? entry.slug.trim() : "" + const slug = this.extractThreadListSlug(entry) if (!slug || slug === "new" || seen.has(slug)) continue seen.add(slug) @@ -1609,6 +1957,21 @@ export class PerplexityAdapter extends SiteAdapter { return results } + private extractThreadListSlug(entry: PerplexityThreadListEntry): string { + const directCandidates = [entry?.slug, entry?.thread_url_slug] + for (const candidate of directCandidates) { + if (typeof candidate !== "string") continue + const slug = candidate.trim() + if (slug) return slug + } + + if (typeof entry?.link === "string") { + return this.parseThreadSlugFromUrl(entry.link) + } + + return "" + } + private getNativeSidebarConversationLinks(): HTMLAnchorElement[] { const root = this.getNativeSidebarRoot() if (!root) return [] @@ -1970,15 +2333,25 @@ export class PerplexityAdapter extends SiteAdapter { } private revealConversationActions(container: HTMLElement): void { + this.ensureElementVisible(container) const events = [ new PointerEvent("pointerenter", { bubbles: true, composed: true }), new MouseEvent("mouseenter", { bubbles: true, composed: true }), new MouseEvent("mouseover", { bubbles: true, composed: true }), + new PointerEvent("pointermove", { bubbles: true, composed: true }), + new MouseEvent("mousemove", { bubbles: true, composed: true }), ] events.forEach((event) => container.dispatchEvent(event)) } + private getConversationHeaderContainer(): HTMLElement | null { + return ( + (document.querySelector(".h-headerHeight.fixed.z-10") as HTMLElement | null) || + (document.querySelector("header") as HTMLElement | null) + ) + } + private findThreadActionButton( preferredContainer: HTMLElement | null, triggerScope: ParentNode, @@ -2367,6 +2740,24 @@ export class PerplexityAdapter extends SiteAdapter { return score } + private ensureElementVisible(element: Element | null): void { + if (!(element instanceof HTMLElement)) return + + try { + element.scrollIntoView({ + block: "center", + inline: "nearest", + behavior: "auto", + }) + } catch { + try { + element.scrollIntoView({ block: "center", inline: "nearest" }) + } catch { + // ignore + } + } + } + private scoreThreadActionButtonCandidate( candidate: HTMLElement, preferredContainer: HTMLElement | null, @@ -2899,23 +3290,14 @@ export class PerplexityAdapter extends SiteAdapter { anchor.closest(".group") || anchor.parentElement - const directCandidates = [ - anchor.getAttribute("title"), - anchor.getAttribute("aria-label"), - row instanceof HTMLElement ? row.getAttribute("title") : null, - row instanceof HTMLElement ? row.getAttribute("aria-label") : null, - anchor.textContent, - row?.textContent || null, - ] - - directCandidates.forEach((value, index) => { + const addCandidate = (value: string | null | undefined, bonus: number) => { const normalized = this.normalizeConversationTitle(value || "", slug) if (!normalized) return candidates.push({ text: normalized, - score: this.scoreConversationTitleCandidate(normalized, slug) + (6 - index), + score: this.scoreConversationTitleCandidate(normalized, slug) + bonus, }) - }) + } const scopedElements = [anchor, row].filter(Boolean) as Element[] const scopedSelectors = ["[data-testid*='title']", "[dir='auto']", ".truncate", "span", "div"] @@ -2923,22 +3305,91 @@ export class PerplexityAdapter extends SiteAdapter { for (const scope of scopedElements) { for (const selector of scopedSelectors) { scope.querySelectorAll(selector).forEach((candidate) => { - const normalized = this.normalizeConversationTitle(candidate.textContent || "", slug) - if (!normalized) return - candidates.push({ - text: normalized, - score: this.scoreConversationTitleCandidate(normalized, slug), - }) + const visibleBonus = + candidate instanceof HTMLElement && this.isVisibleElement(candidate) ? 130 : 70 + addCandidate(candidate.textContent || "", visibleBonus) }) } } + addCandidate(anchor.textContent, 110) + addCandidate(row?.textContent || null, 40) + + // Native Perplexity rename can update visible text before title/aria attributes. + // Keep attributes as fallback signals, but do not let stale attributes beat visible text. + addCandidate(anchor.getAttribute("title"), 25) + addCandidate(anchor.getAttribute("aria-label"), 25) + addCandidate(row instanceof HTMLElement ? row.getAttribute("title") : null, 10) + addCandidate(row instanceof HTMLElement ? row.getAttribute("aria-label") : null, 10) + candidates.sort((left, right) => right.score - left.score) return candidates[0]?.text || slug } + private hasConversationTitleSignal(id: string, title: string): boolean { + const normalizedTarget = this.normalizeConversationTitle(title, id) + if (!normalizedTarget) return false + + const sidebarLink = this.findSidebarConversationLink(id) + if (sidebarLink) { + const sidebarTitle = this.extractConversationTitle(sidebarLink, id) + if (this.normalizeConversationTitle(sidebarTitle, id) === normalizedTarget) { + return true + } + } + + if (this.getSessionId() === id) { + const currentTitle = this.getConversationTitle() || "" + if (this.normalizeConversationTitle(currentTitle, id) === normalizedTarget) { + return true + } + } + + const cachedItem = this.getCachedThreadList().find((item) => item.id === id) + if (cachedItem && this.normalizeConversationTitle(cachedItem.title, id) === normalizedTarget) { + return true + } + + return false + } + + private applyConversationTitleToDom( + anchor: HTMLAnchorElement, + slug: string, + title: string, + ): void { + const currentTitle = this.extractConversationTitle(anchor, slug) + + anchor.setAttribute("title", title) + anchor.setAttribute("aria-label", title) + + const row = this.findConversationItemContainer(anchor) + row?.setAttribute("title", title) + row?.setAttribute("aria-label", title) + + const scopes = [anchor, row].filter(Boolean) as Element[] + for (const scope of scopes) { + const candidates = scope.querySelectorAll( + "[data-testid*='title'], [dir='auto'], .truncate, span", + ) + for (const candidate of Array.from(candidates)) { + if (!(candidate instanceof HTMLElement)) continue + const candidateTitle = this.normalizeConversationTitle(candidate.textContent || "", slug) + if (!candidateTitle || candidateTitle !== currentTitle) continue + candidate.textContent = title + return + } + } + } + private normalizeConversationTitle(rawTitle: string, slug: string): string { - const normalized = rawTitle.replace(/\s+/g, " ").trim() + const normalized = rawTitle + .replace(/\s+/g, " ") + .replace( + /(?:Rename|Delete|More|\u91cd\u547d\u540d|\u5220\u9664|\u66f4\u591a|\u83dc\u5355)+$/giu, + "", + ) + .trim() if (!normalized) return "" if (normalized === "/" || normalized === "..." || normalized === "···") return "" if (/^\(\d+\)$/.test(normalized)) return "" diff --git a/src/components/App.tsx b/src/components/App.tsx index e7abd3c90..c1d8c98d8 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -2166,6 +2166,26 @@ export const App = () => { } }, [promptManager, conversationManager, outlineManager]) + useEffect(() => { + if (!conversationManager) return + + const pendingTimers: ReturnType[] = [] + const scheduleConversationSync = () => { + ;[0, 250, 800, 1600, 3000].forEach((delay) => { + const timer = setTimeout(() => { + conversationManager.syncCurrentConversationNow() + }, delay) + pendingTimers.push(timer) + }) + } + + window.addEventListener("gh-url-change", scheduleConversationSync) + return () => { + window.removeEventListener("gh-url-change", scheduleConversationSync) + pendingTimers.forEach((timer) => clearTimeout(timer)) + } + }, [conversationManager]) + useEffect(() => { if (!conversationManager || typeof chrome === "undefined") return diff --git a/src/core/conversation/manager.ts b/src/core/conversation/manager.ts index 688fcdaad..c74726156 100644 --- a/src/core/conversation/manager.ts +++ b/src/core/conversation/manager.ts @@ -58,10 +58,13 @@ export class ConversationManager { // Observer state private observerConfig: ConversationObserverConfig | null = null private sidebarObserverStop: (() => void) | null = null + private sidebarMutationSyncStop: (() => void) | null = null private observerContainer: Node | null = null private titleWatcher: any = null // DOMToolkit watcher instance private pollInterval: NodeJS.Timeout | null = null private currentConversationSyncTimer: NodeJS.Timeout | null = null + private remoteSnapshotSyncTimer: NodeJS.Timeout | null = null + private remoteSnapshotSyncInFlight: Promise | null = null private geminiMigrationTimer: NodeJS.Timeout | null = null private geminiMigrationRetryCount = 0 @@ -140,6 +143,7 @@ export class ConversationManager { this.startSidebarObserver() this.startCurrentConversationSync() + this.scheduleRemoteConversationSnapshotSync(300) } // Gemini 老数据迁移:数字 cid(0/1/2...) -> 当前邮箱 cid @@ -321,6 +325,7 @@ export class ConversationManager { this.stopGeminiMigrationRetry() this.stopSidebarObserver() this.stopCurrentConversationSync() + this.stopRemoteConversationSnapshotSync() } updateSettings(settings: { syncUnpin: boolean; syncDelete?: boolean }) { @@ -371,6 +376,8 @@ export class ConversationManager { }, { parent: sidebarContainer, shadow: config.shadow }, ) + + this.startSidebarMutationSync(sidebarContainer) } startObserverRetry() @@ -386,6 +393,10 @@ export class ConversationManager { this.sidebarObserverStop = null } this.observerContainer = null + if (this.sidebarMutationSyncStop) { + this.sidebarMutationSyncStop() + this.sidebarMutationSyncStop = null + } if (this.titleWatcher) { // DOMToolkit Watcher doesnt explicitly expose stop on the object returned by watchMultiple? @@ -409,7 +420,8 @@ export class ConversationManager { if (info?.id) { this.updateConversationFromObservation(info, isNew) - this.monitorConversationTitle(el as HTMLElement, info.id) + const titleElement = config.getTitleElement(el) || el + this.monitorConversationTitle(titleElement as HTMLElement, info.id, el) } else if (retries > 0) { setTimeout(() => tryAdd(retries - 1), 500) } @@ -495,7 +507,8 @@ export class ConversationManager { if (!existing) { // 新会话 this.updateConversationFromObservation(info, true) - this.monitorConversationTitle(el as HTMLElement, info.id) + const titleElement = config.getTitleElement(el) || el + this.monitorConversationTitle(titleElement as HTMLElement, info.id, el) } else { // 检测标题变更 if (this.shouldUpdateConversationTitle(existing.title, info.title, existing.siteId)) { @@ -515,66 +528,167 @@ export class ConversationManager { } } + syncCurrentConversationNow(): boolean { + const currentChanged = this.syncCurrentConversation() + const { newCount, updatedCount } = this.syncConversations(null, true) + const listChanged = newCount > 0 || updatedCount > 0 + + if (listChanged) { + this.notifyDataChange() + } + + this.scheduleRemoteConversationSnapshotSync(500) + + return currentChanged || listChanged + } + + private startSidebarMutationSync(container: Node) { + if (this.sidebarMutationSyncStop) return + if (!container || typeof MutationObserver === "undefined") return + if ( + container === document || + container === document.body || + container === document.documentElement + ) { + return + } + + let syncTimer: ReturnType | null = null + const scheduleSync = () => { + if (syncTimer) clearTimeout(syncTimer) + syncTimer = setTimeout(() => { + syncTimer = null + const { newCount, updatedCount } = this.syncConversations(null, true) + if (newCount > 0 || updatedCount > 0) { + this.notifyDataChange() + } + }, 500) + } + + const observer = new MutationObserver(scheduleSync) + observer.observe(container, { + attributes: true, + childList: true, + characterData: true, + subtree: true, + }) + + this.sidebarMutationSyncStop = () => { + if (syncTimer) clearTimeout(syncTimer) + syncTimer = null + observer.disconnect() + } + } + + private scheduleRemoteConversationSnapshotSync(delayMs: number) { + if (this.siteAdapter.getSiteId() !== SITE_IDS.PERPLEXITY) return + if (!this.siteAdapter.loadAllConversations) return + + if (this.remoteSnapshotSyncTimer) { + clearTimeout(this.remoteSnapshotSyncTimer) + } + + this.remoteSnapshotSyncTimer = setTimeout(() => { + this.remoteSnapshotSyncTimer = null + void this.refreshRemoteConversationSnapshot() + }, delayMs) + } + + private async refreshRemoteConversationSnapshot(): Promise { + if (this.siteAdapter.getSiteId() !== SITE_IDS.PERPLEXITY) return + if (!this.siteAdapter.loadAllConversations) return + if (this.remoteSnapshotSyncInFlight) return this.remoteSnapshotSyncInFlight + + this.remoteSnapshotSyncInFlight = (async () => { + try { + await this.siteAdapter.loadAllConversations() + const { newCount, updatedCount } = this.syncConversations(null, true) + if (newCount > 0 || updatedCount > 0) { + this.notifyDataChange() + } + } catch { + // Best-effort only. DOM/sidebar sync still handles visible rows. + } finally { + this.remoteSnapshotSyncInFlight = null + } + })() + + return this.remoteSnapshotSyncInFlight + } + + private stopRemoteConversationSnapshotSync() { + if (this.remoteSnapshotSyncTimer) { + clearTimeout(this.remoteSnapshotSyncTimer) + this.remoteSnapshotSyncTimer = null + } + this.remoteSnapshotSyncInFlight = null + } + private startCurrentConversationSync() { if (this.currentConversationSyncTimer) return - const syncCurrent = () => { - const info = this.siteAdapter.getCurrentConversationInfo() - if (!info?.id) return + this.syncCurrentConversation() + this.currentConversationSyncTimer = setInterval(() => { + this.syncCurrentConversation() + }, 1500) + } - const existing = this.conversations[info.id] - if (!existing && !info.title?.trim()) { - return - } - if (!existing) { - getConversationsStore().addConversation({ - id: info.id, - siteId: this.siteAdapter.getSiteId(), - cid: info.cid, - title: info.title || t("untitledConversation"), - url: info.url || window.location.href, - folderId: this.lastUsedFolderId || "inbox", - pinned: info.isPinned || false, - createdAt: Date.now(), - updatedAt: Date.now(), - }) - this.notifyDataChange() - return - } + private syncCurrentConversation(): boolean { + const info = this.siteAdapter.getCurrentConversationInfo() + if (!info?.id) return false - const updates: Partial = {} - let needsUpdate = false + const existing = this.conversations[info.id] + if (!existing && !info.title?.trim()) { + return false + } + if (!existing) { + getConversationsStore().addConversation({ + id: info.id, + siteId: this.siteAdapter.getSiteId(), + cid: info.cid, + title: info.title || t("untitledConversation"), + url: info.url || window.location.href, + folderId: this.lastUsedFolderId || "inbox", + pinned: info.isPinned || false, + createdAt: Date.now(), + updatedAt: Date.now(), + }) + this.notifyDataChange() + return true + } - if (this.shouldUpdateConversationTitle(existing.title, info.title, existing.siteId)) { - updates.title = info.title - needsUpdate = true - } - if (info.url && info.url !== existing.url) { - updates.url = info.url + const updates: Partial = {} + let needsUpdate = false + + if (this.shouldUpdateConversationTitle(existing.title, info.title, existing.siteId)) { + updates.title = info.title + needsUpdate = true + } + if (info.url && info.url !== existing.url) { + updates.url = info.url + needsUpdate = true + } + if (info.cid !== undefined && info.cid !== existing.cid) { + updates.cid = info.cid + needsUpdate = true + } + if (info.isPinned !== undefined && info.isPinned !== existing.pinned) { + if (info.isPinned) { + updates.pinned = true needsUpdate = true - } - if (info.cid !== undefined && info.cid !== existing.cid) { - updates.cid = info.cid + } else if (this.syncUnpin) { + updates.pinned = false needsUpdate = true } - if (info.isPinned !== undefined && info.isPinned !== existing.pinned) { - if (info.isPinned) { - updates.pinned = true - needsUpdate = true - } else if (this.syncUnpin) { - updates.pinned = false - needsUpdate = true - } - } + } - if (needsUpdate) { - getConversationsStore().updateConversation(info.id, updates) - this.notifyDataChange() - } + if (needsUpdate) { + getConversationsStore().updateConversation(info.id, updates) + this.notifyDataChange() + return true } - syncCurrent() - this.currentConversationSyncTimer = setInterval(syncCurrent, 1500) + return false } private stopCurrentConversationSync() { @@ -583,7 +697,7 @@ export class ConversationManager { this.currentConversationSyncTimer = null } - private monitorConversationTitle(el: HTMLElement, id: string) { + private monitorConversationTitle(el: HTMLElement, id: string, sourceElement?: Element) { if (el.dataset.ghTitleObserver) return el.dataset.ghTitleObserver = "true" @@ -591,6 +705,7 @@ export class ConversationManager { const container = this.siteAdapter.getSidebarScrollContainer() || document.body this.titleWatcher = DOMToolkit.watchMultiple(container as Node, { debounce: 500, + attributes: true, }) } @@ -598,7 +713,7 @@ export class ConversationManager { const config = this.observerConfig if (!config) return - const currentInfo = config.extractInfo(el) + const currentInfo = config.extractInfo(sourceElement || el) const currentId = currentInfo?.id if (!currentId || currentId !== id) return @@ -754,6 +869,7 @@ export class ConversationManager { const remoteMethod = remoteItem?.method || "none" const remoteAttempted = remoteEnabled && remoteResultMap.has(id) && remoteMethod !== "none" const remoteSuccess = remoteAttempted && (remoteItem?.success || false) + const shouldDeleteLocal = exists && (!remoteAttempted || remoteSuccess) if (remoteAttempted) { remoteAttemptedCount++ @@ -764,14 +880,14 @@ export class ConversationManager { } } - if (exists) { + if (shouldDeleteLocal) { getConversationsStore().deleteConversation(id) localDeletedCount++ } results.push({ id, - localDeleted: exists, + localDeleted: shouldDeleteLocal, remoteEnabled, remoteAttempted, remoteSuccess, @@ -838,6 +954,7 @@ export class ConversationManager { if (!normalizedTitle) return const conv = this.conversations[convId] + const previousTitle = conv?.title if (conv) { getConversationsStore().updateConversation(convId, { title: normalizedTitle }) this.notifyDataChange() @@ -850,7 +967,11 @@ export class ConversationManager { } const result = await this.siteAdapter.renameConversationOnSite(target, normalizedTitle) if (!result.success && result.method !== "none") { - showToast(`已在面板重命名,但云端重命名失败${result.reason ? `: ${result.reason}` : ""}`) + if (conv) { + getConversationsStore().updateConversation(convId, { title: previousTitle || "" }) + this.notifyDataChange() + } + showToast(`云端重命名失败,已恢复面板标题${result.reason ? `: ${result.reason}` : ""}`) } } From 73850adec67fcfcfc268fee096270fdd2c48c92b Mon Sep 17 00:00:00 2001 From: KanameMadoka520 <2441883200@qq.com> Date: Sat, 25 Apr 2026 02:39:17 +0800 Subject: [PATCH 17/27] fix: refresh perplexity inbox after native sidebar changes Co-authored-by: LainElaina <1796137367@qq.com> --- src/adapters/perplexity.ts | 19 ++++++---- src/core/conversation/manager.ts | 62 +++++++++++++++++++++++--------- 2 files changed, 59 insertions(+), 22 deletions(-) diff --git a/src/adapters/perplexity.ts b/src/adapters/perplexity.ts index 296280609..13e21174b 100644 --- a/src/adapters/perplexity.ts +++ b/src/adapters/perplexity.ts @@ -507,6 +507,7 @@ export class PerplexityAdapter extends SiteAdapter { ): Promise { const results: SiteDeleteConversationResult[] = [] const deletedSlugs = new Set() + let deletedViaApi = false for (const target of targets) { const slug = @@ -518,10 +519,13 @@ export class PerplexityAdapter extends SiteAdapter { if (result.success && slug) { deletedSlugs.add(slug) + if (result.method === "api") { + deletedViaApi = true + } } } - this.navigateAwayIfCurrentThreadWasDeleted(deletedSlugs) + this.refreshPageAfterNativeApiDelete(deletedSlugs, deletedViaApi) return results } @@ -1634,15 +1638,18 @@ export class PerplexityAdapter extends SiteAdapter { }) } - private navigateAwayIfCurrentThreadWasDeleted(deletedSlugs: Set): void { - if (deletedSlugs.size === 0) return + private refreshPageAfterNativeApiDelete(deletedSlugs: Set, deletedViaApi: boolean): void { + if (deletedSlugs.size === 0 || !deletedViaApi) return window.setTimeout(() => { const currentSlug = this.getSessionId() - if (!currentSlug || !deletedSlugs.has(currentSlug)) return + if (currentSlug && deletedSlugs.has(currentSlug)) { + window.location.assign(buildPerplexityUrl("/")) + return + } - window.location.assign(buildPerplexityUrl("/")) - }, 150) + window.location.reload() + }, 250) } private syncConversationListAfterRename(id: string, title: string): void { diff --git a/src/core/conversation/manager.ts b/src/core/conversation/manager.ts index c74726156..fc820a4fd 100644 --- a/src/core/conversation/manager.ts +++ b/src/core/conversation/manager.ts @@ -543,35 +543,65 @@ export class ConversationManager { } private startSidebarMutationSync(container: Node) { + if (this.siteAdapter.getSiteId() !== SITE_IDS.PERPLEXITY) return if (this.sidebarMutationSyncStop) return if (!container || typeof MutationObserver === "undefined") return - if ( - container === document || - container === document.body || - container === document.documentElement - ) { - return - } - let syncTimer: ReturnType | null = null const scheduleSync = () => { - if (syncTimer) clearTimeout(syncTimer) + if (syncTimer) return syncTimer = setTimeout(() => { syncTimer = null const { newCount, updatedCount } = this.syncConversations(null, true) if (newCount > 0 || updatedCount > 0) { this.notifyDataChange() } - }, 500) + this.scheduleRemoteConversationSnapshotSync(800) + }, 800) } - const observer = new MutationObserver(scheduleSync) - observer.observe(container, { - attributes: true, - childList: true, - characterData: true, - subtree: true, + const isConversationListMutation = (mutations: MutationRecord[]): boolean => { + const selector = this.observerConfig?.selector + if (!selector) return true + + const matchesConversationSelector = (node: Node | null): boolean => { + const element = + node instanceof Element ? node : node instanceof CharacterData ? node.parentElement : null + if (!element) return false + + try { + return Boolean(element.closest(selector) || element.querySelector(selector)) + } catch { + return true + } + } + + return mutations.some((mutation) => { + if (matchesConversationSelector(mutation.target)) return true + return Array.from(mutation.addedNodes).some(matchesConversationSelector) + }) + } + + const observer = new MutationObserver((mutations) => { + if (isConversationListMutation(mutations)) { + scheduleSync() + } }) + try { + observer.observe(container, { + attributes: true, + childList: true, + characterData: true, + subtree: true, + }) + } catch { + if (!(document.body instanceof Node)) return + observer.observe(document.body, { + attributes: true, + childList: true, + characterData: true, + subtree: true, + }) + } this.sidebarMutationSyncStop = () => { if (syncTimer) clearTimeout(syncTimer) From c2e970c47f1c0794579f4f53a17b5a96a92adba5 Mon Sep 17 00:00:00 2001 From: KanameMadoka520 <2441883200@qq.com> Date: Sat, 25 Apr 2026 04:25:19 +0800 Subject: [PATCH 18/27] fix: include perplexity in userscript matches Co-authored-by: LainElaina <1796137367@qq.com> --- vite.userscript.config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/vite.userscript.config.ts b/vite.userscript.config.ts index df86cec75..f54b04e97 100644 --- a/vite.userscript.config.ts +++ b/vite.userscript.config.ts @@ -300,6 +300,8 @@ export default defineConfig({ "https://ima.qq.com/*", "https://chat.deepseek.com/*", "https://www.kimi.com/*", + "https://www.perplexity.ai/*", + "https://perplexity.ai/*", "https://chatglm.cn/*", "https://chat.qwen.ai/*", "https://www.qianwen.com/*", From c5c73ee04977b07d942147ab95b9bf92dd2d0a01 Mon Sep 17 00:00:00 2001 From: KanameMadoka520 <2441883200@qq.com> Date: Sat, 25 Apr 2026 04:32:56 +0800 Subject: [PATCH 19/27] fix: center perplexity zen mode layout Co-authored-by: LainElaina <1796137367@qq.com> --- src/adapters/perplexity.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/adapters/perplexity.ts b/src/adapters/perplexity.ts index 13e21174b..20ba09788 100644 --- a/src/adapters/perplexity.ts +++ b/src/adapters/perplexity.ts @@ -806,22 +806,30 @@ export class PerplexityAdapter extends SiteAdapter { hide: [...ZEN_MODE_HIDE_SELECTORS], styles: [ { selector: ":root", property: "--sidebar-pinned-width", value: "0px" }, + { + selector: "html, body, body *", + property: "--sidebar-pinned-width", + value: "0px", + }, { selector: "main", - property: "padding-left", + property: "margin-left", value: "0px", - extraCss: "padding-right: 0 !important; width: 100% !important;", + extraCss: + "left: 0 !important; right: 0 !important; transform: none !important; padding-left: 0 !important; padding-right: 0 !important; width: 100vw !important; max-width: 100vw !important;", }, { selector: "[role='tabpanel']", property: "max-width", value: "none", - extraCss: "width: 100% !important;", + extraCss: + "width: 100vw !important; margin-left: auto !important; margin-right: auto !important;", }, { selector: "main .mx-auto", property: "max-width", value: "min(1120px, calc(100vw - 48px))", + extraCss: "margin-left: auto !important; margin-right: auto !important;", }, ], } From dc018a309aa7e704e759285308d8926b50581bcb Mon Sep 17 00:00:00 2001 From: KanameMadoka520 <2441883200@qq.com> Date: Sat, 25 Apr 2026 04:35:41 +0800 Subject: [PATCH 20/27] fix: silence perplexity thread preload failures Co-authored-by: LainElaina <1796137367@qq.com> --- src/adapters/perplexity.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/adapters/perplexity.ts b/src/adapters/perplexity.ts index 20ba09788..7f65f9edb 100644 --- a/src/adapters/perplexity.ts +++ b/src/adapters/perplexity.ts @@ -592,8 +592,9 @@ export class PerplexityAdapter extends SiteAdapter { if (apiThreads.length > 0) { this.cacheThreadList(apiThreads) } - } catch (error) { - console.warn("[PerplexityAdapter] Failed to preload thread list:", error) + } catch { + // Thread-list preloading is best-effort. Perplexity can return 403 on login + // pages, and navigation can abort fetches; visible-sidebar sync remains active. } finally { this.loadAllConversationsPromise = null } From 05de0e983612c2f4efaf038d2d4797c7a3120b76 Mon Sep 17 00:00:00 2001 From: KanameMadoka520 <2441883200@qq.com> Date: Sat, 25 Apr 2026 20:05:51 +0800 Subject: [PATCH 21/27] fix: support perplexity userscript model toggle Co-authored-by: LainElaina <1796137367@qq.com> --- src/adapters/perplexity.ts | 93 ++++++++++++++++++++++++++++++-------- 1 file changed, 75 insertions(+), 18 deletions(-) diff --git a/src/adapters/perplexity.ts b/src/adapters/perplexity.ts index 7f65f9edb..056694316 100644 --- a/src/adapters/perplexity.ts +++ b/src/adapters/perplexity.ts @@ -804,6 +804,10 @@ export class PerplexityAdapter extends SiteAdapter { getZenModeConfig(): ZenModeConfig | null { return { + rootClass: { + selector: "body", + className: "ophel-perplexity-zen-mode", + }, hide: [...ZEN_MODE_HIDE_SELECTORS], styles: [ { selector: ":root", property: "--sidebar-pinned-width", value: "0px" }, @@ -813,24 +817,39 @@ export class PerplexityAdapter extends SiteAdapter { value: "0px", }, { - selector: "main", + selector: "body.ophel-perplexity-zen-mode", + property: "--sidebar-pinned-width", + value: "0px", + extraCss: + "--ophel-perplexity-zen-content-width: min(1120px, calc(100dvw - 48px)) !important; overflow-x: hidden !important;", + }, + { + selector: "body.ophel-perplexity-zen-mode :is(#root, #__next, [data-nextjs-root])", + property: "margin-left", + value: "0px", + extraCss: + "left: 0 !important; right: 0 !important; transform: none !important; padding-left: 0 !important; padding-right: 0 !important; width: 100dvw !important; max-width: 100dvw !important;", + }, + { + selector: "body.ophel-perplexity-zen-mode main", property: "margin-left", value: "0px", extraCss: - "left: 0 !important; right: 0 !important; transform: none !important; padding-left: 0 !important; padding-right: 0 !important; width: 100vw !important; max-width: 100vw !important;", + "margin-right: 0 !important; left: 0 !important; right: 0 !important; transform: none !important; padding-left: 0 !important; padding-right: 0 !important; width: 100dvw !important; max-width: 100dvw !important; min-width: 0 !important; box-sizing: border-box !important;", }, { - selector: "[role='tabpanel']", + selector: "body.ophel-perplexity-zen-mode [role='tabpanel']", property: "max-width", value: "none", extraCss: - "width: 100vw !important; margin-left: auto !important; margin-right: auto !important;", + "width: 100dvw !important; margin-left: 0 !important; margin-right: 0 !important; padding-left: 0 !important; padding-right: 0 !important; box-sizing: border-box !important;", }, { - selector: "main .mx-auto", + selector: "body.ophel-perplexity-zen-mode main .mx-auto", property: "max-width", - value: "min(1120px, calc(100vw - 48px))", - extraCss: "margin-left: auto !important; margin-right: auto !important;", + value: "var(--ophel-perplexity-zen-content-width)", + extraCss: + "width: min(100%, var(--ophel-perplexity-zen-content-width)) !important; margin-left: auto !important; margin-right: auto !important; box-sizing: border-box !important;", }, ], } @@ -3502,11 +3521,11 @@ export class PerplexityAdapter extends SiteAdapter { const rect = element.getBoundingClientRect() const clientX = rect.left + Math.max(1, Math.min(rect.width / 2, Math.max(1, rect.width - 1))) const clientY = rect.top + Math.max(1, Math.min(rect.height / 2, Math.max(1, rect.height - 1))) + const eventWindow = element.ownerDocument.defaultView || window const mouseInit: MouseEventInit = { bubbles: true, cancelable: true, composed: true, - view: window, button: 0, buttons: 1, clientX, @@ -3518,17 +3537,55 @@ export class PerplexityAdapter extends SiteAdapter { pointerType: "mouse", isPrimary: true, } + const MouseEventCtor = + eventWindow.MouseEvent || (typeof MouseEvent !== "undefined" ? MouseEvent : null) + const PointerEventCtor = + eventWindow.PointerEvent || (typeof PointerEvent !== "undefined" ? PointerEvent : null) + const EventCtor = eventWindow.Event || Event + + const dispatchSyntheticEvent = (type: string, preferPointer = false) => { + let event: Event | null = null + + try { + if (preferPointer && PointerEventCtor) { + event = new PointerEventCtor(type, pointerInit) + } else if (MouseEventCtor) { + event = new MouseEventCtor(type, mouseInit) + } + } catch { + event = null + } + + if (!event && MouseEventCtor) { + try { + event = new MouseEventCtor(type, mouseInit) + } catch { + event = null + } + } + + if (!event) { + event = new EventCtor(type, { bubbles: true, cancelable: true, composed: true }) + } + + element.dispatchEvent(event) + } - element.focus?.() - element.dispatchEvent(new PointerEvent("pointerenter", pointerInit)) - element.dispatchEvent(new PointerEvent("pointerover", pointerInit)) - element.dispatchEvent(new MouseEvent("mouseenter", mouseInit)) - element.dispatchEvent(new MouseEvent("mouseover", mouseInit)) - element.dispatchEvent(new PointerEvent("pointerdown", pointerInit)) - element.dispatchEvent(new MouseEvent("mousedown", mouseInit)) - element.dispatchEvent(new PointerEvent("pointerup", pointerInit)) - element.dispatchEvent(new MouseEvent("mouseup", mouseInit)) - element.dispatchEvent(new MouseEvent("click", mouseInit)) + try { + element.focus?.({ preventScroll: true }) + } catch { + element.focus?.() + } + + dispatchSyntheticEvent("pointerenter", true) + dispatchSyntheticEvent("pointerover", true) + dispatchSyntheticEvent("mouseenter") + dispatchSyntheticEvent("mouseover") + dispatchSyntheticEvent("pointerdown", true) + dispatchSyntheticEvent("mousedown") + dispatchSyntheticEvent("pointerup", true) + dispatchSyntheticEvent("mouseup") + dispatchSyntheticEvent("click") } private getUiSignalText(element: HTMLElement): string { From 51ef432c8f8b69bf4deebc322aebd7d74d726680 Mon Sep 17 00:00:00 2001 From: KanameMadoka520 <2441883200@qq.com> Date: Sat, 25 Apr 2026 20:17:25 +0800 Subject: [PATCH 22/27] fix: sync perplexity native sidebar deletes Co-authored-by: LainElaina <1796137367@qq.com> --- src/adapters/base.ts | 9 ++++ src/adapters/perplexity.ts | 24 ++++++--- src/core/conversation/manager.ts | 92 +++++++++++++++++++++++++++++++- 3 files changed, 118 insertions(+), 7 deletions(-) diff --git a/src/adapters/base.ts b/src/adapters/base.ts index 654ded6f5..f3d2aa78b 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -92,6 +92,7 @@ export interface ConversationObserverConfig { selector: string shadow: boolean extractInfo: (el: Element) => ConversationInfo | null + extractRemovedInfo?: (el: Element) => ConversationInfo | null getTitleElement: (el: Element) => Element | null enablePolling?: boolean pollIntervalMs?: number @@ -402,6 +403,14 @@ export abstract class SiteAdapter { return [] } + /** + * Whether getConversationList currently includes a complete enough remote snapshot + * to safely prune local conversations that disappeared from the site. + */ + hasAuthoritativeConversationList(): boolean { + return false + } + /** 获取当前页面会话的基础元数据 */ getCurrentConversationInfo(): ConversationInfo | null { const id = this.getSessionId() diff --git a/src/adapters/perplexity.ts b/src/adapters/perplexity.ts index 056694316..53b2b4f88 100644 --- a/src/adapters/perplexity.ts +++ b/src/adapters/perplexity.ts @@ -491,6 +491,10 @@ export class PerplexityAdapter extends SiteAdapter { return this.mergeConversationInfos(cachedList, domList, currentList) } + hasAuthoritativeConversationList(): boolean { + return Date.now() <= this.threadListCacheExpiresAt + } + getConversationObserverConfig(): ConversationObserverConfig | null { return { selector: SIDEBAR_LINK_SELECTOR, @@ -498,6 +502,8 @@ export class PerplexityAdapter extends SiteAdapter { enablePolling: true, pollIntervalMs: 1500, extractInfo: (element) => this.extractConversationInfo(element), + extractRemovedInfo: (element) => + this.extractConversationInfo(element, { allowDisconnected: true }), getTitleElement: (element) => this.findConversationItemContainer(element) || element, } } @@ -589,9 +595,7 @@ export class PerplexityAdapter extends SiteAdapter { this.loadAllConversationsPromise = (async () => { try { const apiThreads = await this.fetchThreadsViaApi() - if (apiThreads.length > 0) { - this.cacheThreadList(apiThreads) - } + this.cacheThreadList(apiThreads) } catch { // Thread-list preloading is best-effort. Perplexity can return 403 on login // pages, and navigation can abort fetches; visible-sidebar sync remains active. @@ -1703,10 +1707,18 @@ export class PerplexityAdapter extends SiteAdapter { } } - private extractConversationInfo(element: Element): ConversationInfo | null { - const anchor = element.closest("a") as HTMLAnchorElement | null + private extractConversationInfo( + element: Element, + options: { allowDisconnected?: boolean } = {}, + ): ConversationInfo | null { + const anchor = ( + element instanceof HTMLAnchorElement + ? element + : element.closest("a") || element.querySelector("a[href^='/search/'], a[href^='/page/']") + ) as HTMLAnchorElement | null if (!anchor) return null - if (!this.isNativeSidebarConversationLink(anchor)) return null + if (!options.allowDisconnected && !this.isNativeSidebarConversationLink(anchor)) return null + if (options.allowDisconnected && this.isElementInsideOphel(anchor)) return null const slug = this.parseThreadSlugFromUrl(anchor.href || anchor.getAttribute("href") || "") if (!slug) return null diff --git a/src/core/conversation/manager.ts b/src/core/conversation/manager.ts index e0fdda7d6..bfebd1253 100644 --- a/src/core/conversation/manager.ts +++ b/src/core/conversation/manager.ts @@ -547,11 +547,19 @@ export class ConversationManager { if (this.sidebarMutationSyncStop) return if (!container || typeof MutationObserver === "undefined") return let syncTimer: ReturnType | null = null + const pendingRemovedConversationIds = new Set() const scheduleSync = () => { if (syncTimer) return syncTimer = setTimeout(() => { syncTimer = null + const removedIds = Array.from(pendingRemovedConversationIds) + pendingRemovedConversationIds.clear() const { newCount, updatedCount } = this.syncConversations(null, true) + void this.syncDeletedConversationsFromRemovedIds(removedIds).then((deletedCount) => { + if (deletedCount > 0) { + this.notifyDataChange() + } + }) if (newCount > 0 || updatedCount > 0) { this.notifyDataChange() } @@ -559,6 +567,45 @@ export class ConversationManager { }, 800) } + const collectRemovedConversationIds = (mutations: MutationRecord[]): string[] => { + const selector = this.observerConfig?.selector + const extractRemovedInfo = + this.observerConfig?.extractRemovedInfo || this.observerConfig?.extractInfo + if (!selector || !extractRemovedInfo) return [] + + const ids = new Set() + const inspectElement = (element: Element | null) => { + if (!element) return + + const candidates: Element[] = [] + try { + if (element.matches(selector)) { + candidates.push(element) + } + candidates.push(...Array.from(element.querySelectorAll(selector))) + } catch { + candidates.push(element) + } + + candidates.forEach((candidate) => { + const info = extractRemovedInfo(candidate) + if (info?.id) ids.add(info.id) + }) + } + + mutations.forEach((mutation) => { + Array.from(mutation.removedNodes).forEach((node) => { + if (node instanceof Element) { + inspectElement(node) + } else if (node instanceof CharacterData) { + inspectElement(node.parentElement) + } + }) + }) + + return Array.from(ids) + } + const isConversationListMutation = (mutations: MutationRecord[]): boolean => { const selector = this.observerConfig?.selector if (!selector) return true @@ -577,11 +624,17 @@ export class ConversationManager { return mutations.some((mutation) => { if (matchesConversationSelector(mutation.target)) return true - return Array.from(mutation.addedNodes).some(matchesConversationSelector) + return ( + Array.from(mutation.addedNodes).some(matchesConversationSelector) || + Array.from(mutation.removedNodes).some(matchesConversationSelector) + ) }) } const observer = new MutationObserver((mutations) => { + collectRemovedConversationIds(mutations).forEach((id) => { + pendingRemovedConversationIds.add(id) + }) if (isConversationListMutation(mutations)) { scheduleSync() } @@ -606,10 +659,47 @@ export class ConversationManager { this.sidebarMutationSyncStop = () => { if (syncTimer) clearTimeout(syncTimer) syncTimer = null + pendingRemovedConversationIds.clear() observer.disconnect() } } + private async syncDeletedConversationsFromRemovedIds(ids: string[]): Promise { + if (!this.syncDelete || ids.length === 0) return 0 + + const uniqueIds = Array.from(new Set(ids.filter(Boolean))) + if (uniqueIds.length === 0) return 0 + + try { + await this.siteAdapter.loadAllConversations?.() + } catch { + return 0 + } + + if (!this.siteAdapter.hasAuthoritativeConversationList()) { + return 0 + } + + const siteIds = new Set(this.siteAdapter.getConversationList().map((item) => item.id)) + const currentId = + this.siteAdapter.getCurrentConversationInfo()?.id || this.siteAdapter.getSessionId() + const store = getConversationsStore() + let deletedCount = 0 + + uniqueIds.forEach((id) => { + if (siteIds.has(id) || currentId === id) return + + const existing = this.conversations[id] + if (!existing) return + if (existing.siteId && existing.siteId !== this.siteAdapter.getSiteId()) return + + store.deleteConversation(id) + deletedCount++ + }) + + return deletedCount + } + private scheduleRemoteConversationSnapshotSync(delayMs: number) { if (this.siteAdapter.getSiteId() !== SITE_IDS.PERPLEXITY) return if (!this.siteAdapter.loadAllConversations) return From ef714d69fd59cd54c79617aadd4f3ccd14245dab Mon Sep 17 00:00:00 2001 From: KanameMadoka520 <2441883200@qq.com> Date: Sat, 25 Apr 2026 20:27:00 +0800 Subject: [PATCH 23/27] fix: center perplexity zen composer Co-authored-by: LainElaina <1796137367@qq.com> --- src/adapters/perplexity.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/adapters/perplexity.ts b/src/adapters/perplexity.ts index 53b2b4f88..a06e95036 100644 --- a/src/adapters/perplexity.ts +++ b/src/adapters/perplexity.ts @@ -825,7 +825,7 @@ export class PerplexityAdapter extends SiteAdapter { property: "--sidebar-pinned-width", value: "0px", extraCss: - "--ophel-perplexity-zen-content-width: min(1120px, calc(100dvw - 48px)) !important; overflow-x: hidden !important;", + "--ophel-perplexity-zen-edge-gap: clamp(16px, 3dvw, 32px) !important; --ophel-perplexity-zen-content-width: min(1120px, calc(100dvw - (var(--ophel-perplexity-zen-edge-gap) * 2))) !important; overflow-x: hidden !important;", }, { selector: "body.ophel-perplexity-zen-mode :is(#root, #__next, [data-nextjs-root])", @@ -841,6 +841,14 @@ export class PerplexityAdapter extends SiteAdapter { extraCss: "margin-right: 0 !important; left: 0 !important; right: 0 !important; transform: none !important; padding-left: 0 !important; padding-right: 0 !important; width: 100dvw !important; max-width: 100dvw !important; min-width: 0 !important; box-sizing: border-box !important;", }, + { + selector: + "body.ophel-perplexity-zen-mode :is(main, [role='tabpanel'], .fixed, .sticky, [class*='fixed'], [class*='sticky']):has(#ask-input), body.ophel-perplexity-zen-mode :is(main, [role='tabpanel'], .fixed, .sticky, [class*='fixed'], [class*='sticky']):has([contenteditable='true'][role='textbox'])", + property: "margin-left", + value: "0px", + extraCss: + "margin-right: 0 !important; left: 0 !important; right: 0 !important; transform: none !important; padding-left: 0 !important; padding-right: 0 !important; width: 100dvw !important; max-width: 100dvw !important; min-width: 0 !important; box-sizing: border-box !important;", + }, { selector: "body.ophel-perplexity-zen-mode [role='tabpanel']", property: "max-width", @@ -855,6 +863,21 @@ export class PerplexityAdapter extends SiteAdapter { extraCss: "width: min(100%, var(--ophel-perplexity-zen-content-width)) !important; margin-left: auto !important; margin-right: auto !important; box-sizing: border-box !important;", }, + { + selector: + "body.ophel-perplexity-zen-mode form:has(#ask-input), body.ophel-perplexity-zen-mode form:has([contenteditable='true'][role='textbox'])", + property: "max-width", + value: "var(--ophel-perplexity-zen-content-width)", + extraCss: + "width: min(100%, var(--ophel-perplexity-zen-content-width)) !important; margin-left: auto !important; margin-right: auto !important; box-sizing: border-box !important;", + }, + { + selector: + "body.ophel-perplexity-zen-mode :is(#ask-input, [contenteditable='true'][role='textbox'])", + property: "max-width", + value: "100%", + extraCss: "box-sizing: border-box !important;", + }, ], } } From 3053fa511db6eb2ccf89c55a0d8a789add62e967 Mon Sep 17 00:00:00 2001 From: KanameMadoka520 <2441883200@qq.com> Date: Sat, 25 Apr 2026 20:34:21 +0800 Subject: [PATCH 24/27] fix: full-bleed perplexity zen composer rail Co-authored-by: LainElaina <1796137367@qq.com> --- src/adapters/perplexity.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/adapters/perplexity.ts b/src/adapters/perplexity.ts index a06e95036..1f656c69a 100644 --- a/src/adapters/perplexity.ts +++ b/src/adapters/perplexity.ts @@ -849,6 +849,14 @@ export class PerplexityAdapter extends SiteAdapter { extraCss: "margin-right: 0 !important; left: 0 !important; right: 0 !important; transform: none !important; padding-left: 0 !important; padding-right: 0 !important; width: 100dvw !important; max-width: 100dvw !important; min-width: 0 !important; box-sizing: border-box !important;", }, + { + selector: + "body.ophel-perplexity-zen-mode :is(div, section, footer):has(> form:has(#ask-input)), body.ophel-perplexity-zen-mode :is(div, section, footer):has(> form:has([contenteditable='true'][role='textbox']))", + property: "margin-left", + value: "calc(50% - 50dvw)", + extraCss: + "margin-right: calc(50% - 50dvw) !important; left: auto !important; right: auto !important; transform: none !important; padding-left: var(--ophel-perplexity-zen-edge-gap) !important; padding-right: var(--ophel-perplexity-zen-edge-gap) !important; width: 100dvw !important; max-width: 100dvw !important; min-width: 0 !important; box-sizing: border-box !important; display: block !important;", + }, { selector: "body.ophel-perplexity-zen-mode [role='tabpanel']", property: "max-width", From dea0dba9810534c99457cb01a487a07575267ee8 Mon Sep 17 00:00:00 2001 From: KanameMadoka520 <2441883200@qq.com> Date: Sat, 25 Apr 2026 20:42:36 +0800 Subject: [PATCH 25/27] fix: calibrate perplexity zen centering Co-authored-by: LainElaina <1796137367@qq.com> --- src/adapters/base.ts | 4 + src/adapters/perplexity.ts | 180 ++++++++++++++++++++++++++++++++++++- src/core/layout-manager.ts | 2 + 3 files changed, 185 insertions(+), 1 deletion(-) diff --git a/src/adapters/base.ts b/src/adapters/base.ts index f3d2aa78b..b284d0b07 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -625,6 +625,10 @@ export abstract class SiteAdapter { return null } + onZenModeChanged(_enabled: boolean): void { + // Optional hook for site-specific layout calibration. + } + /** 返回净化模式配置(隐藏免责声明、广告、下载按钮等冗余元素) */ getCleanModeConfig(): ZenModeConfig | null { return null diff --git a/src/adapters/perplexity.ts b/src/adapters/perplexity.ts index 1f656c69a..b0a121448 100644 --- a/src/adapters/perplexity.ts +++ b/src/adapters/perplexity.ts @@ -215,6 +215,13 @@ export class PerplexityAdapter extends SiteAdapter { private threadListCacheExpiresAt = 0 private loadAllConversationsPromise: Promise | null = null private lastManualModelSelectorToggleAt = 0 + private zenCalibrationTimer: ReturnType | null = null + private zenCalibrationFollowupTimers: Array> = [] + private zenCalibrationRaf: number | null = null + private zenCalibrationObserver: MutationObserver | null = null + private zenCalibrationResizeObserver: ResizeObserver | null = null + private zenComposerCorrectionTarget: HTMLElement | null = null + private zenCorrectionPx = 0 match(): boolean { return HOSTNAMES.has(window.location.hostname) @@ -825,7 +832,7 @@ export class PerplexityAdapter extends SiteAdapter { property: "--sidebar-pinned-width", value: "0px", extraCss: - "--ophel-perplexity-zen-edge-gap: clamp(16px, 3dvw, 32px) !important; --ophel-perplexity-zen-content-width: min(1120px, calc(100dvw - (var(--ophel-perplexity-zen-edge-gap) * 2))) !important; overflow-x: hidden !important;", + "--ophel-perplexity-zen-correction: 0px !important; --ophel-perplexity-zen-edge-gap: clamp(16px, 3dvw, 32px) !important; --ophel-perplexity-zen-content-width: min(1120px, calc(100dvw - (var(--ophel-perplexity-zen-edge-gap) * 2))) !important; overflow-x: hidden !important;", }, { selector: "body.ophel-perplexity-zen-mode :is(#root, #__next, [data-nextjs-root])", @@ -879,6 +886,12 @@ export class PerplexityAdapter extends SiteAdapter { extraCss: "width: min(100%, var(--ophel-perplexity-zen-content-width)) !important; margin-left: auto !important; margin-right: auto !important; box-sizing: border-box !important;", }, + { + selector: "body.ophel-perplexity-zen-mode .ophel-perplexity-zen-composer-center-target", + property: "translate", + value: "var(--ophel-perplexity-zen-correction, 0px) 0", + extraCss: "will-change: translate !important;", + }, { selector: "body.ophel-perplexity-zen-mode :is(#ask-input, [contenteditable='true'][role='textbox'])", @@ -890,12 +903,177 @@ export class PerplexityAdapter extends SiteAdapter { } } + onZenModeChanged(enabled: boolean): void { + if (enabled) { + this.startZenCenterCalibration() + } else { + this.stopZenCenterCalibration() + } + } + getCleanModeConfig(): ZenModeConfig | null { return { hide: [...CLEAN_MODE_HIDE_SELECTORS], } } + private startZenCenterCalibration(): void { + this.stopZenCenterCalibration({ keepCorrection: false }) + this.scheduleZenCenterCalibration(0) + this.zenCalibrationFollowupTimers = [80, 240, 600, 1200, 2200].map((delay) => + setTimeout(() => this.scheduleZenCenterCalibration(0), delay), + ) + + if (typeof MutationObserver !== "undefined" && document.body) { + this.zenCalibrationObserver = new MutationObserver(() => + this.scheduleZenCenterCalibration(80), + ) + this.zenCalibrationObserver.observe(document.body, { + attributes: true, + childList: true, + subtree: true, + }) + } + + if (typeof ResizeObserver !== "undefined") { + this.zenCalibrationResizeObserver = new ResizeObserver(() => + this.scheduleZenCenterCalibration(40), + ) + this.zenCalibrationResizeObserver.observe(document.documentElement) + if (document.body) this.zenCalibrationResizeObserver.observe(document.body) + } + + window.addEventListener("resize", this.handleZenCalibrationResize, { passive: true }) + } + + private stopZenCenterCalibration(options: { keepCorrection?: boolean } = {}): void { + if (this.zenCalibrationTimer) { + clearTimeout(this.zenCalibrationTimer) + this.zenCalibrationTimer = null + } + + this.zenCalibrationFollowupTimers.forEach((timer) => clearTimeout(timer)) + this.zenCalibrationFollowupTimers = [] + + if (this.zenCalibrationRaf !== null) { + cancelAnimationFrame(this.zenCalibrationRaf) + this.zenCalibrationRaf = null + } + + this.zenCalibrationObserver?.disconnect() + this.zenCalibrationObserver = null + this.zenCalibrationResizeObserver?.disconnect() + this.zenCalibrationResizeObserver = null + window.removeEventListener("resize", this.handleZenCalibrationResize) + + if (!options.keepCorrection) { + this.zenCorrectionPx = 0 + this.applyZenCorrectionVariable(0, { remove: true }) + } + + this.clearZenComposerCorrectionTarget() + } + + private handleZenCalibrationResize = () => { + this.scheduleZenCenterCalibration(40) + } + + private scheduleZenCenterCalibration(delayMs: number): void { + if (!document.body?.classList.contains("ophel-perplexity-zen-mode")) return + + if (this.zenCalibrationTimer) { + clearTimeout(this.zenCalibrationTimer) + } + + this.zenCalibrationTimer = setTimeout(() => { + this.zenCalibrationTimer = null + if (this.zenCalibrationRaf !== null) { + cancelAnimationFrame(this.zenCalibrationRaf) + } + this.zenCalibrationRaf = requestAnimationFrame(() => { + this.zenCalibrationRaf = null + this.calibrateZenCenterFromComposer() + }) + }, delayMs) + } + + private calibrateZenCenterFromComposer(): void { + if (!document.body?.classList.contains("ophel-perplexity-zen-mode")) return + + const target = this.getZenComposerMeasurementTarget() + if (!target) { + this.clearZenComposerCorrectionTarget() + return + } + + this.markZenComposerCorrectionTarget(target) + + const rect = target.getBoundingClientRect() + if (rect.width <= 0 || rect.height <= 0) return + + const leftGap = rect.left + const rightGap = window.innerWidth - rect.right + const diff = leftGap - rightGap + if (Math.abs(diff) <= 2) return + + const nextCorrection = this.clampZenCorrection(this.zenCorrectionPx - diff / 2) + if (Math.abs(nextCorrection - this.zenCorrectionPx) <= 0.5) return + + this.zenCorrectionPx = nextCorrection + this.applyZenCorrectionVariable(nextCorrection) + + // Let layout settle and re-measure once; this handles late composer hydration. + this.scheduleZenCenterCalibration(80) + } + + private getZenComposerMeasurementTarget(): HTMLElement | null { + const editor = this.getTextareaElement() + const form = editor?.closest("form") + if (form instanceof HTMLElement && this.isVisibleElement(form)) { + return form + } + + const fallback = + document.querySelector("form:has(#ask-input)") || + document.querySelector("form:has([contenteditable='true'][role='textbox'])") + if (fallback instanceof HTMLElement && this.isVisibleElement(fallback)) { + return fallback + } + + return editor instanceof HTMLElement && this.isVisibleElement(editor) ? editor : null + } + + private clampZenCorrection(value: number): number { + return Math.max(-320, Math.min(320, value)) + } + + private markZenComposerCorrectionTarget(target: HTMLElement): void { + if (this.zenComposerCorrectionTarget === target) return + this.clearZenComposerCorrectionTarget() + this.zenComposerCorrectionTarget = target + target.classList.add("ophel-perplexity-zen-composer-center-target") + } + + private clearZenComposerCorrectionTarget(): void { + this.zenComposerCorrectionTarget?.classList.remove( + "ophel-perplexity-zen-composer-center-target", + ) + this.zenComposerCorrectionTarget = null + } + + private applyZenCorrectionVariable(value: number, options: { remove?: boolean } = {}): void { + const propertyName = "--ophel-perplexity-zen-correction" + if (options.remove) { + document.documentElement.style.removeProperty(propertyName) + document.body?.style.removeProperty(propertyName) + return + } + + const propertyValue = `${value.toFixed(2)}px` + document.documentElement.style.setProperty(propertyName, propertyValue, "important") + document.body?.style.setProperty(propertyName, propertyValue, "important") + } + getModelSwitcherConfig(keyword: string): ModelSwitcherConfig | null { return { targetModelKeyword: keyword, diff --git a/src/core/layout-manager.ts b/src/core/layout-manager.ts index d30d4cbb8..ccd8091b1 100644 --- a/src/core/layout-manager.ts +++ b/src/core/layout-manager.ts @@ -107,6 +107,7 @@ export class LayoutManager { if (!this.zenModeEnabled) { this.cleanupZenModeRootClass() this.unmountZenModeExitButton() + this.siteAdapter.onZenModeChanged(false) this.refreshShadowInjection() return } @@ -118,6 +119,7 @@ export class LayoutManager { this.zenModeStyle = this.injectStyle(STYLE_IDS.ZEN_MODE, css) } this.mountZenModeExitButton() + this.siteAdapter.onZenModeChanged(true) this.refreshShadowInjection() } From 158d5b6b5b1d0d3a41968ea312ca669b41e2657b Mon Sep 17 00:00:00 2001 From: KanameMadoka520 <2441883200@qq.com> Date: Sat, 25 Apr 2026 21:06:21 +0800 Subject: [PATCH 26/27] fix: center perplexity zen home layout Co-authored-by: LainElaina <1796137367@qq.com> --- src/adapters/perplexity.ts | 291 +++++++++++++++++++++++++++++++++++-- 1 file changed, 276 insertions(+), 15 deletions(-) diff --git a/src/adapters/perplexity.ts b/src/adapters/perplexity.ts index b0a121448..a9b53c941 100644 --- a/src/adapters/perplexity.ts +++ b/src/adapters/perplexity.ts @@ -220,7 +220,7 @@ export class PerplexityAdapter extends SiteAdapter { private zenCalibrationRaf: number | null = null private zenCalibrationObserver: MutationObserver | null = null private zenCalibrationResizeObserver: ResizeObserver | null = null - private zenComposerCorrectionTarget: HTMLElement | null = null + private zenCorrectionTargets: HTMLElement[] = [] private zenCorrectionPx = 0 match(): boolean { @@ -887,7 +887,8 @@ export class PerplexityAdapter extends SiteAdapter { "width: min(100%, var(--ophel-perplexity-zen-content-width)) !important; margin-left: auto !important; margin-right: auto !important; box-sizing: border-box !important;", }, { - selector: "body.ophel-perplexity-zen-mode .ophel-perplexity-zen-composer-center-target", + selector: + "body.ophel-perplexity-zen-mode .ophel-perplexity-zen-center-target, body.ophel-perplexity-zen-mode .ophel-perplexity-zen-composer-center-target", property: "translate", value: "var(--ophel-perplexity-zen-correction, 0px) 0", extraCss: "will-change: translate !important;", @@ -971,7 +972,7 @@ export class PerplexityAdapter extends SiteAdapter { this.applyZenCorrectionVariable(0, { remove: true }) } - this.clearZenComposerCorrectionTarget() + this.clearZenCorrectionTargets() } private handleZenCalibrationResize = () => { @@ -1002,11 +1003,11 @@ export class PerplexityAdapter extends SiteAdapter { const target = this.getZenComposerMeasurementTarget() if (!target) { - this.clearZenComposerCorrectionTarget() + this.clearZenCorrectionTargets() return } - this.markZenComposerCorrectionTarget(target) + this.markZenCorrectionTargets(this.getZenCorrectionTargets(target)) const rect = target.getBoundingClientRect() if (rect.width <= 0 || rect.height <= 0) return @@ -1028,6 +1029,16 @@ export class PerplexityAdapter extends SiteAdapter { private getZenComposerMeasurementTarget(): HTMLElement | null { const editor = this.getTextareaElement() + const pageColumn = editor ? this.findZenHomeContentColumn(editor) : null + if (pageColumn) { + return pageColumn + } + + const shell = editor ? this.findZenComposerShell(editor) : null + if (shell) { + return shell + } + const form = editor?.closest("form") if (form instanceof HTMLElement && this.isVisibleElement(form)) { return form @@ -1043,22 +1054,272 @@ export class PerplexityAdapter extends SiteAdapter { return editor instanceof HTMLElement && this.isVisibleElement(editor) ? editor : null } + private findZenHomeContentColumn(editor: HTMLElement): HTMLElement | null { + const viewportWidth = Math.max(window.innerWidth, 1) + const viewportHeight = Math.max(window.innerHeight, 1) + const candidates: Array<{ element: HTMLElement; score: number }> = [] + + let current: HTMLElement | null = editor + while (current && current !== document.body && current !== document.documentElement) { + if (this.isVisibleElement(current)) { + const rect = current.getBoundingClientRect() + const containsEditor = current === editor || current.contains(editor) + const widthOk = rect.width >= 640 && rect.width <= viewportWidth - 24 + const heightOk = rect.height >= 120 && rect.height <= Math.max(viewportHeight, 360) + + if (containsEditor && widthOk && heightOk) { + const signal = this.getZenHomeContentSignal(current) + let score = 0 + if (signal.hasTopCategories) score += 120 + if (signal.hasPerplexityPro) score += 90 + if (signal.hasComputerCard) score += 70 + if (signal.hasModelControl) score += 25 + if (rect.width >= 900) score += 30 + if (rect.height >= 420) score += 30 + score += Math.min(this.getElementDepthFrom(editor, current), 24) * 3 + + if (score >= 120) { + candidates.push({ element: current, score }) + } + } + } + + current = current.parentElement + } + + candidates.sort((a, b) => b.score - a.score) + return candidates[0]?.element || null + } + + private getZenHomeContentSignal(element: HTMLElement): { + hasTopCategories: boolean + hasPerplexityPro: boolean + hasComputerCard: boolean + hasModelControl: boolean + } { + const text = this.normalizeUiText(element.textContent || "") + return { + hasTopCategories: + /发现/.test(text) && /金融/.test(text) && /健康/.test(text) && /学术/.test(text), + hasPerplexityPro: /perplexity\s*pro/i.test(text), + hasComputerCard: /让电脑开始工作|尝试电脑|computer/i.test(text), + hasModelControl: /(gpt|gemini|claude|sonar|模型)/i.test(text), + } + } + + private findZenComposerShell(editor: HTMLElement): HTMLElement | null { + const viewportWidth = Math.max(window.innerWidth, 1) + const viewportHeight = Math.max(window.innerHeight, 1) + const candidates: Array<{ element: HTMLElement; score: number }> = [] + + let current: HTMLElement | null = editor + while (current && current !== document.body && current !== document.documentElement) { + if (this.isVisibleElement(current)) { + const rect = current.getBoundingClientRect() + const widthOk = rect.width >= 420 && rect.width <= viewportWidth - 24 + const heightOk = rect.height >= 44 && rect.height <= Math.max(260, viewportHeight * 0.45) + const containsEditor = current === editor || current.contains(editor) + + if (containsEditor && widthOk && heightOk) { + const signal = this.getZenComposerShellSignal(current) + const style = window.getComputedStyle(current) + const borderRadius = Number.parseFloat(style.borderTopLeftRadius || "0") + const hasBorder = + style.borderTopStyle !== "none" || + style.borderRightStyle !== "none" || + style.borderBottomStyle !== "none" || + style.borderLeftStyle !== "none" + const hasSurface = + hasBorder || + style.boxShadow !== "none" || + borderRadius >= 8 || + /rgb|hsl|color|var\(/i.test(style.backgroundColor) + + let score = 0 + if (current instanceof HTMLFormElement) score += 40 + if (signal.hasSubmitControl) score += 80 + if (signal.hasModelControl) score += 70 + if (signal.hasAttachmentControl) score += 30 + if (hasSurface) score += 35 + if (borderRadius >= 12) score += 20 + score += Math.min(rect.width, 1120) / 40 + score -= Math.abs(rect.height - 96) / 4 + score -= this.getElementDepthFrom(editor, current) * 2 + + if (score >= 70) { + candidates.push({ element: current, score }) + } + } + } + + current = current.parentElement + } + + candidates.sort((a, b) => b.score - a.score) + return candidates[0]?.element || null + } + + private getZenComposerShellSignal(element: HTMLElement): { + hasModelControl: boolean + hasSubmitControl: boolean + hasAttachmentControl: boolean + } { + const buttons = Array.from( + element.querySelectorAll("button, [role='button'], [aria-haspopup='menu']"), + ) + + let hasModelControl = false + let hasSubmitControl = false + let hasAttachmentControl = false + + for (const button of buttons) { + const signal = this.normalizeUiText( + [ + button.textContent || "", + button.getAttribute("aria-label") || "", + button.getAttribute("title") || "", + button.getAttribute("data-testid") || "", + button.getAttribute("data-test-id") || "", + button.className || "", + ].join(" "), + ) + + if (/(model|gpt|gemini|claude|sonar|nemotron|模型)/i.test(signal)) { + hasModelControl = true + } + if (/(submit|send|voice|audio|mic|microphone|arrow|提交|发送|語音|语音)/i.test(signal)) { + hasSubmitControl = true + } + if ( + /(attach|upload|file|connector|computer|add|附件|文件|上传|連接器|连接器)/i.test(signal) + ) { + hasAttachmentControl = true + } + } + + return { hasModelControl, hasSubmitControl, hasAttachmentControl } + } + + private getElementDepthFrom(descendant: HTMLElement, ancestor: HTMLElement): number { + let depth = 0 + let current: HTMLElement | null = descendant + while (current && current !== ancestor) { + depth += 1 + current = current.parentElement + } + return depth + } + + private getZenCorrectionTargets(measurementTarget: HTMLElement): HTMLElement[] { + const targets = [measurementTarget] + targets.push(...this.findZenHomeLandmarkCorrectionTargets(measurementTarget)) + return this.dedupeZenCorrectionTargets(targets) + } + + private findZenHomeLandmarkCorrectionTargets(measurementTarget: HTMLElement): HTMLElement[] { + const scope = document.querySelector("main") || document.body + if (!scope) return [] + + const candidates = Array.from( + scope.querySelectorAll( + "h1, h2, header, nav, section, div, [role='heading'], [role='tablist']", + ), + ) + const targets: HTMLElement[] = [] + const landmarkPatterns = [ + /perplexity\s*pro/i, + /发现.*金融.*健康.*学术|发现.*金融.*健康|金融.*健康.*学术.*专利/, + ] + const viewportWidth = Math.max(window.innerWidth, 1) + + for (const candidate of candidates) { + if (!this.isVisibleElement(candidate)) continue + if (candidate.closest("#gh-zen-mode-exit-host, .ophel-root, [data-ophel-root]")) continue + if (measurementTarget.contains(candidate)) continue + + const text = this.normalizeUiText(candidate.textContent || "") + if (!landmarkPatterns.some((pattern) => pattern.test(text))) continue + + const target = this.getZenLandmarkCorrectionTarget(candidate) + if (!target || measurementTarget.contains(target)) continue + + const rect = target.getBoundingClientRect() + if (rect.width <= 0 || rect.width > viewportWidth - 24 || rect.height > 260) continue + + targets.push(target) + } + + return targets + } + + private getZenLandmarkCorrectionTarget(element: HTMLElement): HTMLElement | null { + const viewportWidth = Math.max(window.innerWidth, 1) + let target: HTMLElement = element + let current = element.parentElement + let climbed = 0 + + while ( + current && + current !== document.body && + current !== document.documentElement && + climbed < 4 && + this.isVisibleElement(current) + ) { + const rect = current.getBoundingClientRect() + if (rect.width > viewportWidth - 24 || rect.height > 260) break + target = current + current = current.parentElement + climbed += 1 + } + + return target + } + + private dedupeZenCorrectionTargets(targets: HTMLElement[]): HTMLElement[] { + const unique: HTMLElement[] = [] + for (const target of targets) { + if (!target.isConnected) continue + if (unique.some((existing) => existing === target || existing.contains(target))) continue + + for (let index = unique.length - 1; index >= 0; index -= 1) { + if (target.contains(unique[index])) { + unique.splice(index, 1) + } + } + + unique.push(target) + } + + return unique + } + private clampZenCorrection(value: number): number { return Math.max(-320, Math.min(320, value)) } - private markZenComposerCorrectionTarget(target: HTMLElement): void { - if (this.zenComposerCorrectionTarget === target) return - this.clearZenComposerCorrectionTarget() - this.zenComposerCorrectionTarget = target - target.classList.add("ophel-perplexity-zen-composer-center-target") + private markZenCorrectionTargets(targets: HTMLElement[]): void { + const current = this.zenCorrectionTargets + if ( + current.length === targets.length && + current.every((target, index) => target === targets[index]) + ) { + return + } + + this.clearZenCorrectionTargets() + this.zenCorrectionTargets = targets + for (const target of targets) { + target.classList.add("ophel-perplexity-zen-center-target") + target.classList.add("ophel-perplexity-zen-composer-center-target") + } } - private clearZenComposerCorrectionTarget(): void { - this.zenComposerCorrectionTarget?.classList.remove( - "ophel-perplexity-zen-composer-center-target", - ) - this.zenComposerCorrectionTarget = null + private clearZenCorrectionTargets(): void { + for (const target of this.zenCorrectionTargets) { + target.classList.remove("ophel-perplexity-zen-center-target") + target.classList.remove("ophel-perplexity-zen-composer-center-target") + } + this.zenCorrectionTargets = [] } private applyZenCorrectionVariable(value: number, options: { remove?: boolean } = {}): void { From 7ca9c77cd44b80ae26b90a59462d1cdef952423b Mon Sep 17 00:00:00 2001 From: KanameMadoka520 <2441883200@qq.com> Date: Sat, 25 Apr 2026 21:24:48 +0800 Subject: [PATCH 27/27] fix: address perplexity review findings Co-authored-by: LainElaina <1796137367@qq.com> --- src/components/App.tsx | 16 ++++-- src/core/assistant-mermaid-renderer.ts | 13 ++++- src/core/theme-manager.ts | 8 +++ src/core/usage-counter-manager.ts | 57 +-------------------- src/tabs/options/pages/SiteSettingsPage.tsx | 2 +- 5 files changed, 35 insertions(+), 61 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 7ad6f38b7..a3e32ccb7 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -2353,20 +2353,28 @@ export const App = () => { useEffect(() => { if (!conversationManager) return - const pendingTimers: ReturnType[] = [] + const pendingTimers = new Set>() + const clearPendingTimers = () => { + pendingTimers.forEach((timer) => clearTimeout(timer)) + pendingTimers.clear() + } + const scheduleConversationSync = () => { + clearPendingTimers() ;[0, 250, 800, 1600, 3000].forEach((delay) => { - const timer = setTimeout(() => { + let timer: ReturnType + timer = setTimeout(() => { + pendingTimers.delete(timer) conversationManager.syncCurrentConversationNow() }, delay) - pendingTimers.push(timer) + pendingTimers.add(timer) }) } window.addEventListener("gh-url-change", scheduleConversationSync) return () => { window.removeEventListener("gh-url-change", scheduleConversationSync) - pendingTimers.forEach((timer) => clearTimeout(timer)) + clearPendingTimers() } }, [conversationManager]) diff --git a/src/core/assistant-mermaid-renderer.ts b/src/core/assistant-mermaid-renderer.ts index 1a0525ef2..44801f64e 100644 --- a/src/core/assistant-mermaid-renderer.ts +++ b/src/core/assistant-mermaid-renderer.ts @@ -738,7 +738,10 @@ export class AssistantMermaidRenderer { } console.warn("[AssistantMermaidRenderer] Mermaid render skipped:", error) - if (this.adapter.getSiteId() === "perplexity") { + if ( + this.adapter.getSiteId() === "perplexity" && + this.isPerplexityMermaidDebugToastEnabled() + ) { const message = error instanceof Error ? error.message : typeof error === "string" ? error : "unknown" showToastThrottled( @@ -759,6 +762,14 @@ export class AssistantMermaidRenderer { } } + private isPerplexityMermaidDebugToastEnabled(): boolean { + try { + return localStorage.getItem("ophel:perplexity-mermaid-debug") === "1" + } catch { + return false + } + } + private ensurePanel(block: HTMLElement): HTMLElement { const existing = this.blockPanels.get(block) || diff --git a/src/core/theme-manager.ts b/src/core/theme-manager.ts index fd5e22464..63015f58a 100644 --- a/src/core/theme-manager.ts +++ b/src/core/theme-manager.ts @@ -201,10 +201,18 @@ export class ThemeManager { document.documentElement.classList.toggle("light", targetMode === "light") document.documentElement.style.colorScheme = targetMode if (document.body) { + document.body.setAttribute("data-color-scheme", targetMode) document.body.classList.toggle("dark", targetMode === "dark") document.body.classList.toggle("light", targetMode === "light") document.body.style.colorScheme = targetMode } + window.dispatchEvent( + new StorageEvent("storage", { + key: "theme", + newValue: "system", + storageArea: localStorage, + }), + ) window.dispatchEvent( new StorageEvent("storage", { key: "appearance", diff --git a/src/core/usage-counter-manager.ts b/src/core/usage-counter-manager.ts index 5ae557eb9..3a3f791a3 100644 --- a/src/core/usage-counter-manager.ts +++ b/src/core/usage-counter-manager.ts @@ -1235,58 +1235,7 @@ export class UsageCounterManager { } } - { - const nodes = this.queryAllSelectors(selectors) - if (nodes.length === 0) { - return { - conversationText: "", - conversationChars: 0, - conversationTokens: 0, - outputChars: 0, - outputTokens: 0, - } - } - - const userSelector = this.adapter.getUserQuerySelector() - const uniqueNodes = this.sortElementsInDomOrder(Array.from(new Set(nodes))) - const conversationChunks: string[] = [] - const outputChunks: string[] = [] - - uniqueNodes.forEach((node) => { - const isUser = userSelector ? this.matchesSelector(node, userSelector) : false - const text = isUser - ? this.adapter.extractUserQueryMarkdown(node) - : this.adapter.extractAssistantResponseText(node) - const normalized = normalizeText(text || node.textContent || "") - if (!normalized) return - - conversationChunks.push(normalized) - if (!isUser) { - outputChunks.push(normalized) - } - }) - - const conversationText = conversationChunks.join("\n") - const outputText = outputChunks.join("\n") - - return { - conversationText, - conversationChars: conversationText.length, - conversationTokens: this.estimateTokens(conversationText), - outputChars: outputText.length, - outputTokens: this.estimateTokens(outputText), - } - } - - let nodes: Element[] = [] - try { - // DOMToolkit.query 在 all=true 且传入 selector 数组时,会返回“首个命中的 selector 结果”, - // 而不是多个 selector 的并集。这里显式合并为一个复合 selector,确保用户消息和 AI 输出都能被统计。 - const combinedSelector = selectors.join(", ") - nodes = (DOMToolkit.query(combinedSelector, { all: true, shadow: true }) as Element[]) || [] - } catch { - nodes = [] - } + const nodes = this.queryAllSelectors(selectors) if (nodes.length === 0) { return { @@ -1299,12 +1248,10 @@ export class UsageCounterManager { } const userSelector = this.adapter.getUserQuerySelector() - const uniqueNodes = Array.from(new Set(nodes)) + const uniqueNodes = this.sortElementsInDomOrder(Array.from(new Set(nodes))) const conversationChunks: string[] = [] const outputChunks: string[] = [] - // 这里只统计当前页面 DOM 中“已经加载出来”的会话内容。 - // 如果站点做了懒加载或折叠隐藏,未出现在 DOM 中的历史内容不会被计入。 uniqueNodes.forEach((node) => { const isUser = userSelector ? this.matchesSelector(node, userSelector) : false const text = isUser diff --git a/src/tabs/options/pages/SiteSettingsPage.tsx b/src/tabs/options/pages/SiteSettingsPage.tsx index 688712eca..4a887e1e0 100644 --- a/src/tabs/options/pages/SiteSettingsPage.tsx +++ b/src/tabs/options/pages/SiteSettingsPage.tsx @@ -616,7 +616,7 @@ const SiteSettingsPage: React.FC = ({ siteId, initialTab siteKey="perplexity" settings={settings} setSettings={setSettings} - placeholder={t("modelKeywordPlaceholder") || "Model keyword"} + placeholder={t("modelKeywordPlaceholder")} onDisabledClick={() => showPrerequisiteToast(modelLockLabel)} settingId="model-lock-perplexity" />