t3chat recent models

adds buttons for recently used models to the sidebar

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         t3chat recent models
// @namespace    https://t3.chat/
// @version      2025-07-28
// @description  adds buttons for recently used models to the sidebar
// @author       arturmarc
// @match        https://t3.chat/*
// @icon         https://t3.chat/favicon.ico
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  const NUM_RECENT_MODELS = 4;
  const $$ = document.querySelectorAll.bind(document);
  const $ = (selector) => {
    const el = document.querySelector(selector);
    if (!(el instanceof HTMLElement || el instanceof SVGElement)) {
      throw new Error(
        `[t3chatCustom] Element not found for selector: ${selector}`
      );
    }
    return el;
  };
  const asElementOrSvgItems = (nodeList) => [...nodeList].filter(
    (el) => el instanceof HTMLElement || el instanceof SVGElement
  );
  const clickElement = (element) => {
    if (!element) {
      console.warn("[t3chatCustom] Cannot click null/undefined element");
      return false;
    }
    try {
      element.focus();
      const keyEvent = new KeyboardEvent("keydown", {
        key: "Enter",
        code: "Enter",
        bubbles: true,
        cancelable: true
      });
      element.dispatchEvent(keyEvent);
      return true;
    } catch (error) {
      console.warn("[t3chatCustom] Click failed:", error);
      return false;
    }
  };
  function assertExists(value, message) {
    if (value === null || value === void 0) {
      console.error(message);
      throw new Error(message);
    }
  }
  async function waitForElements(selector) {
    const els = await waitForSelectorAll(selector);
    const res = [...els].filter(
      (el) => el instanceof HTMLElement
    );
    if (res.length === 0) {
      throw new Error(
        `[t3chatCustom] No elements found for selector: ${selector}`
      );
    }
    return res;
  }
  function waitForSelectorAll(selector) {
    return new Promise((resolve, reject) => {
      const immediateResult = asElementOrSvgItems($$(selector));
      if (immediateResult.length > 0) {
        return resolve(immediateResult);
      }
      const observer = new MutationObserver(() => {
        const res = asElementOrSvgItems($$(selector));
        if (res.length > 0) {
          observer.disconnect();
          clearTimeout(timeout);
          resolve(res);
        }
      });
      const timeout = setTimeout(() => {
        observer.disconnect();
        reject(
          new Error(
            `[t3chatCustom] Selector "${selector}" not found within 500ms`
          )
        );
      }, 500);
      observer.observe(document.body, {
        childList: true,
        subtree: true
      });
    });
  }
  const waitForSelector = async (selector) => {
    const elements = await waitForSelectorAll(selector);
    return elements[0];
  };
  const createModelButton = ({
    modelName,
    modelMenu,
    newChatButton
  }) => {
    const svg = modelSVGSs.get(modelName);
    if (!svg) {
      console.log(`[t3chatCustom] skipping model: ${modelName} - svg not found`);
      return null;
    }
    const svgElement = svg.cloneNode(true);
    const button = document.createElement("button");
    button.className = newChatButton.className;
    newChatButton.parentElement?.classList.add("flex", "flex-col", "gap-2");
    button.appendChild(svgElement);
    button.appendChild(document.createTextNode(modelName));
    button.addEventListener("click", () => {
      clickElement(newChatButton);
      clickElement(modelMenu);
      waitForElements(
        "div[data-radix-menu-content] div[data-radix-collection-item]"
      ).then((items) => {
        const foundItem = items.find(
          (item) => item.querySelector("span")?.textContent === modelName
        );
        if (foundItem) {
          clickElement(foundItem);
        }
      });
    });
    return button;
  };
  const STORAGE_KEY = "t3chatCustom:recentModels";
  const modelSVGSs = /* @__PURE__ */ new Map();
  const getAllModelSVGSs = (modelMenuItems) => {
    modelMenuItems.forEach((item) => {
      const svg = item.querySelector("svg");
      if (svg) {
        modelSVGSs.set(
          (item.querySelector("span")?.textContent || "").trim(),
          svg
        );
      }
    });
  };
  const renderRecentModels = async () => {
    let extraButtonsContainer = $$("[data-t3chat-custom-extra-buttons]")[0];
    const startingHtml = `
      <div data-sidebar="group-label" class="flex h-8 shrink-0 select-none items-center rounded-md text-xs font-medium outline-none ring-sidebar-ring transition-[margin,opa] duration-200 ease-snappy focus-visible:ring-2 [&amp;&gt;svg]:size-4 [&amp;&gt;svg]:shrink-0 group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 px-3.5 text-color-heading pt-4">
        <span>Recent models</span>
      </div>
    `;
    if (extraButtonsContainer) {
      extraButtonsContainer.innerHTML = startingHtml;
    }
    const links = await waitForElements("a[href='/']");
    const newChatButton = links.find(
      (link) => (link.textContent ?? "").trim().toLocaleLowerCase().includes("new chat")
    );
    if (!newChatButton) {
      console.log("[t3chatCustom] new chat button not found");
      return;
    }
    const modelMenu = (await waitForSelector("button[aria-haspopup] > svg.lucide-chevron-down")).parentElement;
    assertExists(modelMenu, "modelMenu not found");
    let currentModelName = modelMenu.textContent;
    const storageItem = localStorage.getItem(STORAGE_KEY);
    let recentModels = [];
    if (!storageItem) {
      clickElement(modelMenu);
      const modelMenuItems = await waitForSelectorAll(
        "div[data-radix-menu-content] div[data-radix-collection-item]"
      );
      getAllModelSVGSs(modelMenuItems);
      const defaultRecentModels = [
        currentModelName || "",
        // take 5 items from the model menu items as default recent models
        ...[...modelMenuItems].map((item) => item.querySelector("span")?.textContent || "").filter((item) => item !== currentModelName).slice(0, 3)
      ];
      clickElement(modelMenu);
      localStorage.setItem(STORAGE_KEY, JSON.stringify(defaultRecentModels));
      recentModels = defaultRecentModels;
    } else {
      recentModels = JSON.parse(storageItem);
    }
    if (!extraButtonsContainer) {
      const newExtraButtonsContainer = document.createElement("div");
      newExtraButtonsContainer.className = "flex flex-col gap-2";
      newExtraButtonsContainer.setAttribute(
        "data-t3chat-custom-extra-buttons",
        ""
      );
      newChatButton.parentElement?.insertAdjacentElement(
        "afterend",
        newExtraButtonsContainer
      );
      extraButtonsContainer = newExtraButtonsContainer;
      extraButtonsContainer.innerHTML = startingHtml;
    }
    const recentModelsToRender = recentModels.slice(0, NUM_RECENT_MODELS);
    if (!recentModelsToRender.every((model) => modelSVGSs.has(model))) {
      clickElement(modelMenu);
      try {
        const modelMenuItems = await waitForSelectorAll(
          "div[data-radix-menu-content] div[data-radix-collection-item]"
        );
        getAllModelSVGSs(modelMenuItems);
        clickElement(modelMenu);
      } catch (error) {
        console.log("[t3chatCustom] error getting model menu items", error);
      }
    }
    recentModelsToRender.forEach((model) => {
      if (!modelSVGSs.has(model)) {
        console.log(
          "[t3chatCustom] skipping - model svg not found for model",
          model
        );
      }
    });
    recentModels.filter((model) => modelSVGSs.has(model)).slice(0, NUM_RECENT_MODELS).forEach((model) => {
      const button = createModelButton({
        modelName: model,
        modelMenu,
        newChatButton
      });
      if (button) {
        extraButtonsContainer.appendChild(button);
      }
    });
  };
  waitForSelector("button[aria-haspopup] > svg.lucide-chevron-down").then(
    async (dropdownSvg) => {
      const modelMenu = dropdownSvg.parentElement;
      assertExists(modelMenu, "modelMenu not found");
      renderRecentModels();
      let sidebarRendered = $$("div[data-sidebar]").length > 0;
      let extraButtonsContainerRendered = $$("[data-t3chat-custom-extra-buttons]").length > 0;
      let modelMenuRendered = true;
      const mutationObserver = new MutationObserver(
        (mutations) => {
          const sidebarRenderedAfterMutation = $$("div[data-sidebar]").length > 0;
          if (sidebarRenderedAfterMutation && !sidebarRendered) {
            renderRecentModels();
          }
          sidebarRendered = sidebarRenderedAfterMutation;
          const extraButtonsContainerRenderedAfterMutation = $$("[data-t3chat-custom-extra-buttons]").length > 0;
          if (!extraButtonsContainerRenderedAfterMutation && extraButtonsContainerRendered) {
            renderRecentModels();
          }
          extraButtonsContainerRendered = extraButtonsContainerRenderedAfterMutation;
          const allRemovedNodes = mutations.flatMap((mutation) => [
            ...mutation.removedNodes
          ]);
          const modelMenuRemoved = allRemovedNodes.some(
            (node) => node instanceof HTMLElement && node.querySelector(
              "button[aria-haspopup] > svg.lucide-chevron-down"
            )
          );
          if (modelMenuRemoved) {
            modelMenuObserver?.disconnect();
          }
          const modelMenuRenderedAfterMutation = $$("button[aria-haspopup] > svg.lucide-chevron-down").length > 0;
          if (!modelMenuRenderedAfterMutation && modelMenuRendered) {
            modelMenuObserver?.disconnect();
          }
          if (modelMenuRenderedAfterMutation && (!modelMenuRendered || modelMenuRemoved)) {
            observeModelMenu(
              $$("button[aria-haspopup] > svg.lucide-chevron-down")[0].parentElement
            );
          }
          modelMenuRendered = modelMenuRenderedAfterMutation;
        }
      );
      mutationObserver.observe(document.body, {
        childList: true,
        subtree: true
      });
      observeModelMenu(modelMenu);
    }
  );
  let modelMenuObserver = null;
  function observeModelMenu(modelMenu) {
    let currentModelName = (modelMenu.textContent ?? "").replace(
      /(\w)\(/g,
      "$1 ("
      // text before the bracket is missing a space
    );
    console.log("observing model menu again, currentModelName", currentModelName);
    const setRecentModels = () => {
      const recentModels = JSON.parse(
        localStorage.getItem(STORAGE_KEY) ?? "[]"
      );
      const newRecentModels = [
        currentModelName,
        ...recentModels.filter((model) => model !== currentModelName).slice(0, 8)
      ];
      localStorage.setItem(STORAGE_KEY, JSON.stringify(newRecentModels));
    };
    setRecentModels();
    modelMenuObserver = new MutationObserver(() => {
      if (modelMenu.textContent !== currentModelName) {
        currentModelName = (modelMenu.textContent ?? "").replace(
          /(\w)\(/g,
          "$1 ("
          // text before the bracket is missing a space
        );
        setRecentModels();
        renderRecentModels();
      }
    });
    modelMenuObserver.observe(modelMenu, {
      childList: true,
      subtree: true,
      characterData: true
    });
  }

})();