Repo Gist

Provides GitHub repositories as additional context.

当前为 2025-06-04 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Repo Gist
  3. // @namespace https://github.com/prudentbird
  4. // @version 0.0.1
  5. // @description Provides GitHub repositories as additional context.
  6. // @author Prudent Bird
  7. // @match https://t3.chat/*
  8. // @match https://beta.t3.chat/*
  9. // @icon https://www.google.com/s2/favicons?sz=64&domain=t3.chat
  10. // @grant GM_getValue
  11. // @grant GM_setValue
  12. // @grant unsafeWindow
  13. // @grant GM_xmlhttpRequest
  14. // @grant GM_registerMenuCommand
  15. // @run-at document-idle
  16. // @connect https://generativelanguage.googleapis.com
  17. // @license MIT
  18. // ==/UserScript==
  19.  
  20. (function () {
  21. "use strict";
  22.  
  23. // --- Configuration and State ---
  24. let debugMode = false;
  25. const DB_VERSION = 1;
  26. const SCRIPT_VERSION = "0.1.0";
  27. const SCRIPT_NAME = "Repo Gist";
  28. const DB_NAME = "t3chat_repogist_db";
  29. const STORE_NAME = "repogist_states";
  30. 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>`;
  31.  
  32. const GM_STORAGE_KEYS = {
  33. DEBUG: "debug",
  34. API_URL: "apiUrl",
  35. GEMINI_API_KEY: "geminiApiKey",
  36. };
  37.  
  38. // --- Utility: Logger ---
  39. const Logger = {
  40. log: (...args) => {
  41. if (debugMode) console.log(`[${SCRIPT_NAME}]`, ...args);
  42. },
  43. error: (...args) => console.error(`[${SCRIPT_NAME}]`, ...args),
  44. };
  45.  
  46. const getChatId = () => {
  47. const currentUrl = window.location.href;
  48. const match = currentUrl.match(/\/chat\/([^/?#]+)/);
  49. const chatId = match ? match[1] : null;
  50. if (!chatId) {
  51. Logger.log("getChatId: No chat ID found in URL", currentUrl);
  52. }
  53. return chatId;
  54. };
  55.  
  56. let apiUrl = null;
  57. let geminiApiKey = null;
  58.  
  59. const ApiKeyModal = {
  60. _isShown: false,
  61. _isValidURL: (url) => {
  62. try {
  63. new URL(url);
  64. return true;
  65. } catch (e) {
  66. return false;
  67. }
  68. },
  69. show: () => {
  70. if (document.getElementById(UI_IDS.apiKeyModal) || ApiKeyModal._isShown)
  71. return;
  72. ApiKeyModal._isShown = true;
  73. const wrapper = document.createElement("div");
  74. wrapper.id = UI_IDS.apiKeyModal;
  75. wrapper.innerHTML = `
  76. <div id="${UI_IDS.apiKeyModalContent}">
  77. <div id="${UI_IDS.apiKeyModalHeader}">
  78. <div><!-- Icon container -->
  79. <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>
  80. </div>
  81. <div>Enter API Configuration</div><!-- Title -->
  82. <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>
  83. </div>
  84. <div id="${UI_IDS.apiKeyModalInputContainer}">
  85. <label for="${UI_IDS.apiKeyModalInput}">RepoGist API URL</label>
  86. <input id="${UI_IDS.apiKeyModalInput}" type="text" placeholder="https://api.repogist.com/ingest" />
  87. <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>
  88. </div>
  89. <div id="${UI_IDS.apiKeyModalInputContainer}">
  90. <label for="${UI_IDS.geminiApiKeyInput}">Gemini API Key</label>
  91. <input id="${UI_IDS.geminiApiKeyInput}" type="text" placeholder="Enter your Gemini API key" />
  92. <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>
  93. </div>
  94. <button id="${UI_IDS.apiKeyModalSaveButton}">Save</button>
  95. </div>`;
  96. document.body.appendChild(wrapper);
  97. const input = wrapper.querySelector(`#${UI_IDS.apiKeyModalInput}`);
  98. if (input) {
  99. input.focus();
  100. }
  101. ApiKeyModal._attachEventListeners(wrapper);
  102. },
  103. _attachEventListeners: (modalElement) => {
  104. const urlInput = modalElement.querySelector(
  105. `#${UI_IDS.apiKeyModalInput}`
  106. );
  107. const geminiKeyInput = modalElement.querySelector(
  108. `#${UI_IDS.geminiApiKeyInput}`
  109. );
  110. const saveButton = modalElement.querySelector(
  111. `#${UI_IDS.apiKeyModalSaveButton}`
  112. );
  113. const closeButton = modalElement.querySelector(
  114. `#${UI_IDS.apiKeyModalCloseButton}`
  115. );
  116. const clearButton = modalElement.querySelector(
  117. `#${UI_IDS.apiKeyModalClearButton}`
  118. );
  119. const geminiClearButton = modalElement.querySelector(
  120. `#${UI_IDS.geminiApiKeyClearButton}`
  121. );
  122.  
  123. modalElement.addEventListener("click", (e) => {
  124. if (e.target === modalElement) {
  125. ApiKeyModal._isShown = false;
  126. modalElement.remove();
  127. }
  128. });
  129.  
  130. const updateClearButtonVisibility = (input, button) => {
  131. if (button) {
  132. button.style.display = input.value ? "flex" : "none";
  133. }
  134. };
  135.  
  136. updateClearButtonVisibility(urlInput, clearButton);
  137. updateClearButtonVisibility(geminiKeyInput, geminiClearButton);
  138.  
  139. if (clearButton) {
  140. clearButton.addEventListener("click", () => {
  141. urlInput.value = "";
  142. urlInput.focus();
  143. updateClearButtonVisibility(urlInput, clearButton);
  144. });
  145. }
  146.  
  147. if (geminiClearButton) {
  148. geminiClearButton.addEventListener("click", () => {
  149. geminiKeyInput.value = "";
  150. geminiKeyInput.focus();
  151. updateClearButtonVisibility(geminiKeyInput, geminiClearButton);
  152. });
  153. }
  154.  
  155. urlInput.addEventListener("input", () =>
  156. updateClearButtonVisibility(urlInput, clearButton)
  157. );
  158. geminiKeyInput.addEventListener("input", () =>
  159. updateClearButtonVisibility(geminiKeyInput, geminiClearButton)
  160. );
  161.  
  162. const handleSave = () => {
  163. const url = urlInput.value.trim();
  164. const geminiKey = geminiKeyInput.value.trim();
  165.  
  166. if (url && !ApiKeyModal._isValidURL(url)) {
  167. alert("Invalid RepoGist API URL");
  168. return;
  169. }
  170.  
  171. if (url) {
  172. GM_setValue(GM_STORAGE_KEYS.API_URL, url)
  173. .then(() => {
  174. apiUrl = url;
  175. if (geminiKey) {
  176. return GM_setValue(GM_STORAGE_KEYS.GEMINI_API_KEY, geminiKey);
  177. }
  178. })
  179. .then(() => {
  180. if (geminiKey) {
  181. geminiApiKey = geminiKey;
  182. }
  183. ApiKeyModal._isShown = false;
  184. modalElement.remove();
  185. })
  186. .catch((err) => {
  187. Logger.error("Failed to save API configuration:", err);
  188. });
  189. } else {
  190. ApiKeyModal._isShown = false;
  191. modalElement.remove();
  192. }
  193. };
  194.  
  195. saveButton.addEventListener("click", handleSave);
  196.  
  197. urlInput.addEventListener("keydown", (e) => {
  198. if (e.key === "Enter") {
  199. handleSave();
  200. }
  201. });
  202.  
  203. geminiKeyInput.addEventListener("keydown", (e) => {
  204. if (e.key === "Enter") {
  205. handleSave();
  206. }
  207. });
  208.  
  209. closeButton.addEventListener("click", () => {
  210. ApiKeyModal._isShown = false;
  211. modalElement.remove();
  212. });
  213. },
  214. };
  215.  
  216. const getRepoNamefromURL = (url) => {
  217. if (typeof url !== "string") {
  218. Logger.log("getRepoNamefromURL: Invalid URL type", typeof url);
  219. return null;
  220. }
  221. url = url.replace(/\/+$/, "");
  222. let match = url.match(/[:\/]([^\/]+)\.git$/);
  223. if (match) {
  224. Logger.log("getRepoNamefromURL: Found .git URL match", match[1]);
  225. return match[1];
  226. }
  227. match = url.match(/\/([^\/]+)(?:\/tree\/[^\/]+)?$/);
  228. if (match) {
  229. Logger.log("getRepoNamefromURL: Found standard URL match", match[1]);
  230. return match[1];
  231. }
  232. Logger.log("getRepoNamefromURL: No match found for URL", url);
  233. return null;
  234. };
  235.  
  236. const selectors = {
  237. messageActions: "div.ml-\\[-7px\\].flex.items-center.gap-1",
  238. searchButton: 'button#search-toggle[aria-label="Enable search"]',
  239. };
  240.  
  241. const UI_IDS = {
  242. apiKeyModal: "api-key-modal",
  243. apiKeyModalContent: "api-key-modal-content",
  244. apiKeyModalHeader: "api-key-modal-header",
  245. apiKeyModalInput: "api-key-modal-input",
  246. apiKeyModalInputContainer: "api-key-modal-input-container",
  247. apiKeyModalSaveButton: "api-key-modal-save-button",
  248. apiKeyModalCloseButton: "api-key-modal-close-button",
  249. apiKeyModalClearButton: "api-key-modal-clear-button",
  250. geminiApiKeyInput: "gemini-api-key-input",
  251. geminiApiKeyClearButton: "gemini-api-key-clear-button",
  252. importButton: "import-button",
  253. searchToggle: "search-toggle",
  254. repoUrlModal: "repo-url-modal",
  255. repoUrlModalContent: "repo-url-modal-content",
  256. repoUrlModalHeader: "repo-url-modal-header",
  257. repoUrlModalDescription: "repo-url-modal-description",
  258. repoUrlModalInput: "repo-url-modal-input",
  259. repoUrlModalSaveButton: "repo-url-modal-save-button",
  260. repoUrlModalCloseButton: "repo-url-modal-close-button",
  261. repoUrlModalClearButton: "repo-url-modal-clear-button",
  262. repoUrlModalInputContainer: "repo-url-modal-input-container",
  263. styleElement: "repo-gist-style",
  264. };
  265.  
  266. const CSS_CLASSES = {
  267. button:
  268. "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 px-3 text-xs -mb-1.5 h-auto gap-2 rounded-full border border-solid border-secondary-foreground/10 py-1.5 pl-2 pr-2.5 text-muted-foreground",
  269. importButtonLoading: "loading",
  270. importButtonOn: "on",
  271. };
  272.  
  273. const StyleManager = {
  274. injectGlobalStyles: () => {
  275. if (document.getElementById(UI_IDS.styleElement)) return;
  276. const styleEl = document.createElement("style");
  277. styleEl.id = UI_IDS.styleElement;
  278. styleEl.textContent = `
  279. /* Button toggle animation */
  280. #${UI_IDS.importButton} { position: relative; overflow: hidden; transition: color 0.3s ease; }
  281. #${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; }
  282. #${UI_IDS.importButton}.${CSS_CLASSES.importButtonOn}::before { transform: scaleX(1); }
  283. #${UI_IDS.importButton} svg { transition: transform 0.3s ease; }
  284. #${UI_IDS.importButton}.${CSS_CLASSES.importButtonOn} svg { transform: rotate(360deg); }
  285. /* Loading state */
  286. #${UI_IDS.importButton}.${CSS_CLASSES.importButtonLoading} { opacity: 0.6; position: relative; }
  287. #${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; }
  288. @keyframes spin { to { transform: rotate(360deg); } }
  289.  
  290. /* Repo URL Modal Styles */
  291. #${UI_IDS.repoUrlModal} {
  292. position: fixed;
  293. inset: 0;
  294. background: rgba(0,0,0,0.7);
  295. display: flex;
  296. align-items: center;
  297. justify-content: center;
  298. z-index: 9999;
  299. }
  300. #${UI_IDS.repoUrlModalContent} {
  301. background: #1c1c1e;
  302. padding: 24px;
  303. border-radius: 12px;
  304. width: 500px;
  305. max-width: 95vw;
  306. box-sizing: border-box;
  307. box-shadow: 0 8px 24px rgba(0,0,0,0.3);
  308. }
  309. @media (max-width: 600px) {
  310. #${UI_IDS.repoUrlModalContent} {
  311. width: 95vw;
  312. padding: 16px;
  313. }
  314. }
  315. #${UI_IDS.repoUrlModalHeader} {
  316. display: flex;
  317. align-items: center;
  318. margin-bottom: 20px;
  319. position: relative;
  320. }
  321. #${UI_IDS.repoUrlModalHeader} > div:first-child { /* Icon container */
  322. color: #c62a88;
  323. margin-right: 12px;
  324. }
  325. #${UI_IDS.repoUrlModalHeader} > div:last-child { /* Title container */
  326. font-size: 22px;
  327. font-weight: 600;
  328. color: #fff;
  329. }
  330. #${UI_IDS.repoUrlModalCloseButton} {
  331. position: absolute;
  332. top: 0;
  333. right: 0;
  334. background: none;
  335. border: none;
  336. cursor: pointer;
  337. color: #fff;
  338. font-size: 24px;
  339. transition: color 0.3s ease;
  340. }
  341. #${UI_IDS.repoUrlModalDescription} {
  342. color: #999;
  343. font-size: 14px;
  344. margin-bottom: 16px;
  345. }
  346. #${UI_IDS.repoUrlModalInputContainer} {
  347. position: relative;
  348. width: 100%;
  349. margin-bottom: 16px;
  350. }
  351. #${UI_IDS.repoUrlModalInput} {
  352. width: 100%;
  353. padding: 12px 36px 12px 12px;
  354. box-sizing: border-box;
  355. background: #2a2a2c;
  356. color: #fff;
  357. border: 1px solid #333;
  358. border-radius: 6px;
  359. outline: none;
  360. font-size: 14px;
  361. }
  362. #${UI_IDS.repoUrlModalClearButton} {
  363. position: absolute;
  364. top: 50%;
  365. right: 10px;
  366. transform: translateY(-50%);
  367. width: 20px;
  368. height: 20px;
  369. display: flex;
  370. align-items: center;
  371. justify-content: center;
  372. background: none;
  373. border: none;
  374. cursor: pointer;
  375. font-size: 16px;
  376. font-weight: 500;
  377. color: #aaa;
  378. transition: color 0.2s ease;
  379. padding: 0;
  380. }
  381. #${UI_IDS.repoUrlModalClearButton}:hover {
  382. color: #c62a88;
  383. }
  384. #${UI_IDS.repoUrlModalSaveButton} {
  385. width: 100%;
  386. padding: 12px;
  387. background: #a02553;
  388. border: none;
  389. border-radius: 6px;
  390. color: white;
  391. cursor: pointer;
  392. font-size: 15px;
  393. font-weight: 500;
  394. transition: all 0.2s ease;
  395. }
  396. #${UI_IDS.repoUrlModalSaveButton}:hover {
  397. background: #c62a88;
  398. }
  399.  
  400. /* API Key Modal Styles */
  401. #${UI_IDS.apiKeyModal} {
  402. position: fixed;
  403. inset: 0;
  404. background: rgba(0,0,0,0.7);
  405. display: flex;
  406. align-items: center;
  407. justify-content: center;
  408. z-index: 9999;
  409. }
  410. #${UI_IDS.apiKeyModalContent} {
  411. background: #1c1c1e;
  412. padding: 24px;
  413. border-radius: 12px;
  414. width: 500px;
  415. max-width: 95vw;
  416. box-sizing: border-box;
  417. box-shadow: 0 8px 24px rgba(0,0,0,0.3);
  418. }
  419. @media (max-width: 600px) {
  420. #${UI_IDS.apiKeyModalContent} {
  421. width: 95vw;
  422. padding: 16px;
  423. }
  424. }
  425. #${UI_IDS.apiKeyModalHeader} {
  426. display: flex;
  427. align-items: center;
  428. margin-bottom: 20px;
  429. position: relative;
  430. }
  431. #${UI_IDS.apiKeyModalHeader} > div:first-child { /* Icon container */
  432. color: #c62a88;
  433. margin-right: 12px;
  434. }
  435. #${UI_IDS.apiKeyModalHeader} > div:last-child { /* Title container */
  436. font-size: 22px;
  437. font-weight: 600;
  438. color: #fff;
  439. }
  440. #${UI_IDS.apiKeyModalCloseButton} {
  441. position: absolute;
  442. top: 0;
  443. right: 0;
  444. background: none;
  445. border: none;
  446. cursor: pointer;
  447. color: #fff;
  448. font-size: 24px;
  449. transition: color 0.3s ease;
  450. }
  451. #${UI_IDS.apiKeyModalInputContainer} {
  452. position: relative;
  453. width: 100%;
  454. margin-bottom: 16px;
  455. }
  456. #${UI_IDS.apiKeyModalInputContainer} label {
  457. display: block;
  458. color: #999;
  459. font-size: 14px;
  460. margin-bottom: 8px;
  461. }
  462. #${UI_IDS.apiKeyModalInput} {
  463. width: 100%;
  464. padding: 12px 36px 12px 12px;
  465. box-sizing: border-box;
  466. background: #2a2a2c;
  467. color: #fff;
  468. border: 1px solid #333;
  469. border-radius: 6px;
  470. outline: none;
  471. font-size: 14px;
  472. }
  473. #${UI_IDS.apiKeyModalInput}:focus {
  474. border-color: #c62a88;
  475. }
  476. #${UI_IDS.geminiApiKeyInput} {
  477. width: 100%;
  478. padding: 12px 36px 12px 12px;
  479. box-sizing: border-box;
  480. background: #2a2a2c;
  481. color: #fff;
  482. border: 1px solid #333;
  483. border-radius: 6px;
  484. outline: none;
  485. font-size: 14px;
  486. }
  487. #${UI_IDS.geminiApiKeyInput}:focus {
  488. border-color: #c62a88;
  489. }
  490. #${UI_IDS.apiKeyModalClearButton}, #${UI_IDS.geminiApiKeyClearButton} {
  491. position: absolute;
  492. top: 68%;
  493. right: 10px;
  494. transform: translateY(-50%);
  495. width: 20px;
  496. height: 20px;
  497. display: flex;
  498. align-items: center;
  499. justify-content: center;
  500. background: none;
  501. border: none;
  502. cursor: pointer;
  503. font-size: 16px;
  504. font-weight: 500;
  505. color: #aaa;
  506. transition: color 0.2s ease;
  507. padding: 0;
  508. }
  509. #${UI_IDS.apiKeyModalClearButton}:hover, #${UI_IDS.geminiApiKeyClearButton}:hover {
  510. color: #c62a88;
  511. }
  512. #${UI_IDS.apiKeyModalSaveButton} {
  513. width: 100%;
  514. padding: 12px;
  515. background: #a02553;
  516. border: none;
  517. border-radius: 6px;
  518. color: white;
  519. cursor: pointer;
  520. font-size: 15px;
  521. font-weight: 500;
  522. transition: all 0.2s ease;
  523. }
  524. #${UI_IDS.apiKeyModalSaveButton}:hover {
  525. background: #c62a88;
  526. }`;
  527. document.head.appendChild(styleEl);
  528. },
  529. };
  530.  
  531. const UIManager = {
  532. importButton: null,
  533. _createImportButton: () => {
  534. return new Promise((resolve) => {
  535. const chatId = getChatId();
  536. IngestDBManager.getState(chatId)
  537. .then((state) => {
  538. const button = document.createElement("button");
  539. if (state && state.repoUrl) {
  540. button.innerHTML = `${githubSVG} ${getRepoNamefromURL(
  541. state.repoUrl
  542. )
  543. .split("/")
  544. .pop()
  545. .slice(0, 10)
  546. .replace(/^./, (c) => c.toUpperCase())}`;
  547. button.id = UI_IDS.importButton;
  548. button.type = "button";
  549. button.setAttribute("aria-label", "Disable import");
  550. button.setAttribute("data-state", "open");
  551. button.className = CSS_CLASSES.button;
  552. button.dataset.mode = "on";
  553. button.classList.toggle(CSS_CLASSES.importButtonOn);
  554. } else {
  555. button.innerHTML = `${githubSVG} Import Repo`;
  556. button.id = UI_IDS.importButton;
  557. button.type = "button";
  558. button.setAttribute("aria-label", "Enable import");
  559. button.setAttribute("data-state", "closed");
  560. button.className = CSS_CLASSES.button;
  561. button.dataset.mode = "off";
  562. }
  563.  
  564. button.addEventListener("click", () => {
  565. if (!apiUrl || !geminiApiKey) {
  566. ApiKeyModal.show();
  567. return;
  568. }
  569.  
  570. if (!chatId) {
  571. alert("No chat ID found, can't import repo");
  572. Logger.log("No chat ID found, skipping import button click");
  573. return;
  574. }
  575.  
  576. if (RepoUrlModal._isShown) {
  577. RepoUrlModal._isShown = false;
  578. return;
  579. }
  580.  
  581. RepoUrlModal.show();
  582. });
  583. resolve(button);
  584. })
  585. .catch((err) => {
  586. Logger.error("Failed to create import button:", err);
  587. resolve(null);
  588. });
  589. });
  590. },
  591. injectImportButton: () => {
  592. return new Promise((resolve) => {
  593. const messageActions = document.querySelectorAll(
  594. selectors.messageActions
  595. );
  596.  
  597. if (!messageActions || messageActions.length === 0) {
  598. Logger.error("Message Actions not found.");
  599. resolve(false);
  600. return;
  601. }
  602.  
  603. UIManager._createImportButton()
  604. .then((button) => {
  605. UIManager.importButton = button;
  606. if (!button) {
  607. Logger.error("Import button not found.");
  608. resolve(false);
  609. return;
  610. }
  611.  
  612. messageActions.forEach((container) => {
  613. if (!container.querySelector(`#${UI_IDS.importButton}`)) {
  614. container.appendChild(button);
  615. }
  616. });
  617. resolve(true);
  618. })
  619. .catch((err) => {
  620. Logger.error("Failed to inject import button:", err);
  621. resolve(false);
  622. });
  623. });
  624. },
  625. };
  626.  
  627. const RepoUrlModal = {
  628. _isShown: false,
  629. _isValidRepoUrl: (url) => {
  630. const patterns = [
  631. /^git@[^:]+:.+\.git$/,
  632. /^https:\/\/[^/]+\/.+\.git$/,
  633. /^https:\/\/github\.com\/[^/]+\/[^/]+(\/tree\/[^/]+)?$/,
  634. /^https:\/\/gitlab\.com\/[^/]+\/[^/]+(\/-\/tree\/[^/]+)?$/,
  635. ];
  636.  
  637. return patterns.some((pattern) => pattern.test(url));
  638. },
  639. show: () => {
  640. return new Promise((resolve) => {
  641. const chatId = getChatId();
  642. IngestDBManager.getState(chatId)
  643. .then((state) => {
  644. if (
  645. document.getElementById(UI_IDS.repoUrlModal) ||
  646. RepoUrlModal._isShown
  647. ) {
  648. resolve();
  649. return;
  650. }
  651. RepoUrlModal._isShown = true;
  652. const wrapper = document.createElement("div");
  653. wrapper.id = UI_IDS.repoUrlModal;
  654. wrapper.innerHTML = `
  655. <div id="${UI_IDS.repoUrlModalContent}">
  656. <div id="${UI_IDS.repoUrlModalHeader}">
  657. <div><!-- Icon container -->
  658. <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>
  659. </div>
  660. <div>Enter Repo URL</div><!-- Title -->
  661. <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>
  662. </div>
  663. <div id="${UI_IDS.repoUrlModalDescription}">Enter the URL of the GitHub repository you want to import.</div>
  664. <div id="${UI_IDS.repoUrlModalInputContainer}">
  665. <input id="${UI_IDS.repoUrlModalInput}" type="text" placeholder="https://github.com/username/repo" />
  666. <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>
  667. </div>
  668. <button id="${UI_IDS.repoUrlModalSaveButton}">Import</button>
  669. </div>`;
  670. document.body.appendChild(wrapper);
  671. const input = wrapper.querySelector(`#${UI_IDS.repoUrlModalInput}`);
  672. if (input) {
  673. input.focus();
  674. input.value = (state && state.repoUrl) || "";
  675. }
  676. RepoUrlModal._attachEventListeners(wrapper);
  677. resolve();
  678. })
  679. .catch((err) => {
  680. Logger.error("Failed to show repo URL modal:", err);
  681. resolve();
  682. });
  683. });
  684. },
  685. _attachEventListeners: (modalElement) => {
  686. const urlInput = modalElement.querySelector(
  687. `#${UI_IDS.repoUrlModalInput}`
  688. );
  689. const saveButton = modalElement.querySelector(
  690. `#${UI_IDS.repoUrlModalSaveButton}`
  691. );
  692. const closeButton = modalElement.querySelector(
  693. `#${UI_IDS.repoUrlModalCloseButton}`
  694. );
  695. const clearButton = modalElement.querySelector(
  696. `#${UI_IDS.repoUrlModalClearButton}`
  697. );
  698.  
  699. modalElement.addEventListener("click", (e) => {
  700. if (e.target === modalElement) {
  701. const urlInput = modalElement.querySelector(
  702. `#${UI_IDS.repoUrlModalInput}`
  703. );
  704. if (urlInput && !urlInput.value) {
  705. const importButton = document.getElementById(UI_IDS.importButton);
  706. if (importButton) {
  707. importButton.classList.remove(CSS_CLASSES.importButtonOn);
  708. importButton.setAttribute("aria-label", "Enable import");
  709. importButton.setAttribute("data-state", "closed");
  710. importButton.innerHTML = `${githubSVG} Import Repo`;
  711. importButton.dataset.mode = "off";
  712. }
  713.  
  714. IngestDBManager.deleteState(getChatId());
  715. }
  716. RepoUrlModal._isShown = false;
  717. modalElement.remove();
  718. }
  719. });
  720.  
  721. const updateClearButtonVisibility = () => {
  722. if (clearButton) {
  723. clearButton.style.display = urlInput.value ? "flex" : "none";
  724. }
  725. };
  726.  
  727. updateClearButtonVisibility();
  728.  
  729. if (clearButton) {
  730. clearButton.addEventListener("click", () => {
  731. urlInput.value = "";
  732. urlInput.focus();
  733. updateClearButtonVisibility();
  734. });
  735. }
  736.  
  737. urlInput.addEventListener("input", updateClearButtonVisibility);
  738.  
  739. const handleSave = () => {
  740. const url = urlInput.value.trim();
  741. if (url) {
  742. if (!RepoUrlModal._isValidRepoUrl(url)) {
  743. alert("Please enter a valid git repository URL.");
  744. return;
  745. }
  746.  
  747. RepoUrlModal._isShown = false;
  748. modalElement.remove();
  749.  
  750. const importButton = document.getElementById(UI_IDS.importButton);
  751. if (importButton) {
  752. importButton.classList.add(CSS_CLASSES.importButtonLoading);
  753. importButton.setAttribute("aria-label", "Importing repository...");
  754. importButton.disabled = true;
  755. }
  756.  
  757. new Promise((resolve, reject) => {
  758. GM_xmlhttpRequest({
  759. method: "POST",
  760. url: apiUrl,
  761. headers: {
  762. "Content-Type": "application/json",
  763. },
  764. data: JSON.stringify({
  765. url,
  766. }),
  767. onload: (res) => {
  768. if (res.status < 200 || res.status >= 300) {
  769. reject(
  770. new Error(`API Error (${res.status}): ${res.responseText}`)
  771. );
  772. return;
  773. }
  774. try {
  775. const data = JSON.parse(res.responseText);
  776. if (
  777. !data ||
  778. typeof data !== "object" ||
  779. !data.data ||
  780. !data.data.content ||
  781. !data.data.normalized ||
  782. !data.data.tree ||
  783. !data.data.index
  784. ) {
  785. reject(new Error("Invalid response data from API"));
  786. return;
  787. }
  788.  
  789. IngestDBManager.saveState({
  790. chatId: getChatId(),
  791. repoUrl: url,
  792. repoTree: data.data.tree,
  793. repoIndex: data.data.index,
  794. repoContent: data.data.content,
  795. repoNormalizedContent: data.data.normalized,
  796. })
  797. .then(() => {
  798. if (importButton) {
  799. importButton.classList.add(CSS_CLASSES.importButtonOn);
  800. importButton.setAttribute(
  801. "aria-label",
  802. "Disable import"
  803. );
  804. importButton.setAttribute("data-state", "open");
  805. importButton.dataset.mode = "on";
  806. importButton.innerHTML = `${githubSVG} ${getRepoNamefromURL(
  807. url
  808. )
  809. .split("/")
  810. .pop()
  811. .slice(0, 10)
  812. .replace(/^./, (c) => c.toUpperCase())}`;
  813. }
  814. resolve();
  815. })
  816. .catch((err) => {
  817. reject(err);
  818. });
  819. } catch (err) {
  820. reject(err);
  821. }
  822. },
  823. onerror: (err) => {
  824. reject(err);
  825. },
  826. ontimeout: () => {
  827. reject(new Error("Request timed out"));
  828. },
  829. timeout: 30000,
  830. });
  831. })
  832. .catch((err) => {
  833. if (importButton) {
  834. importButton.classList.remove(CSS_CLASSES.importButtonLoading);
  835. importButton.classList.remove(CSS_CLASSES.importButtonOn);
  836. importButton.setAttribute("aria-label", "Enable import");
  837. importButton.setAttribute("data-state", "closed");
  838. importButton.innerHTML = `${githubSVG} Import Repo`;
  839. importButton.dataset.mode = "off";
  840. importButton.disabled = false;
  841. }
  842. Logger.error("Error during repo import:", err);
  843. })
  844. .finally(() => {
  845. if (importButton) {
  846. importButton.classList.remove(CSS_CLASSES.importButtonLoading);
  847. importButton.disabled = false;
  848. if (importButton.dataset.mode === "on") {
  849. importButton.setAttribute("aria-label", "Disable import");
  850. }
  851. }
  852. });
  853. } else {
  854. alert("Repo URL cannot be empty");
  855. }
  856. };
  857.  
  858. saveButton.addEventListener("click", handleSave);
  859.  
  860. urlInput.addEventListener("keydown", (e) => {
  861. if (e.key === "Enter") {
  862. handleSave();
  863. }
  864. });
  865.  
  866. closeButton.addEventListener("click", () => {
  867. const urlInput = modalElement.querySelector(
  868. `#${UI_IDS.repoUrlModalInput}`
  869. );
  870. if (urlInput && !urlInput.value) {
  871. const importButton = document.getElementById(UI_IDS.importButton);
  872. if (importButton) {
  873. importButton.classList.remove(CSS_CLASSES.importButtonOn);
  874. importButton.setAttribute("aria-label", "Enable import");
  875. importButton.setAttribute("data-state", "closed");
  876. importButton.innerHTML = `${githubSVG} Import Repo`;
  877. importButton.dataset.mode = "off";
  878. }
  879.  
  880. IngestDBManager.deleteState(getChatId());
  881. }
  882. RepoUrlModal._isShown = false;
  883. modalElement.remove();
  884. });
  885. },
  886. };
  887.  
  888. const getAllFileNames = (index) => {
  889. Logger.log(
  890. "getAllFileNames: Processing index with",
  891. (index && index.length) || 0,
  892. "files"
  893. );
  894. return index.map((file, idx) => ({
  895. fileName: file.fileName,
  896. index: idx,
  897. }));
  898. };
  899.  
  900. const getFileContents = (index, indices) => {
  901. Logger.log("getFileContents: Requesting contents for indices", indices);
  902. if (!index || !Array.isArray(indices)) {
  903. Logger.log("getFileContents: Invalid input", {
  904. index: !!index,
  905. indices: !!indices,
  906. });
  907. return [];
  908. }
  909. const contents = indices
  910. .map((idx) => {
  911. const file = index[idx];
  912. if (file) {
  913. return {
  914. fileName: file.fileName,
  915. content: file.fileContent,
  916. };
  917. } else {
  918. return null;
  919. }
  920. })
  921. .filter(function (item) { return item !== null; });
  922. Logger.log(
  923. "getFileContents: Retrieved contents for",
  924. contents.length,
  925. "files"
  926. );
  927. return contents;
  928. };
  929.  
  930. const getRepoTree = () => {
  931. return new Promise((resolve) => {
  932. const chatId = getChatId();
  933. IngestDBManager.getState(chatId)
  934. .then((state) => {
  935. if (!state || !state.repoTree) {
  936. Logger.log("getRepoTree: No repo tree found in state");
  937. resolve(null);
  938. return;
  939. }
  940. Logger.log("getRepoTree: Retrieved repo tree");
  941. resolve(state.repoTree);
  942. })
  943. .catch((err) => {
  944. Logger.error("Failed to get repo tree:", err);
  945. resolve(null);
  946. });
  947. });
  948. };
  949.  
  950. const generateRelevantContext = (query) => {
  951. return new Promise((resolve) => {
  952. Logger.log("generateRelevantContext: Processing query", query);
  953. const chatId = getChatId();
  954. IngestDBManager.getState(chatId)
  955. .then((state) => {
  956. if (!state || !state.repoIndex) {
  957. Logger.log("generateRelevantContext: No repo index found in state");
  958. resolve(null);
  959. return;
  960. }
  961.  
  962. const index = state.repoIndex;
  963. const fileList = getAllFileNames(index);
  964. Logger.log(
  965. "generateRelevantContext: Generated file list with",
  966. fileList.length,
  967. "files"
  968. );
  969.  
  970. 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.
  971.  
  972. Files in repository:
  973. ${JSON.stringify(fileList, null, 2)}
  974.  
  975. User query: ${query}
  976.  
  977. Return the response in this exact JSON format:
  978. {
  979. "relevantIndices": [array of indices]
  980. }`;
  981.  
  982. return new Promise((resolve, reject) => {
  983. GM_xmlhttpRequest({
  984. method: "POST",
  985. url: `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${geminiApiKey}`,
  986. headers: {
  987. "Content-Type": "application/json",
  988. },
  989. data: JSON.stringify({
  990. contents: [
  991. {
  992. parts: [{ text: prompt }],
  993. },
  994. ],
  995. generationConfig: {
  996. responseMimeType: "application/json",
  997. responseSchema: {
  998. type: "OBJECT",
  999. properties: {
  1000. relevantIndices: {
  1001. type: "ARRAY",
  1002. items: { type: "NUMBER" },
  1003. },
  1004. },
  1005. propertyOrdering: ["relevantIndices"],
  1006. },
  1007. },
  1008. }),
  1009. onload: (response) => {
  1010. if (response.status >= 200 && response.status < 300) {
  1011. Logger.log(
  1012. "generateRelevantContext: Gemini API call successful"
  1013. );
  1014. resolve(JSON.parse(response.responseText));
  1015. } else {
  1016. Logger.error(
  1017. "generateRelevantContext: Gemini API error",
  1018. response.status
  1019. );
  1020. reject(new Error(`Gemini API error: ${response.status}`));
  1021. }
  1022. },
  1023. onerror: (error) => {
  1024. Logger.error(
  1025. "generateRelevantContext: Gemini API request failed",
  1026. error
  1027. );
  1028. reject(error);
  1029. },
  1030. });
  1031. });
  1032. })
  1033. .then((response) => {
  1034. if (!response) return null;
  1035. const responseText = response.candidates[0].content.parts[0].text;
  1036. let relevantIndices;
  1037. try {
  1038. relevantIndices = JSON.parse(responseText).relevantIndices;
  1039. Logger.log(
  1040. "generateRelevantContext: Parsed relevant indices",
  1041. relevantIndices
  1042. );
  1043. } catch (e) {
  1044. Logger.error(
  1045. "generateRelevantContext: Failed to parse Gemini API response",
  1046. e
  1047. );
  1048. return null;
  1049. }
  1050.  
  1051. if (!Array.isArray(relevantIndices)) {
  1052. Logger.error(
  1053. "generateRelevantContext: Invalid relevantIndices format",
  1054. relevantIndices
  1055. );
  1056. return null;
  1057. }
  1058.  
  1059. const relevantFiles = getFileContents(index, relevantIndices);
  1060. if (!relevantFiles.length) {
  1061. Logger.log("generateRelevantContext: No relevant files found");
  1062. return null;
  1063. }
  1064.  
  1065. const context = relevantFiles
  1066. .map(
  1067. (file) => `File: ${file.fileName}\nContent:\n${file.content}\n`
  1068. )
  1069. .join("\n");
  1070.  
  1071. Logger.log(
  1072. "generateRelevantContext: Generated context with",
  1073. relevantFiles.length,
  1074. "files"
  1075. );
  1076. resolve(context);
  1077. })
  1078. .catch((error) => {
  1079. Logger.error(
  1080. "generateRelevantContext: Error generating context",
  1081. error
  1082. );
  1083. resolve(null);
  1084. });
  1085. });
  1086. };
  1087.  
  1088. const FetchInterceptor = {
  1089. originalFetch: null,
  1090. isIntercepting: false,
  1091. init: () => {
  1092. try {
  1093. if (typeof unsafeWindow === "undefined") {
  1094. Logger.error("FetchInterceptor: unsafeWindow is not available");
  1095. return;
  1096. }
  1097. const w = unsafeWindow;
  1098. w.t3ChatIngest = w.t3ChatIngest || { needIngest: false };
  1099. const originalFetch = w.fetch;
  1100. FetchInterceptor.originalFetch = originalFetch;
  1101.  
  1102. w.fetch = (input, initOptions = {}) => {
  1103. if (FetchInterceptor.isIntercepting) {
  1104. Logger.log(
  1105. "FetchInterceptor: Already intercepting, passing through"
  1106. );
  1107. return originalFetch.call(w, input, initOptions);
  1108. }
  1109.  
  1110. const url = typeof input === "string" ? input : input.url;
  1111. if (
  1112. !url.includes("/api/chat") ||
  1113. url.includes("/api/chat/resume") ||
  1114. initOptions.method !== "POST"
  1115. ) {
  1116. return originalFetch.call(w, input, initOptions);
  1117. }
  1118.  
  1119. const chatId = getChatId();
  1120. IngestDBManager.getState(chatId)
  1121. .then((state) => {
  1122. if (!state || !state.repoUrl) {
  1123. Logger.log(
  1124. "FetchInterceptor: No repo URL in state, passing through"
  1125. );
  1126. return originalFetch.call(w, input, initOptions);
  1127. }
  1128.  
  1129. let data;
  1130. try {
  1131. data = JSON.parse(initOptions.body || "{}");
  1132. } catch (error) {
  1133. Logger.error(
  1134. "FetchInterceptor: Failed to parse request body",
  1135. error
  1136. );
  1137. return originalFetch.call(w, input, initOptions);
  1138. }
  1139.  
  1140. if (!Array.isArray(data.messages)) {
  1141. Logger.log("FetchInterceptor: No messages array in request");
  1142. return originalFetch.call(w, input, initOptions);
  1143. }
  1144.  
  1145. const messages = data.messages;
  1146. const lastIdx = messages.length - 1;
  1147. if (
  1148. lastIdx < 0 ||
  1149. !messages[lastIdx] ||
  1150. messages[lastIdx].role !== "user"
  1151. ) {
  1152. Logger.log("FetchInterceptor: No user message found");
  1153. return originalFetch.call(w, input, initOptions);
  1154. }
  1155. const originalPrompt = messages[lastIdx].content;
  1156.  
  1157. if (typeof originalPrompt !== "string") {
  1158. Logger.log(
  1159. "FetchInterceptor: Invalid prompt type",
  1160. typeof originalPrompt
  1161. );
  1162. return originalFetch.call(w, input, initOptions);
  1163. }
  1164.  
  1165. Logger.log(
  1166. "FetchInterceptor: Intercepting fetch for ingest enhancement"
  1167. );
  1168. FetchInterceptor.isIntercepting = true;
  1169.  
  1170. return Promise.all([
  1171. getRepoTree(),
  1172. generateRelevantContext(originalPrompt),
  1173. ])
  1174. .then(([tree, context]) => {
  1175. Logger.log("FetchInterceptor: Retrieved repo tree");
  1176. Logger.log("FetchInterceptor: Generated context", !!context);
  1177.  
  1178. if (context) {
  1179. const importInstruction =
  1180. "The following information was retrieved from the repository. Please use these results to inform your response:\n";
  1181. messages[
  1182. lastIdx
  1183. ].content = `${importInstruction}\n[Repository Tree]\n${tree}\n\n[Repository Context]\n${context}\n\n[Original Message]\n${originalPrompt}`;
  1184. initOptions.body = JSON.stringify(data);
  1185. Logger.log(
  1186. "FetchInterceptor: Enhanced prompt with repository context"
  1187. );
  1188. } else {
  1189. Logger.log("FetchInterceptor: No context to add to prompt");
  1190. }
  1191.  
  1192. return originalFetch.call(w, input, initOptions);
  1193. })
  1194. .catch((error) => {
  1195. Logger.error(
  1196. "FetchInterceptor: Error during interception",
  1197. error
  1198. );
  1199. return originalFetch.call(w, input, initOptions);
  1200. })
  1201. .finally(() => {
  1202. FetchInterceptor.isIntercepting = false;
  1203. });
  1204. })
  1205. .catch((error) => {
  1206. Logger.error("FetchInterceptor: Error getting state", error);
  1207. return originalFetch.call(w, input, initOptions);
  1208. });
  1209. };
  1210.  
  1211. Logger.log("FetchInterceptor: Initialized successfully");
  1212. } catch (error) {
  1213. Logger.error("FetchInterceptor: Failed to initialize", error);
  1214. }
  1215. },
  1216. };
  1217.  
  1218. const IngestDBManager = {
  1219. db: null,
  1220.  
  1221. init: () => {
  1222. return new Promise((resolve, reject) => {
  1223. const request = indexedDB.open(DB_NAME, DB_VERSION);
  1224.  
  1225. request.onerror = () => {
  1226. Logger.error("Failed to open IndexedDB");
  1227. reject(request.error);
  1228. };
  1229.  
  1230. request.onsuccess = (event) => {
  1231. IngestDBManager.db = event.target.result;
  1232. Logger.log("IndexedDB opened successfully");
  1233. resolve();
  1234. };
  1235.  
  1236. request.onupgradeneeded = (event) => {
  1237. const db = event.target.result;
  1238. if (!db.objectStoreNames.contains(STORE_NAME)) {
  1239. const store = db.createObjectStore(STORE_NAME, {
  1240. keyPath: "chatId",
  1241. });
  1242. store.createIndex("repoUrl", "repoUrl", { unique: false });
  1243. Logger.log("IndexedDB store created");
  1244. }
  1245. };
  1246. });
  1247. },
  1248.  
  1249. getAllStates: () => {
  1250. return new Promise((resolve, reject) => {
  1251. const transaction = IngestDBManager.db.transaction(
  1252. [STORE_NAME],
  1253. "readonly"
  1254. );
  1255. const store = transaction.objectStore(STORE_NAME);
  1256. const request = store.getAll();
  1257.  
  1258. request.onsuccess = () => resolve(request.result);
  1259. request.onerror = () => reject(request.error);
  1260. });
  1261. },
  1262.  
  1263. getState: (chatId) => {
  1264. return new Promise((resolve, reject) => {
  1265. if (!chatId) {
  1266. resolve(null);
  1267. return;
  1268. }
  1269.  
  1270. const transaction = IngestDBManager.db.transaction(
  1271. [STORE_NAME],
  1272. "readonly"
  1273. );
  1274. const store = transaction.objectStore(STORE_NAME);
  1275. const request = store.get(chatId);
  1276.  
  1277. request.onsuccess = () => resolve(request.result);
  1278. request.onerror = () => reject(request.error);
  1279. });
  1280. },
  1281.  
  1282. saveState: (state) => {
  1283. return new Promise((resolve, reject) => {
  1284. const transaction = IngestDBManager.db.transaction(
  1285. [STORE_NAME],
  1286. "readwrite"
  1287. );
  1288. const store = transaction.objectStore(STORE_NAME);
  1289. const request = store.put(state);
  1290.  
  1291. request.onsuccess = () => resolve(request.result);
  1292. request.onerror = () => reject(request.error);
  1293. });
  1294. },
  1295.  
  1296. deleteState: (chatId) => {
  1297. return new Promise((resolve, reject) => {
  1298. const transaction = IngestDBManager.db.transaction(
  1299. [STORE_NAME],
  1300. "readwrite"
  1301. );
  1302. const store = transaction.objectStore(STORE_NAME);
  1303. const request = store.delete(chatId);
  1304.  
  1305. request.onsuccess = () => resolve(request.result);
  1306. request.onerror = () => reject(request.error);
  1307. });
  1308. },
  1309.  
  1310. clearAll: () => {
  1311. return new Promise((resolve, reject) => {
  1312. const transaction = IngestDBManager.db.transaction(
  1313. [STORE_NAME],
  1314. "readwrite"
  1315. );
  1316. const store = transaction.objectStore(STORE_NAME);
  1317. const request = store.clear();
  1318.  
  1319. request.onsuccess = () => resolve(request.result);
  1320. request.onerror = () => reject(request.error);
  1321. });
  1322. },
  1323. };
  1324.  
  1325. const MenuCommands = {
  1326. init: () => {
  1327. return new Promise((resolve) => {
  1328. GM_registerMenuCommand("Toggle debug logs", () => {
  1329. GM_getValue(GM_STORAGE_KEYS.DEBUG, false)
  1330. .then((currentDebug) => {
  1331. const newDebug = !currentDebug;
  1332. return GM_setValue(GM_STORAGE_KEYS.DEBUG, newDebug);
  1333. })
  1334. .then(() => {
  1335. debugMode = !debugMode;
  1336. Logger.log(
  1337. `Debug mode toggled to: ${debugMode} via menu. Reloading...`
  1338. );
  1339. location.reload();
  1340. })
  1341. .catch((err) => {
  1342. Logger.error("Failed to toggle debug mode:", err);
  1343. });
  1344. });
  1345.  
  1346. GM_registerMenuCommand("Reset Gemini API Key", () => {
  1347. GM_setValue(GM_STORAGE_KEYS.GEMINI_API_KEY, "")
  1348. .then(() => {
  1349. geminiApiKey = null;
  1350. Logger.log("Gemini API Key reset via menu.");
  1351. location.reload();
  1352. })
  1353. .catch((err) => {
  1354. Logger.error("Failed to reset Gemini API key:", err);
  1355. });
  1356. });
  1357.  
  1358. GM_registerMenuCommand("Reset RepoGist API URL", () => {
  1359. GM_setValue(GM_STORAGE_KEYS.API_URL, "")
  1360. .then(() => {
  1361. apiUrl = null;
  1362. Logger.log("RepoGist API URL reset via menu.");
  1363. location.reload();
  1364. })
  1365. .catch((err) => {
  1366. Logger.error("Failed to reset RepoGist API URL:", err);
  1367. });
  1368. });
  1369.  
  1370. GM_registerMenuCommand("Reset IndexedDB for all chats", () => {
  1371. IngestDBManager.clearAll()
  1372. .then(() => {
  1373. Logger.log("IndexedDB reset via menu.");
  1374. location.reload();
  1375. })
  1376. .catch((err) => {
  1377. Logger.error("Failed to reset IndexedDB:", err);
  1378. });
  1379. });
  1380.  
  1381. Logger.log("Menu commands registered.");
  1382. resolve();
  1383. });
  1384. },
  1385. };
  1386.  
  1387. function main() {
  1388. try {
  1389. debugMode = GM_getValue(GM_STORAGE_KEYS.DEBUG, false);
  1390. Logger.log(
  1391. `${SCRIPT_NAME} v${SCRIPT_VERSION} starting. Debug mode: ${debugMode}`
  1392. );
  1393.  
  1394. FetchInterceptor.init();
  1395. MenuCommands.init().catch((err) => {
  1396. Logger.error("Failed to initialize menu commands:", err);
  1397. });
  1398. IngestDBManager.init().catch((err) => {
  1399. Logger.error("Failed to initialize database:", err);
  1400. });
  1401. StyleManager.injectGlobalStyles();
  1402.  
  1403. GM_getValue(GM_STORAGE_KEYS.API_URL)
  1404. .then((url) => {
  1405. apiUrl = url;
  1406. return GM_getValue(GM_STORAGE_KEYS.GEMINI_API_KEY);
  1407. })
  1408. .then((key) => {
  1409. geminiApiKey = key;
  1410. if (!apiUrl || !geminiApiKey) {
  1411. Logger.log(
  1412. "RepoGist API URL or Gemini API Key not found. It will be requested upon first import attempt."
  1413. );
  1414. } else {
  1415. Logger.log("RepoGist API URL and Gemini API Key loaded.");
  1416. }
  1417. })
  1418. .catch((err) => {
  1419. Logger.error("Failed to load API configuration:", err);
  1420. });
  1421.  
  1422. let lastChatId = getChatId();
  1423. const urlObserver = new MutationObserver(() => {
  1424. const currentChatId = getChatId();
  1425. if (currentChatId !== lastChatId) {
  1426. lastChatId = currentChatId;
  1427. injectButtonWithRetry().catch((err) => {
  1428. Logger.error("Failed to inject button:", err);
  1429. });
  1430. IngestDBManager.getState(currentChatId)
  1431. .then((state) => {
  1432. const importButton = document.getElementById(UI_IDS.importButton);
  1433. if (importButton) {
  1434. if (state && state.repoUrl) {
  1435. importButton.innerHTML = `${githubSVG} ${getRepoNamefromURL(
  1436. state.repoUrl
  1437. )
  1438. .split("/")
  1439. .pop()
  1440. .slice(0, 10)
  1441. .replace(/^./, (c) => c.toUpperCase())}`;
  1442. importButton.classList.add(CSS_CLASSES.importButtonOn);
  1443. importButton.setAttribute("aria-label", "Disable import");
  1444. importButton.setAttribute("data-state", "open");
  1445. importButton.dataset.mode = "on";
  1446. } else {
  1447. importButton.innerHTML = `${githubSVG} Import Repo`;
  1448. importButton.classList.remove(CSS_CLASSES.importButtonOn);
  1449. importButton.setAttribute("aria-label", "Enable import");
  1450. importButton.setAttribute("data-state", "closed");
  1451. importButton.dataset.mode = "off";
  1452. }
  1453. }
  1454. })
  1455. .catch((err) => {
  1456. Logger.error("Failed to update button state:", err);
  1457. });
  1458. }
  1459. });
  1460.  
  1461. urlObserver.observe(document.querySelector("title"), {
  1462. subtree: true,
  1463. characterData: true,
  1464. childList: true,
  1465. });
  1466.  
  1467. const injectButtonWithRetry = (maxRetries = 5, delay = 1000) => {
  1468. let retries = 0;
  1469.  
  1470. const tryInjection = () => {
  1471. const injectionObserverTargetParent = document.querySelector(
  1472. selectors.messageActions
  1473. );
  1474.  
  1475. if (injectionObserverTargetParent) {
  1476. UIManager.injectImportButton()
  1477. .then((success) => {
  1478. if (success) {
  1479. Logger.log("Successfully injected import button");
  1480. return true;
  1481. }
  1482. })
  1483. .catch((err) => {
  1484. Logger.error("Failed to inject button:", err);
  1485. });
  1486. }
  1487.  
  1488. if (retries < maxRetries) {
  1489. retries++;
  1490. Logger.log(
  1491. `Retrying button injection (${retries}/${maxRetries})...`
  1492. );
  1493. setTimeout(() => tryInjection(), delay);
  1494. return false;
  1495. }
  1496.  
  1497. return false;
  1498. };
  1499.  
  1500. return tryInjection();
  1501. };
  1502.  
  1503. injectButtonWithRetry()
  1504. .then((success) => {
  1505. if (!success) {
  1506. Logger.log("Initial button injection failed, setting up observers");
  1507.  
  1508. const injectionObserver = new MutationObserver(() => {
  1509. UIManager.injectImportButton()
  1510. .then((success) => {
  1511. if (success) {
  1512. Logger.log("Button injected via mutation observer");
  1513. }
  1514. })
  1515. .catch((err) => {
  1516. Logger.error("Failed to inject button:", err);
  1517. });
  1518. });
  1519.  
  1520. const documentObserver = new MutationObserver(() => {
  1521. const target = document.querySelector(selectors.messageActions);
  1522. if (target) {
  1523. injectButtonWithRetry()
  1524. .then((success) => {
  1525. if (success) {
  1526. Logger.log("Button injected via document observer");
  1527. }
  1528. })
  1529. .catch((err) => {
  1530. Logger.error("Failed to inject button:", err);
  1531. });
  1532. }
  1533. });
  1534.  
  1535. const messageActionsContainer = document.querySelector(
  1536. selectors.messageActions
  1537. );
  1538. if (messageActionsContainer) {
  1539. injectionObserver.observe(messageActionsContainer, {
  1540. childList: true,
  1541. subtree: true,
  1542. });
  1543. }
  1544.  
  1545. documentObserver.observe(document.body, {
  1546. childList: true,
  1547. subtree: true,
  1548. });
  1549. }
  1550. })
  1551. .catch((err) => {
  1552. Logger.error("Failed to initialize button injection:", err);
  1553. });
  1554.  
  1555. Logger.log(`${SCRIPT_NAME} v${SCRIPT_VERSION} initialized!`);
  1556. } catch (error) {
  1557. Logger.error("Failed to initialize:", error);
  1558. }
  1559. }
  1560.  
  1561. main();
  1562. })();