您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Provides GitHub repositories as additional context.
当前为
// ==UserScript== // @name Repo Gist // @namespace https://github.com/prudentbird // @version 0.0.3 // @description Provides GitHub repositories as additional context. // @author Prudent Bird // @match https://t3.chat/* // @match https://beta.t3.chat/* // @icon https://www.google.com/s2/favicons?sz=64&domain=t3.chat // @grant GM_getValue // @grant GM_setValue // @grant unsafeWindow // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @run-at document-idle // @connect * // @license MIT // ==/UserScript== (function () { "use strict"; // --- Configuration and State --- let debugMode = false; const DB_VERSION = 1; const SCRIPT_VERSION = "0.1.0"; const SCRIPT_NAME = "Repo Gist"; const DB_NAME = "t3chat_repogist_db"; const STORE_NAME = "repogist_states"; const githubSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-github-icon lucide-github"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/></svg>`; const GM_STORAGE_KEYS = { DEBUG: "debug", API_URL: "apiUrl", GEMINI_API_KEY: "geminiApiKey", }; // Utility function to handle GM_getValue safely const safeGMGetValue = (key, defaultValue = null) => { try { const result = GM_getValue(key, defaultValue); // Check if it's a promise if (result && typeof result.then === 'function') { return result; } else { // Return a resolved promise for consistency return Promise.resolve(result); } } catch (error) { Logger.error(`Error getting GM value for ${key}:`, error); return Promise.resolve(defaultValue); } }; // Utility function to handle GM_setValue safely const safeGMSetValue = (key, value) => { try { const result = GM_setValue(key, value); // Check if it's a promise if (result && typeof result.then === 'function') { return result; } else { // Return a resolved promise for consistency return Promise.resolve(result); } } catch (error) { Logger.error(`Error setting GM value for ${key}:`, error); return Promise.reject(error); } }; // --- Utility: Logger --- const Logger = { log: (...args) => { if (debugMode) console.log(`[${SCRIPT_NAME}]`, ...args); }, error: (...args) => console.error(`[${SCRIPT_NAME}]`, ...args), }; const getChatId = () => { const currentUrl = window.location.href; const match = currentUrl.match(/\/chat\/([^/?#]+)/); const chatId = match ? match[1] : null; if (!chatId) { Logger.log("getChatId: No chat ID found in URL", currentUrl); } return chatId; }; let apiUrl = null; let geminiApiKey = null; const ApiKeyModal = { _isShown: false, _isValidURL: (url) => { try { new URL(url); return true; } catch (e) { return false; } }, show: () => { if (document.getElementById(UI_IDS.apiKeyModal) || ApiKeyModal._isShown) return; ApiKeyModal._isShown = true; const wrapper = document.createElement("div"); wrapper.id = UI_IDS.apiKeyModal; wrapper.innerHTML = ` <div id="${UI_IDS.apiKeyModalContent}"> <div id="${UI_IDS.apiKeyModalHeader}"> <div><!-- Icon container --> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cog-icon lucide-cog"><path d="M12 20a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z"/><path d="M12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"/><path d="M12 2v2"/><path d="M12 22v-2"/><path d="m17 20.66-1-1.73"/><path d="M11 10.27 7 3.34"/><path d="m20.66 17-1.73-1"/><path d="m3.34 7 1.73 1"/><path d="M14 12h8"/><path d="M2 12h2"/><path d="m20.66 7-1.73 1"/><path d="m3.34 17 1.73-1"/><path d="m17 3.34-1 1.73"/><path d="m11 13.73-4 6.93"/></svg> </div> <div>Enter API Configuration</div><!-- Title --> <button id="${UI_IDS.apiKeyModalCloseButton}"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button> </div> <div id="${UI_IDS.apiKeyModalInputContainer}"> <label for="${UI_IDS.apiKeyModalInput}">RepoGist API URL</label> <input id="${UI_IDS.apiKeyModalInput}" type="text" placeholder="https://api.repogist.com/ingest" /> <button id="${UI_IDS.apiKeyModalClearButton}" aria-label="Clear input"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2-icon lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg></button> </div> <div id="${UI_IDS.apiKeyModalInputContainer}"> <label for="${UI_IDS.geminiApiKeyInput}">Gemini API Key</label> <input id="${UI_IDS.geminiApiKeyInput}" type="text" placeholder="Enter your Gemini API key" /> <button id="${UI_IDS.geminiApiKeyClearButton}" aria-label="Clear input"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2-icon lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg></button> </div> <button id="${UI_IDS.apiKeyModalSaveButton}">Save</button> </div>`; document.body.appendChild(wrapper); const input = wrapper.querySelector(`#${UI_IDS.apiKeyModalInput}`); if (input) { input.focus(); } ApiKeyModal._attachEventListeners(wrapper); }, _attachEventListeners: (modalElement) => { const urlInput = modalElement.querySelector( `#${UI_IDS.apiKeyModalInput}` ); const geminiKeyInput = modalElement.querySelector( `#${UI_IDS.geminiApiKeyInput}` ); const saveButton = modalElement.querySelector( `#${UI_IDS.apiKeyModalSaveButton}` ); const closeButton = modalElement.querySelector( `#${UI_IDS.apiKeyModalCloseButton}` ); const clearButton = modalElement.querySelector( `#${UI_IDS.apiKeyModalClearButton}` ); const geminiClearButton = modalElement.querySelector( `#${UI_IDS.geminiApiKeyClearButton}` ); modalElement.addEventListener("click", (e) => { if (e.target === modalElement) { ApiKeyModal._isShown = false; modalElement.remove(); } }); const updateClearButtonVisibility = (input, button) => { if (button) { button.style.display = input.value ? "flex" : "none"; } }; updateClearButtonVisibility(urlInput, clearButton); updateClearButtonVisibility(geminiKeyInput, geminiClearButton); if (clearButton) { clearButton.addEventListener("click", () => { urlInput.value = ""; urlInput.focus(); updateClearButtonVisibility(urlInput, clearButton); }); } if (geminiClearButton) { geminiClearButton.addEventListener("click", () => { geminiKeyInput.value = ""; geminiKeyInput.focus(); updateClearButtonVisibility(geminiKeyInput, geminiClearButton); }); } urlInput.addEventListener("input", () => updateClearButtonVisibility(urlInput, clearButton) ); geminiKeyInput.addEventListener("input", () => updateClearButtonVisibility(geminiKeyInput, geminiClearButton) ); const handleSave = () => { const url = urlInput.value.trim(); const geminiKey = geminiKeyInput.value.trim(); if (url && !ApiKeyModal._isValidURL(url)) { alert("Invalid RepoGist API URL"); return; } if (url) { safeGMSetValue(GM_STORAGE_KEYS.API_URL, url) .then(() => { apiUrl = url; if (geminiKey) { return safeGMSetValue(GM_STORAGE_KEYS.GEMINI_API_KEY, geminiKey); } }) .then(() => { if (geminiKey) { geminiApiKey = geminiKey; } ApiKeyModal._isShown = false; modalElement.remove(); }) .catch((err) => { Logger.error("Failed to save API configuration:", err); }); } else { ApiKeyModal._isShown = false; modalElement.remove(); } }; saveButton.addEventListener("click", handleSave); urlInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { handleSave(); } }); geminiKeyInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { handleSave(); } }); closeButton.addEventListener("click", () => { ApiKeyModal._isShown = false; modalElement.remove(); }); }, }; const getRepoNamefromURL = (url) => { if (typeof url !== "string") { Logger.log("getRepoNamefromURL: Invalid URL type", typeof url); return null; } url = url.replace(/\/+$/, ""); let match = url.match(/[:\/]([^\/]+)\.git$/); if (match) { Logger.log("getRepoNamefromURL: Found .git URL match", match[1]); return match[1]; } match = url.match(/\/([^\/]+)(?:\/tree\/[^\/]+)?$/); if (match) { Logger.log("getRepoNamefromURL: Found standard URL match", match[1]); return match[1]; } Logger.log("getRepoNamefromURL: No match found for URL", url); return null; }; // Updated selector to match the exact HTML structure provided const selectors = { messageActions: "div.ml-\\[-7px\\].flex.items-center.gap-1", searchButton: 'button#search-toggle[aria-label="Enable search"]', }; const UI_IDS = { apiKeyModal: "api-key-modal", apiKeyModalContent: "api-key-modal-content", apiKeyModalHeader: "api-key-modal-header", apiKeyModalInput: "api-key-modal-input", apiKeyModalInputContainer: "api-key-modal-input-container", apiKeyModalSaveButton: "api-key-modal-save-button", apiKeyModalCloseButton: "api-key-modal-close-button", apiKeyModalClearButton: "api-key-modal-clear-button", geminiApiKeyInput: "gemini-api-key-input", geminiApiKeyClearButton: "gemini-api-key-clear-button", importButton: "import-button", searchToggle: "search-toggle", repoUrlModal: "repo-url-modal", repoUrlModalContent: "repo-url-modal-content", repoUrlModalHeader: "repo-url-modal-header", repoUrlModalDescription: "repo-url-modal-description", repoUrlModalInput: "repo-url-modal-input", repoUrlModalSaveButton: "repo-url-modal-save-button", repoUrlModalCloseButton: "repo-url-modal-close-button", repoUrlModalClearButton: "repo-url-modal-clear-button", repoUrlModalInputContainer: "repo-url-modal-input-container", styleElement: "repo-gist-style", }; const CSS_CLASSES = { // Updated button classes to match the attach button style exactly button: "inline-flex items-center justify-center whitespace-nowrap font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 disabled:cursor-not-allowed hover:bg-muted/40 hover:text-foreground disabled:hover:bg-transparent disabled:hover:text-foreground/50 text-xs cursor-pointer -mb-1.5 h-auto gap-2 rounded-full border border-solid border-secondary-foreground/10 px-2 py-1.5 pr-2.5 text-muted-foreground max-sm:p-2", importButtonLoading: "loading", importButtonOn: "on", }; const StyleManager = { injectGlobalStyles: () => { if (document.getElementById(UI_IDS.styleElement)) return; const styleEl = document.createElement("style"); styleEl.id = UI_IDS.styleElement; styleEl.textContent = ` /* Button toggle animation */ #${UI_IDS.importButton} { position: relative; overflow: hidden; transition: color 0.3s ease; } #${UI_IDS.importButton}::before { content: ''; position: absolute; inset: 0; background-color: rgba(219,39,119,0.15); transform: scaleX(0); transform-origin: left; transition: transform 0.3s ease; z-index:-1; } #${UI_IDS.importButton}.${CSS_CLASSES.importButtonOn}::before { transform: scaleX(1); } #${UI_IDS.importButton} svg { transition: transform 0.3s ease; } #${UI_IDS.importButton}.${CSS_CLASSES.importButtonOn} svg { transform: rotate(360deg); } /* Loading state */ #${UI_IDS.importButton}.${CSS_CLASSES.importButtonLoading} { opacity: 0.6; position: relative; } #${UI_IDS.importButton}.${CSS_CLASSES.importButtonLoading}::after { content: ''; position: absolute; top:50%; left:50%; width:12px; height:12px; margin:-6px 0 0 -6px; border:2px solid currentColor; border-radius:50%; border-top-color:transparent; animation:spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } /* Repo URL Modal Styles */ #${UI_IDS.repoUrlModal} { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 9999; } #${UI_IDS.repoUrlModalContent} { background: #1c1c1e; padding: 24px; border-radius: 12px; width: 500px; max-width: 95vw; box-sizing: border-box; box-shadow: 0 8px 24px rgba(0,0,0,0.3); } @media (max-width: 600px) { #${UI_IDS.repoUrlModalContent} { width: 95vw; padding: 16px; } } #${UI_IDS.repoUrlModalHeader} { display: flex; align-items: center; margin-bottom: 20px; position: relative; } #${UI_IDS.repoUrlModalHeader} > div:first-child { /* Icon container */ color: #c62a88; margin-right: 12px; } #${UI_IDS.repoUrlModalHeader} > div:last-child { /* Title container */ font-size: 22px; font-weight: 600; color: #fff; } #${UI_IDS.repoUrlModalCloseButton} { position: absolute; top: 0; right: 0; background: none; border: none; cursor: pointer; color: #fff; font-size: 24px; transition: color 0.3s ease; } #${UI_IDS.repoUrlModalDescription} { color: #999; font-size: 14px; margin-bottom: 16px; } #${UI_IDS.repoUrlModalInputContainer} { position: relative; width: 100%; margin-bottom: 16px; } #${UI_IDS.repoUrlModalInput} { width: 100%; padding: 12px 36px 12px 12px; box-sizing: border-box; background: #2a2a2c; color: #fff; border: 1px solid #333; border-radius: 6px; outline: none; font-size: 14px; } #${UI_IDS.repoUrlModalClearButton} { position: absolute; top: 50%; right: 10px; transform: translateY(-50%); width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; background: none; border: none; cursor: pointer; font-size: 16px; font-weight: 500; color: #aaa; transition: color 0.2s ease; padding: 0; } #${UI_IDS.repoUrlModalClearButton}:hover { color: #c62a88; } #${UI_IDS.repoUrlModalSaveButton} { width: 100%; padding: 12px; background: #a02553; border: none; border-radius: 6px; color: white; cursor: pointer; font-size: 15px; font-weight: 500; transition: all 0.2s ease; } #${UI_IDS.repoUrlModalSaveButton}:hover { background: #c62a88; } /* API Key Modal Styles */ #${UI_IDS.apiKeyModal} { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 9999; } #${UI_IDS.apiKeyModalContent} { background: #1c1c1e; padding: 24px; border-radius: 12px; width: 500px; max-width: 95vw; box-sizing: border-box; box-shadow: 0 8px 24px rgba(0,0,0,0.3); } @media (max-width: 600px) { #${UI_IDS.apiKeyModalContent} { width: 95vw; padding: 16px; } } #${UI_IDS.apiKeyModalHeader} { display: flex; align-items: center; margin-bottom: 20px; position: relative; } #${UI_IDS.apiKeyModalHeader} > div:first-child { /* Icon container */ color: #c62a88; margin-right: 12px; } #${UI_IDS.apiKeyModalHeader} > div:last-child { /* Title container */ font-size: 22px; font-weight: 600; color: #fff; } #${UI_IDS.apiKeyModalCloseButton} { position: absolute; top: 0; right: 0; background: none; border: none; cursor: pointer; color: #fff; font-size: 24px; transition: color 0.3s ease; } #${UI_IDS.apiKeyModalInputContainer} { position: relative; width: 100%; margin-bottom: 16px; } #${UI_IDS.apiKeyModalInputContainer} label { display: block; color: #999; font-size: 14px; margin-bottom: 8px; } #${UI_IDS.apiKeyModalInput} { width: 100%; padding: 12px 36px 12px 12px; box-sizing: border-box; background: #2a2a2c; color: #fff; border: 1px solid #333; border-radius: 6px; outline: none; font-size: 14px; } #${UI_IDS.apiKeyModalInput}:focus { border-color: #c62a88; } #${UI_IDS.geminiApiKeyInput} { width: 100%; padding: 12px 36px 12px 12px; box-sizing: border-box; background: #2a2a2c; color: #fff; border: 1px solid #333; border-radius: 6px; outline: none; font-size: 14px; } #${UI_IDS.geminiApiKeyInput}:focus { border-color: #c62a88; } #${UI_IDS.apiKeyModalClearButton}, #${UI_IDS.geminiApiKeyClearButton} { position: absolute; top: 68%; right: 10px; transform: translateY(-50%); width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; background: none; border: none; cursor: pointer; font-size: 16px; font-weight: 500; color: #aaa; transition: color 0.2s ease; padding: 0; } #${UI_IDS.apiKeyModalClearButton}:hover, #${UI_IDS.geminiApiKeyClearButton}:hover { color: #c62a88; } #${UI_IDS.apiKeyModalSaveButton} { width: 100%; padding: 12px; background: #a02553; border: none; border-radius: 6px; color: white; cursor: pointer; font-size: 15px; font-weight: 500; transition: all 0.2s ease; } #${UI_IDS.apiKeyModalSaveButton}:hover { background: #c62a88; }`; document.head.appendChild(styleEl); }, }; const UIManager = { importButton: null, _createImportButton: () => { return new Promise((resolve) => { const chatId = getChatId(); IngestDBManager.getState(chatId) .then((state) => { const button = document.createElement("button"); button.type = "button"; button.id = UI_IDS.importButton; button.className = CSS_CLASSES.button; button.setAttribute("data-state", "closed"); if (state && state.repoUrl) { const repoName = getRepoNamefromURL(state.repoUrl) ?.split("/") ?.pop() ?.slice(0, 10) ?.replace(/^./, (c) => c.toUpperCase()) || "Repo"; button.innerHTML = `<div class="flex gap-1">${githubSVG}<span class="max-sm:hidden sm:ml-0.5">${repoName}</span></div>`; button.setAttribute("aria-label", "Repository imported"); button.dataset.mode = "on"; button.classList.add(CSS_CLASSES.importButtonOn); } else { button.innerHTML = `<div class="flex gap-1">${githubSVG}<span class="max-sm:hidden sm:ml-0.5">Import</span></div>`; button.setAttribute("aria-label", "Import repository"); button.dataset.mode = "off"; } button.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); if (!apiUrl || !geminiApiKey) { ApiKeyModal.show(); return; } const chatId = getChatId(); if (!chatId) { alert("No chat ID found, can't import repo"); Logger.log("No chat ID found, skipping import button click"); return; } if (RepoUrlModal._isShown) { RepoUrlModal._isShown = false; return; } RepoUrlModal.show(); }); resolve(button); }) .catch((err) => { Logger.error("Failed to create import button:", err); resolve(null); }); }); }, injectImportButton: () => { return new Promise((resolve) => { // Find the exact container with model selector, thinking level, and attach buttons const messageActionsContainer = document.querySelector(selectors.messageActions); if (!messageActionsContainer) { Logger.log("Message actions container not found with selector:", selectors.messageActions); resolve(false); return; } // Check if button already exists if (messageActionsContainer.querySelector(`#${UI_IDS.importButton}`)) { Logger.log("Import button already exists"); resolve(true); return; } UIManager._createImportButton() .then((button) => { if (!button) { Logger.error("Import button creation failed."); resolve(false); return; } UIManager.importButton = button; // Insert the button as the last child (after attach button) messageActionsContainer.appendChild(button); Logger.log("Import button injected successfully in message actions container"); resolve(true); }) .catch((err) => { Logger.error("Failed to inject import button:", err); resolve(false); }); }); }, }; const RepoUrlModal = { _isShown: false, _isValidRepoUrl: (url) => { const patterns = [ /^git@[^:]+:.+\.git$/, /^https:\/\/[^/]+\/.+\.git$/, /^https:\/\/github\.com\/[^/]+\/[^/]+(\/tree\/[^/]+)?$/, /^https:\/\/gitlab\.com\/[^/]+\/[^/]+(\/-\/tree\/[^/]+)?$/, ]; return patterns.some((pattern) => pattern.test(url)); }, show: () => { return new Promise((resolve) => { const chatId = getChatId(); IngestDBManager.getState(chatId) .then((state) => { if ( document.getElementById(UI_IDS.repoUrlModal) || RepoUrlModal._isShown ) { resolve(); return; } RepoUrlModal._isShown = true; const wrapper = document.createElement("div"); wrapper.id = UI_IDS.repoUrlModal; wrapper.innerHTML = ` <div id="${UI_IDS.repoUrlModalContent}"> <div id="${UI_IDS.repoUrlModalHeader}"> <div><!-- Icon container --> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-folder-git2-icon lucide-folder-git-2"><path d="M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v5"/><circle cx="13" cy="12" r="2"/><path d="M18 19c-2.8 0-5-2.2-5-5v8"/><circle cx="20" cy="19" r="2"/></svg> </div> <div>Enter Repo URL</div><!-- Title --> <button id="${UI_IDS.repoUrlModalCloseButton}"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button> </div> <div id="${UI_IDS.repoUrlModalDescription}">Enter the URL of the GitHub repository you want to import.</div> <div id="${UI_IDS.repoUrlModalInputContainer}"> <input id="${UI_IDS.repoUrlModalInput}" type="text" placeholder="https://github.com/username/repo" /> <button id="${UI_IDS.repoUrlModalClearButton}" aria-label="Clear input"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2-icon lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg></button> </div> <button id="${UI_IDS.repoUrlModalSaveButton}">Import</button> </div>`; document.body.appendChild(wrapper); const input = wrapper.querySelector(`#${UI_IDS.repoUrlModalInput}`); if (input) { input.focus(); input.value = (state && state.repoUrl) || ""; } RepoUrlModal._attachEventListeners(wrapper); resolve(); }) .catch((err) => { Logger.error("Failed to show repo URL modal:", err); resolve(); }); }); }, _attachEventListeners: (modalElement) => { const urlInput = modalElement.querySelector( `#${UI_IDS.repoUrlModalInput}` ); const saveButton = modalElement.querySelector( `#${UI_IDS.repoUrlModalSaveButton}` ); const closeButton = modalElement.querySelector( `#${UI_IDS.repoUrlModalCloseButton}` ); const clearButton = modalElement.querySelector( `#${UI_IDS.repoUrlModalClearButton}` ); modalElement.addEventListener("click", (e) => { if (e.target === modalElement) { const urlInput = modalElement.querySelector( `#${UI_IDS.repoUrlModalInput}` ); if (urlInput && !urlInput.value) { const importButton = document.getElementById(UI_IDS.importButton); if (importButton) { importButton.classList.remove(CSS_CLASSES.importButtonOn); importButton.setAttribute("aria-label", "Import repository"); importButton.innerHTML = `<div class="flex gap-1">${githubSVG}<span class="max-sm:hidden sm:ml-0.5">Import</span></div>`; importButton.dataset.mode = "off"; } IngestDBManager.deleteState(getChatId()); } RepoUrlModal._isShown = false; modalElement.remove(); } }); const updateClearButtonVisibility = () => { if (clearButton) { clearButton.style.display = urlInput.value ? "flex" : "none"; } }; updateClearButtonVisibility(); if (clearButton) { clearButton.addEventListener("click", () => { urlInput.value = ""; urlInput.focus(); updateClearButtonVisibility(); }); } urlInput.addEventListener("input", updateClearButtonVisibility); const handleSave = () => { const url = urlInput.value.trim(); if (url) { if (!RepoUrlModal._isValidRepoUrl(url)) { alert("Please enter a valid git repository URL."); return; } RepoUrlModal._isShown = false; modalElement.remove(); const importButton = document.getElementById(UI_IDS.importButton); if (importButton) { importButton.classList.add(CSS_CLASSES.importButtonLoading); importButton.setAttribute("aria-label", "Importing repository..."); importButton.disabled = true; } new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "POST", url: apiUrl, headers: { "Content-Type": "application/json", }, data: JSON.stringify({ url, }), onload: (res) => { if (res.status < 200 || res.status >= 300) { reject( new Error(`API Error (${res.status}): ${res.responseText}`) ); return; } try { const data = JSON.parse(res.responseText); if ( !data || typeof data !== "object" || !data.data || !data.data.content || !data.data.normalized || !data.data.tree || !data.data.index ) { reject(new Error("Invalid response data from API")); return; } IngestDBManager.saveState({ chatId: getChatId(), repoUrl: url, repoTree: data.data.tree, repoIndex: data.data.index, repoContent: data.data.content, repoNormalizedContent: data.data.normalized, }) .then(() => { if (importButton) { const repoName = getRepoNamefromURL(url) ?.split("/") ?.pop() ?.slice(0, 10) ?.replace(/^./, (c) => c.toUpperCase()) || "Repo"; importButton.classList.add(CSS_CLASSES.importButtonOn); importButton.setAttribute("aria-label", "Repository imported"); importButton.dataset.mode = "on"; importButton.innerHTML = `<div class="flex gap-1">${githubSVG}<span class="max-sm:hidden sm:ml-0.5">${repoName}</span></div>`; } resolve(); }) .catch((err) => { reject(err); }); } catch (err) { reject(err); } }, onerror: (err) => { reject(err); }, ontimeout: () => { reject(new Error("Request timed out")); }, timeout: 30000, }); }) .catch((err) => { if (importButton) { importButton.classList.remove(CSS_CLASSES.importButtonLoading); importButton.classList.remove(CSS_CLASSES.importButtonOn); importButton.setAttribute("aria-label", "Import repository"); importButton.innerHTML = `<div class="flex gap-1">${githubSVG}<span class="max-sm:hidden sm:ml-0.5">Import</span></div>`; importButton.dataset.mode = "off"; importButton.disabled = false; } Logger.error("Error during repo import:", err); alert(`Failed to import repository: ${err.message}`); }) .finally(() => { if (importButton) { importButton.classList.remove(CSS_CLASSES.importButtonLoading); importButton.disabled = false; } }); } else { alert("Repo URL cannot be empty"); } }; saveButton.addEventListener("click", handleSave); urlInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { handleSave(); } }); closeButton.addEventListener("click", () => { const urlInput = modalElement.querySelector( `#${UI_IDS.repoUrlModalInput}` ); if (urlInput && !urlInput.value) { const importButton = document.getElementById(UI_IDS.importButton); if (importButton) { importButton.classList.remove(CSS_CLASSES.importButtonOn); importButton.setAttribute("aria-label", "Import repository"); importButton.innerHTML = `<div class="flex gap-1">${githubSVG}<span class="max-sm:hidden sm:ml-0.5">Import</span></div>`; importButton.dataset.mode = "off"; } IngestDBManager.deleteState(getChatId()); } RepoUrlModal._isShown = false; modalElement.remove(); }); }, }; const getAllFileNames = (index) => { Logger.log( "getAllFileNames: Processing index with", (index && index.length) || 0, "files" ); return index.map((file, idx) => ({ fileName: file.fileName, index: idx, })); }; const getFileContents = (index, indices) => { Logger.log("getFileContents: Requesting contents for indices", indices); if (!index || !Array.isArray(indices)) { Logger.log("getFileContents: Invalid input", { index: !!index, indices: !!indices, }); return []; } const contents = indices .map((idx) => { const file = index[idx]; if (file) { return { fileName: file.fileName, content: file.fileContent, }; } else { return null; } }) .filter(function (item) { return item !== null; }); Logger.log( "getFileContents: Retrieved contents for", contents.length, "files" ); return contents; }; const getRepoTree = () => { return new Promise((resolve) => { const chatId = getChatId(); IngestDBManager.getState(chatId) .then((state) => { if (!state || !state.repoTree) { Logger.log("getRepoTree: No repo tree found in state"); resolve(null); return; } Logger.log("getRepoTree: Retrieved repo tree"); resolve(state.repoTree); }) .catch((err) => { Logger.error("Failed to get repo tree:", err); resolve(null); }); }); }; // Simplified generateRelevantContext function const generateRelevantContext = (query) => new Promise((resolve) => { const chatId = getChatId(); IngestDBManager.getState(chatId).then((state) => { if (!state?.repoIndex) return resolve(null); const idx = state.repoIndex; const fileList = getAllFileNames(idx); const prompt = `Given the following list of files in a repository and a user query, return the indices of the most relevant files that would help answer the query. Only return the indices of files that are directly relevant. Files in repository: ${JSON.stringify(fileList, null, 2)} User query: ${query} Return the response in this exact JSON format: { "relevantIndices": [array of indices] }`; GM_xmlhttpRequest({ method: "POST", url: `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${geminiApiKey}`, headers: { "Content-Type": "application/json" }, data: JSON.stringify({ contents: [ { parts: [{ text: prompt }], }, ], generationConfig: { responseMimeType: "application/json", responseSchema: { type: "OBJECT", properties: { relevantIndices: { type: "ARRAY", items: { type: "NUMBER" }, }, }, propertyOrdering: ["relevantIndices"], }, }, }), onload: (r) => { if (r.status < 200 || r.status >= 300) { Logger.error("Gemini error", r); return resolve(null); } let relevantIndices; try { relevantIndices = JSON.parse( JSON.parse(r.responseText).candidates[0].content.parts[0].text ).relevantIndices; } catch (e) { Logger.error("Parse error", e); return resolve(null); } if (!Array.isArray(relevantIndices)) { Logger.error("Invalid relevantIndices format", relevantIndices); return resolve(null); } const files = getFileContents(idx, relevantIndices); if (!files.length) return resolve(null); const context = files .map((f) => `File: ${f.fileName}\nContent:\n${f.content}\n`) .join("\n"); resolve(context); }, onerror: (e) => { Logger.error("Gemini request failed", e); resolve(null); }, }); }); }); // Fixed FetchInterceptor to prevent interference with pasting const FetchInterceptor = { originalFetch: null, isIntercepting: false, init: () => { try { if (typeof unsafeWindow === "undefined") { Logger.error("FetchInterceptor: unsafeWindow is not available"); return; } const w = unsafeWindow; w.t3ChatIngest = w.t3ChatIngest || { needIngest: false }; const originalFetch = w.fetch; FetchInterceptor.originalFetch = originalFetch; w.fetch = (input, initOptions = {}) => { // Early return for non-relevant requests const url = typeof input === "string" ? input : input?.url; if (!url || !url.includes("/api/chat") || url.includes("/api/chat/resume") || initOptions?.method !== "POST" || FetchInterceptor.isIntercepting) { return originalFetch.call(w, input, initOptions); } const chatId = getChatId(); if (!chatId) { return originalFetch.call(w, input, initOptions); } // Set intercepting flag immediately FetchInterceptor.isIntercepting = true; return IngestDBManager.getState(chatId) .then((state) => { if (!state || !state.repoUrl) { Logger.log("FetchInterceptor: No repo URL in state, passing through"); return originalFetch.call(w, input, initOptions); } let data; try { data = JSON.parse(initOptions.body || "{}"); } catch (error) { Logger.error("FetchInterceptor: Failed to parse request body", error); return originalFetch.call(w, input, initOptions); } if (!Array.isArray(data.messages)) { Logger.log("FetchInterceptor: No messages array in request"); return originalFetch.call(w, input, initOptions); } const messages = data.messages; const lastIdx = messages.length - 1; if (lastIdx < 0 || !messages[lastIdx] || messages[lastIdx].role !== "user") { Logger.log("FetchInterceptor: No user message found"); return originalFetch.call(w, input, initOptions); } const originalPrompt = messages[lastIdx].content; if (typeof originalPrompt !== "string") { Logger.log("FetchInterceptor: Invalid prompt type", typeof originalPrompt); return originalFetch.call(w, input, initOptions); } Logger.log("FetchInterceptor: Intercepting fetch for ingest enhancement"); return Promise.all([ getRepoTree(), generateRelevantContext(originalPrompt), ]) .then(([tree, context]) => { Logger.log("FetchInterceptor: Retrieved repo tree and context"); if (context) { const importInstruction = "The following information was retrieved from the repository. Please use these results to inform your response:\n"; messages[lastIdx].content = `${importInstruction}\n[Repository Tree]\n${tree}\n\n[Repository Context]\n${context}\n\n[Original Message]\n${originalPrompt}`; initOptions.body = JSON.stringify(data); Logger.log("FetchInterceptor: Enhanced prompt with repository context"); } else { Logger.log("FetchInterceptor: No context to add to prompt"); } return originalFetch.call(w, input, initOptions); }) .catch((error) => { Logger.error("FetchInterceptor: Error during interception", error); return originalFetch.call(w, input, initOptions); }); }) .catch((error) => { Logger.error("FetchInterceptor: Error getting state", error); return originalFetch.call(w, input, initOptions); }) .finally(() => { FetchInterceptor.isIntercepting = false; }); }; Logger.log("FetchInterceptor: Initialized successfully"); } catch (error) { Logger.error("FetchInterceptor: Failed to initialize", error); } }, }; const IngestDBManager = { db: null, init: () => { return new Promise((resolve, reject) => { try { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onerror = () => { Logger.error("Failed to open IndexedDB"); reject(request.error); }; request.onsuccess = (event) => { IngestDBManager.db = event.target.result; Logger.log("IndexedDB opened successfully"); resolve(); }; request.onupgradeneeded = (event) => { const db = event.target.result; if (!db.objectStoreNames.contains(STORE_NAME)) { const store = db.createObjectStore(STORE_NAME, { keyPath: "chatId", }); store.createIndex("repoUrl", "repoUrl", { unique: false }); Logger.log("IndexedDB store created"); } }; } catch (error) { Logger.error("Error initializing IndexedDB:", error); reject(error); } }); }, getAllStates: () => { return new Promise((resolve, reject) => { try { const transaction = IngestDBManager.db.transaction( [STORE_NAME], "readonly" ); const store = transaction.objectStore(STORE_NAME); const request = store.getAll(); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); } catch (error) { reject(error); } }); }, getState: (chatId) => { return new Promise((resolve, reject) => { if (!chatId) { resolve(null); return; } try { const transaction = IngestDBManager.db.transaction( [STORE_NAME], "readonly" ); const store = transaction.objectStore(STORE_NAME); const request = store.get(chatId); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); } catch (error) { reject(error); } }); }, saveState: (state) => { return new Promise((resolve, reject) => { try { const transaction = IngestDBManager.db.transaction( [STORE_NAME], "readwrite" ); const store = transaction.objectStore(STORE_NAME); const request = store.put(state); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); } catch (error) { reject(error); } }); }, deleteState: (chatId) => { return new Promise((resolve, reject) => { try { const transaction = IngestDBManager.db.transaction( [STORE_NAME], "readwrite" ); const store = transaction.objectStore(STORE_NAME); const request = store.delete(chatId); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); } catch (error) { reject(error); } }); }, clearAll: () => { return new Promise((resolve, reject) => { try { const transaction = IngestDBManager.db.transaction( [STORE_NAME], "readwrite" ); const store = transaction.objectStore(STORE_NAME); const request = store.clear(); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); } catch (error) { reject(error); } }); }, }; const MenuCommands = { init: () => { return new Promise((resolve) => { try { GM_registerMenuCommand("Toggle debug logs", () => { safeGMGetValue(GM_STORAGE_KEYS.DEBUG, false) .then((currentDebug) => { const newDebug = !currentDebug; return safeGMSetValue(GM_STORAGE_KEYS.DEBUG, newDebug); }) .then(() => { debugMode = !debugMode; Logger.log( `Debug mode toggled to: ${debugMode} via menu. Reloading...` ); location.reload(); }) .catch((err) => { Logger.error("Failed to toggle debug mode:", err); }); }); GM_registerMenuCommand("Reset Gemini API Key", () => { safeGMSetValue(GM_STORAGE_KEYS.GEMINI_API_KEY, "") .then(() => { geminiApiKey = null; Logger.log("Gemini API Key reset via menu."); location.reload(); }) .catch((err) => { Logger.error("Failed to reset Gemini API key:", err); }); }); GM_registerMenuCommand("Reset RepoGist API URL", () => { safeGMSetValue(GM_STORAGE_KEYS.API_URL, "") .then(() => { apiUrl = null; Logger.log("RepoGist API URL reset via menu."); location.reload(); }) .catch((err) => { Logger.error("Failed to reset RepoGist API URL:", err); }); }); GM_registerMenuCommand("Reset IndexedDB for all chats", () => { IngestDBManager.clearAll() .then(() => { Logger.log("IndexedDB reset via menu."); location.reload(); }) .catch((err) => { Logger.error("Failed to reset IndexedDB:", err); }); }); Logger.log("Menu commands registered."); resolve(); } catch (error) { Logger.error("Error registering menu commands:", error); resolve(); } }); }, }; function main() { try { // Initialize debug mode safely safeGMGetValue(GM_STORAGE_KEYS.DEBUG, false) .then((value) => { debugMode = value; Logger.log( `${SCRIPT_NAME} v${SCRIPT_VERSION} starting. Debug mode: ${debugMode}` ); // Initialize all components FetchInterceptor.init(); return Promise.all([ MenuCommands.init(), IngestDBManager.init() ]); }) .then(() => { StyleManager.injectGlobalStyles(); // Load API configuration safely return Promise.all([ safeGMGetValue(GM_STORAGE_KEYS.API_URL), safeGMGetValue(GM_STORAGE_KEYS.GEMINI_API_KEY) ]); }) .then(([url, key]) => { apiUrl = url; geminiApiKey = key; if (!apiUrl || !geminiApiKey) { Logger.log( "RepoGist API URL or Gemini API Key not found. It will be requested upon first import attempt." ); } else { Logger.log("RepoGist API URL and Gemini API Key loaded."); } // Set up URL observer and button injection let lastChatId = getChatId(); const urlObserver = new MutationObserver(() => { const currentChatId = getChatId(); if (currentChatId !== lastChatId) { lastChatId = currentChatId; setTimeout(() => { injectButtonWithRetry().catch((err) => { Logger.error("Failed to inject button:", err); }); }, 500); IngestDBManager.getState(currentChatId) .then((state) => { const importButton = document.getElementById(UI_IDS.importButton); if (importButton) { if (state && state.repoUrl) { const repoName = getRepoNamefromURL(state.repoUrl) ?.split("/") ?.pop() ?.slice(0, 10) ?.replace(/^./, (c) => c.toUpperCase()) || "Repo"; importButton.innerHTML = `<div class="flex gap-1">${githubSVG}<span class="max-sm:hidden sm:ml-0.5">${repoName}</span></div>`; importButton.classList.add(CSS_CLASSES.importButtonOn); importButton.setAttribute("aria-label", "Repository imported"); importButton.dataset.mode = "on"; } else { importButton.innerHTML = `<div class="flex gap-1">${githubSVG}<span class="max-sm:hidden sm:ml-0.5">Import</span></div>`; importButton.classList.remove(CSS_CLASSES.importButtonOn); importButton.setAttribute("aria-label", "Import repository"); importButton.dataset.mode = "off"; } } }) .catch((err) => { Logger.error("Failed to update button state:", err); }); } }); urlObserver.observe(document.querySelector("title"), { subtree: true, characterData: true, childList: true, }); const injectButtonWithRetry = (maxRetries = 10, delay = 1000) => { let retries = 0; const tryInjection = () => { return new Promise((resolve) => { const injectionObserverTargetParent = document.querySelector( selectors.messageActions ); if (injectionObserverTargetParent) { UIManager.injectImportButton() .then((success) => { if (success) { Logger.log("Successfully injected import button"); resolve(true); return; } throw new Error("Injection failed"); }) .catch(() => { if (retries < maxRetries) { retries++; Logger.log( `Retrying button injection (${retries}/${maxRetries})...` ); setTimeout(() => { tryInjection().then(resolve).catch(() => resolve(false)); }, delay); } else { resolve(false); } }); } else { if (retries < maxRetries) { retries++; Logger.log( `Container not found, retrying (${retries}/${maxRetries})...` ); setTimeout(() => { tryInjection().then(resolve).catch(() => resolve(false)); }, delay); } else { Logger.error("Container not found after max retries"); resolve(false); } } }); }; return tryInjection(); }; // Initial injection with delay to ensure DOM is ready setTimeout(() => { injectButtonWithRetry() .then((success) => { if (!success) { Logger.log("Initial button injection failed, setting up observers"); const documentObserver = new MutationObserver(() => { const target = document.querySelector(selectors.messageActions); if (target && !target.querySelector(`#${UI_IDS.importButton}`)) { UIManager.injectImportButton() .then((success) => { if (success) { Logger.log("Button injected via document observer"); } }) .catch((err) => { Logger.error("Failed to inject button:", err); }); } }); documentObserver.observe(document.body, { childList: true, subtree: true, }); } }) .catch((err) => { Logger.error("Failed to initialize button injection:", err); }); }, 2000); Logger.log(`${SCRIPT_NAME} v${SCRIPT_VERSION} initialized!`); }) .catch((err) => { Logger.error("Failed to initialize:", err); }); } catch (error) { Logger.error("Failed to initialize:", error); } } main(); })();