ChatGPT-Pin-Helper

Enable users to easily pin important conversations to the top for quick access and better organization, enhancing productivity and user experience.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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 });
})();