ChatGPT-Pin助手

让用户能够轻松地将重要对话置顶,以便快速访问,从而提高生产力和用户体验。

// ==UserScript==
// @name         ChatGPT-Pin-Helper
// @name:en      ChatGPT-Pin-Helper
// @name:en-US   ChatGPT-Pin-Helper
// @name:zh-CN   ChatGPT-Pin助手
// @namespace    http://tampermonkey.net/
// @version      0.1.2
// @description  Enable users to easily pin important conversations to the top for quick access and better organization, enhancing productivity and user experience.
// @description:zh-CN  让用户能够轻松地将重要对话置顶,以便快速访问,从而提高生产力和用户体验。
// @author       NevainK
// @license      GPL-3.0
// @match        https://chatgpt.com/*
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_listValues

// ==/UserScript==
(function () {
  "use strict";

  const messages = {
    pin: "Pin",
    unpin: "Unpin",
    pinnedChatsSidebarTitle: "Pinned Chats",
  };

  function getMessage(key) {
    return messages[key] || key;
  }

  const PIN_PATH_D =
    "M12 2C8.13401 2 5 5.13401 5 9C5 11.4087 6.71776 14.8163 12 22C17.2822 14.8163 19 11.4087 19 9C19 5.13401 15.866 2 12 2ZM12 11C13.1046 11 14 10.1046 14 9C14 7.89543 13.1046 7 12 7C10.8954 7 10 7.89543 10 9C10 10.1046 10.8954 11 12 11Z";
  const UNPIN_PATH_D =
    "M12 2C8.13401 2 5 5.13401 5 9C5 11.4087 6.71776 14.8163 12 22C17.2822 14.8163 19 11.4087 19 9C19 5.13401 15.866 2 12 2ZM12 11C13.1046 11 14 10.1046 14 9C14 7.89543 13.1046 7 12 7C10.8954 7 10 7.89543 10 9C10 10.1046 10.8954 11 12 11ZM4.70711 2.29289L21.7071 19.2929L20.2929 20.7071L3.29289 3.70711L4.70711 2.29289Z";
  const WAITING_PATH_D =
    "M12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2ZM12 4C16.4183 4 20 7.58172 20 12C20 16.4183 16.4183 20 12 20C7.58172 20 4 16.4183 4 12C4 7.58172 7.58172 4 12 4Z";

  const pinnedChatsSidebarID = "chatgpt-pinnedChats-1122334";
  const pinnedChatsOrderListID = "chatgpt-pinnedChats-OL-1122334";

  // 创建一个状态管理对象,用于处理绑定按钮触发的弹窗与对应会话条目的关联
  const state = {
    chatID: null,
    associatedH3Text: null,
    promiseResolve: null,
    currentPromise: null,

    setChatInfo(id, name) {
      this.chatID = id;
      this.associatedH3Text = name;
      if (this.promiseResolve) {
        this.promiseResolve({ id: this.chatID, name: this.associatedH3Text });
      }
      // oneshot: 设置完就立即重置所有状态
      this.reset();
    },

    async waitForChatInfo() {
      if (this.chatID !== null && this.associatedH3Text !== null) {
        const info = { id: this.chatID, name: this.associatedH3Text };
        this.reset(); // oneshot: 获取完就立即重置
        return info;
      }

      if (!this.currentPromise) {
        this.currentPromise = new Promise((resolve) => {
          this.promiseResolve = resolve;
        });
      }

      const result = await this.currentPromise;
      return result;
    },

    reset() {
      this.chatID = null;
      this.associatedH3Text = null;
      this.promiseResolve = null;
      this.currentPromise = null;
    },
  };

  class sidebarManager {
    constructor(db, state) {
      this.pinnedChatsDB = db;
      this.chatInfoState = state;
    }
    // 在处理点击事件时获取当前点击的聊天 ID 和对应的 H3 标题文本
    addListenEventsOnClick() {
      // 处理点击事件
      const handleClick = (event) => {
        const navElement = event.target.closest("nav");
        if (navElement) {
          const listItem = event.target.closest("li");
          const link = listItem?.querySelector("a");
          const chatId = link?.href?.split("/c/").pop();
          const associatedH3Text =
            listItem.parentElement.previousElementSibling.querySelector(
              "h3"
            ).textContent;

          this.chatInfoState.setChatInfo(chatId, associatedH3Text);
        }
      };

      // 绑定点击事件监听器
      document.addEventListener("click", handleClick, true);
    }

    // 当菜单弹窗弹出时绑定新建pin按钮事件,需要搭配 addListenEventsOnClick 获取当前点击的聊天ID和对应的H3标题文本
    addDomMutationObserver() {
      // 处理 DOM 变化
      const handleMutation = (mutations) => {
        mutations.forEach((mutation) => {
          mutation.addedNodes.forEach((node) => {
            if (
              node instanceof HTMLElement &&
              node.hasAttribute("data-radix-popper-content-wrapper") &&
              node.getAttribute("dir") === "ltr"
            ) {
              this.insertPinUnpinButton(node);
            }
          });
        });
      };
      // 创建并绑定 MutationObserver
      const observer = new MutationObserver(handleMutation);
      observer.observe(document.body, { childList: true, subtree: true });
    }

    insertPinUnpinButton(node) {
      const menu = node.querySelector('[role="menu"]');
      if (!menu) return;
      const newitem = menu.querySelector('[role="menuitem"]').cloneNode(true);

      newitem.querySelector("path").setAttribute("d", WAITING_PATH_D);
      this.#updateSubTextNodeContent(newitem, "Loading...");

      const s = menu.firstChild;

      this.chatInfoState
        .waitForChatInfo()
        .then(({ id: currentChatId, name: associatedH3Text }) => {
          if (this.pinnedChatsDB.has(currentChatId)) {
            newitem.querySelector("path").setAttribute("d", UNPIN_PATH_D);
            this.#updateSubTextNodeContent(newitem, getMessage("unpin"));
          } else {
            newitem.querySelector("path").setAttribute("d", PIN_PATH_D);
            this.#updateSubTextNodeContent(newitem, getMessage("pin"));
          }

          newitem.addEventListener("click", () => {
            if (newitem.textContent === getMessage("pin")) {
              this.pinnedChatsDB.insert(currentChatId, associatedH3Text);
              this.#moveChatToPinnedSection(currentChatId);
              newitem.querySelector("path").setAttribute("d", UNPIN_PATH_D);
              this.#updateSubTextNodeContent(newitem, getMessage("unpin"));
            } else {
              this.#moveChatOutOfPinnedSection(
                currentChatId,
                this.pinnedChatsDB.get(currentChatId)
              );
              this.pinnedChatsDB.remove(currentChatId);
              newitem.querySelector("path").setAttribute("d", PIN_PATH_D);
              this.#updateSubTextNodeContent(newitem, getMessage("pin"));
            }
            menu.remove();
          });
        });

      s.insertBefore(newitem, s.firstChild);
    }

    initPinnedChatsSidebar() {
      const sidebarSection = document.querySelector("nav").querySelector("h3")
        ?.parentElement?.parentElement?.parentElement;
      if (!sidebarSection) return;
      this.sidebarSectionTemplate = sidebarSection?.cloneNode(true);
      this.menuSectionParent = sidebarSection?.parentNode;

      const menu = document.querySelector("nav").querySelector("h3");
      const menuParent = menu?.parentElement?.parentElement?.parentElement;

      // 如果找不到目标父元素,则退出
      if (!menuParent) return;

      // 克隆菜单部分的模板,并设置其 ID 和标题
      const pinnedChatsSection = this.sidebarSectionTemplate.cloneNode(true);
      pinnedChatsSection.id = pinnedChatsSidebarID;
      pinnedChatsSection.querySelector("h3").textContent = getMessage(
        "pinnedChatsSidebarTitle"
      );

      // 将新的固定聊天区域插入到菜单容器中
      this.menuSectionParent.insertBefore(pinnedChatsSection, menuParent);

      // 获取固定聊天区域的列表容器,并克隆第一个列表项作为模板
      const pinnedChatsOl = pinnedChatsSection.querySelector("ol");

      pinnedChatsOl.innerHTML = "";
      pinnedChatsOl.id = pinnedChatsOrderListID;

      // 遍历历史固定聊天数据,生成列表项
      const pinnedChatsInfo = this.pinnedChatsDB.getAll();
      Object.keys(pinnedChatsInfo).forEach((chatId) => {
        try {
          this.#moveChatToPinnedSection(chatId);
        } catch (error) {
          console.error(`Error moving chat ${chatId} to pinned section:`, error);
          // 可以选择继续处理后续的 chatId
        }
      });
    }

    #updateSubTextNodeContent(domNode, newText) {
      // 创建 TreeWalker
      const walker = document.createTreeWalker(
        domNode, // 根节点
        NodeFilter.SHOW_TEXT, // 只筛选文本节点
        null,
        false
      );

      // 遍历文本节点
      while (walker.nextNode()) {
        const textNode = walker.currentNode;
        textNode.textContent = newText; // 修改文本内容
      }
    }

    #moveChatToPinnedSection(chatId) {
      const chatItem = document.querySelector(`a[href$='/c/${chatId}']`)
        ?.parentElement?.parentElement;

      const pinnedChatsOl = document.getElementById(pinnedChatsOrderListID);
      pinnedChatsOl.appendChild(chatItem);
    }

    #moveChatOutOfPinnedSection(chatId, associatedH3Text) {
      const chatItem = document.querySelector(`a[href$='/c/${chatId}']`)
        ?.parentElement?.parentElement;
      const h3Node = this.#findH3ByText(associatedH3Text);

      const preChatOl =
        h3Node[0].parentElement.parentElement.nextElementSibling;
      preChatOl.appendChild(chatItem);
    }

    // 大小写敏感的文本查找
    #findH3ByText(text) {
      const h3Elements = document.querySelectorAll("h3");
      const targetH3 = [];

      for (const h3 of h3Elements) {
        if (h3.textContent.trim() === text) {
          targetH3.push(h3);
        }
      }

      return targetH3;
    }
  }

  class DBService {
    /**
     * 添加一个键值对, 如果键已存在则覆盖
     * @param {string} key 键
     * @param {*} value 值
     */
    insert(key, value) {
      GM_setValue(key, value);
    }

    /**
     * 获取指定键的值
     * @param {string} key 键
     * @returns {*} 存储的值,如果键不存在则返回 undefined
     */
    get(key) {
      return GM_getValue(key, undefined);
    }

    /**
     * 检查键是否存在
     * @param {string} key 键
     * @returns {boolean} 是否存在
     */
    has(key) {
      return this.get(key) !== undefined;
    }

    /**
     * 删除指定键
     * @param {string} key 键
     */
    remove(key) {
      GM_deleteValue(key);
    }

    /**
     * 获取所有键值对
     * @returns {Object} 包含所有键值对的对象
     */
    getAll() {
      const allKeys = GM_listValues();
      const result = {};
      allKeys.forEach((key) => {
        result[key] = GM_getValue(key);
      });
      return result;
    }

    /**
     * 清空所有键值对
     */
    clear() {
      const allKeys = GM_listValues();
      allKeys.forEach((key) => {
        GM_deleteValue(key);
      });
    }
  }

  const db = new DBService();
  const manager = new sidebarManager(db, state);

  const run = () => {
    manager.initPinnedChatsSidebar();
    manager.addListenEventsOnClick();
    manager.addDomMutationObserver();
  };

  const observer = new MutationObserver(() => {
    const nav = document.querySelector("nav");
    const h3 = nav?.querySelector("h3");

    if (h3) {
      run(); 
      observer.disconnect(); // 停止观察
    }
  });

  // 开始观察文档根节点的子树变化
  observer.observe(document.body, { childList: true, subtree: true });
})();