t3chat recent models

adds buttons for recently used models to the sidebar

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

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

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

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

你需要先安裝一款使用者腳本管理器擴展,比如 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
    });
  }

})();