t3chat recent models

adds buttons for recently used models to the sidebar

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

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址