ChatGPT LaTeX Auto Render (OpenAI, new bing, you, etc.)

自动渲染 ChatGPT 页面 (OpenAI, new bing, you 等) 上的 LaTeX 数学公式。

  1. // ==UserScript==
  2. // @name ChatGPT LaTeX Auto Render (OpenAI, new bing, you, etc.)
  3. // @version 0.6.13
  4. // @author Scruel Tao
  5. // @homepage https://github.com/scruel/tampermonkey-scripts
  6. // @description Auto typeset LaTeX math formulas on ChatGPT pages (OpenAI, new bing, you, etc.).
  7. // @description:zh-CN 自动渲染 ChatGPT 页面 (OpenAI, new bing, you 等) 上的 LaTeX 数学公式。
  8. // @match https://chatgpt.com/*
  9. // @match https://platform.openai.com/playground/*
  10. // @match https://www.bing.com/search?*
  11. // @match https://you.com/search?*&tbm=youchat*
  12. // @match https://www.you.com/search?*&tbm=youchat*
  13. // @namespace http://tampermonkey.net/
  14. // @icon https://chatgpt.com/favicon.ico
  15. // @grant none
  16. // @noframes
  17. // ==/UserScript==
  18.  
  19. "use strict";
  20.  
  21. const PARSED_MARK = "_sc_parsed";
  22. const MARKDOWN_RERENDER_MARK = "sc_mktag";
  23.  
  24. const MARKDOWN_SYMBOL_UNDERLINE = "XXXSCUEDLXXX";
  25. const MARKDOWN_SYMBOL_ASTERISK = "XXXSCAESKXXX";
  26.  
  27. function queryAddNoParsed(query) {
  28. return query + ":not([" + PARSED_MARK + "])";
  29. }
  30.  
  31. function showTipsElement() {
  32. const tipsElement = window._sc_ChatLatex.tipsElement;
  33. tipsElement.style.position = "fixed";
  34. tipsElement.style.right = "10px";
  35. tipsElement.style.top = "10px";
  36. tipsElement.style.background = "#333";
  37. tipsElement.style.color = "#fff";
  38. tipsElement.style.zIndex = "999999";
  39. var tipContainer = document.body.querySelector("header");
  40. if (!tipContainer) {
  41. tipContainer = document.body;
  42. }
  43. tipContainer.appendChild(tipsElement);
  44. }
  45.  
  46. function setTipsElementText(text, errorRaise = false) {
  47. window._sc_ChatLatex.tipsElement.innerHTML = text;
  48. if (errorRaise) {
  49. throw text;
  50. }
  51. console.log(text);
  52. }
  53.  
  54. async function addScript(url) {
  55. const scriptElement = document.createElement("script");
  56. const headElement =
  57. document.getElementsByTagName("head")[0] || document.documentElement;
  58. if (!headElement.appendChild(scriptElement)) {
  59. // Prevent appendChild overwritten problem.
  60. headElement.append(scriptElement);
  61. }
  62. scriptElement.src = url;
  63. }
  64.  
  65. function traverseDOM(element, callback, onlySingle = true) {
  66. if (!onlySingle || !element.hasChildNodes()) {
  67. callback(element);
  68. }
  69. element = element.firstChild;
  70. while (element) {
  71. traverseDOM(element, callback, onlySingle);
  72. element = element.nextSibling;
  73. }
  74. }
  75.  
  76. function getExtraInfoAddedMKContent(content) {
  77. // Ensure that the whitespace before and after the same
  78. content = content.replaceAll(/( *\*+ *)/g, MARKDOWN_SYMBOL_ASTERISK + "$1");
  79. content = content.replaceAll(/( *_+ *)/g, MARKDOWN_SYMBOL_UNDERLINE + "$1");
  80. // Ensure render for single line
  81. content = content.replaceAll(
  82. new RegExp(`^${MARKDOWN_SYMBOL_ASTERISK}(\\*+)`, "gm"),
  83. `${MARKDOWN_SYMBOL_ASTERISK} $1`
  84. );
  85. content = content.replaceAll(
  86. new RegExp(`^${MARKDOWN_SYMBOL_UNDERLINE}(_+)`, "gm"),
  87. `${MARKDOWN_SYMBOL_UNDERLINE} $1`
  88. );
  89. return content;
  90. }
  91.  
  92. function removeEleMKExtraInfo(ele) {
  93. traverseDOM(ele, function (e) {
  94. if (e.textContent) {
  95. e.textContent = removeMKExtraInfo(e.textContent);
  96. }
  97. });
  98. }
  99.  
  100. function removeMKExtraInfo(content) {
  101. content = content.replaceAll(MARKDOWN_SYMBOL_UNDERLINE, "");
  102. content = content.replaceAll(MARKDOWN_SYMBOL_ASTERISK, "");
  103. return content;
  104. }
  105.  
  106. function getLastMKSymbol(ele, defaultSymbol) {
  107. if (!ele) {
  108. return defaultSymbol;
  109. }
  110. const content = ele.textContent.trim();
  111. if (content.endsWith(MARKDOWN_SYMBOL_UNDERLINE)) {
  112. return "_";
  113. }
  114. if (content.endsWith(MARKDOWN_SYMBOL_ASTERISK)) {
  115. return "*";
  116. }
  117. return defaultSymbol;
  118. }
  119.  
  120. function restoreMarkdown(msgEle, tagName, defaultSymbol) {
  121. const eles = msgEle.querySelectorAll(tagName);
  122. eles.forEach((e) => {
  123. const restoredNodes = document
  124. .createRange()
  125. .createContextualFragment(e.innerHTML);
  126. const fn = restoredNodes.childNodes[0];
  127. const ln = restoredNodes.childNodes[restoredNodes.childNodes.length - 1];
  128. const wrapperSymbol = getLastMKSymbol(e.previousSibling, defaultSymbol);
  129. fn.textContent = wrapperSymbol + fn.textContent;
  130. ln.textContent = ln.textContent + wrapperSymbol;
  131. restoredNodes.prepend(
  132. document.createComment(
  133. MARKDOWN_RERENDER_MARK + "|0|" + tagName + "|" + wrapperSymbol.length
  134. )
  135. );
  136. restoredNodes.append(
  137. document.createComment(MARKDOWN_RERENDER_MARK + "|1|" + tagName)
  138. );
  139. e.parentElement.insertBefore(restoredNodes, e);
  140. e.parentNode.removeChild(e);
  141. });
  142. removeEleMKExtraInfo(msgEle);
  143. }
  144.  
  145. function restoreAllMarkdown(msgEle) {
  146. restoreMarkdown(msgEle, "em", "_");
  147. }
  148.  
  149. function rerenderAllMarkdown(msgEle) {
  150. // restore HTML from restored markdown comment info
  151. const startComments = [];
  152. traverseDOM(msgEle, function (n) {
  153. if (n.nodeType !== 8) {
  154. return;
  155. }
  156. const text = n.textContent.trim();
  157. if (!text.startsWith(MARKDOWN_RERENDER_MARK)) {
  158. return;
  159. }
  160. const tokens = text.split("|");
  161. if (tokens[1] === "0") {
  162. startComments.push(n);
  163. }
  164. });
  165. // Reverse to prevent nested elements
  166. startComments.reverse().forEach((n) => {
  167. const tokens = n.textContent.trim().split("|");
  168. const tagName = tokens[2];
  169. const tagRepLen = tokens[3];
  170. const tagEle = document.createElement(tagName);
  171. n.parentElement.insertBefore(tagEle, n);
  172. n.parentNode.removeChild(n);
  173. let subEle = tagEle.nextSibling;
  174. while (subEle) {
  175. if (subEle.nodeType == 8) {
  176. const text = subEle.textContent.trim();
  177. if (
  178. text.startsWith(MARKDOWN_RERENDER_MARK) &&
  179. text.split("|")[1] === "1"
  180. ) {
  181. subEle.parentNode.removeChild(subEle);
  182. break;
  183. }
  184. }
  185. tagEle.appendChild(subEle);
  186. subEle = tagEle.nextSibling;
  187. }
  188. // Remove previously added markdown symbols.
  189. tagEle.firstChild.textContent =
  190. tagEle.firstChild.textContent.substring(tagRepLen);
  191. tagEle.lastChild.textContent = tagEle.lastChild.textContent.substring(
  192. 0,
  193. tagEle.lastChild.textContent.length - tagRepLen
  194. );
  195. });
  196. }
  197.  
  198. async function prepareScript() {
  199. window._sc_beforeTypesetMsgEle = (msgEle) => {};
  200. window._sc_afterTypesetMsgEle = (msgEle) => {};
  201. window._sc_typeset = () => {
  202. try {
  203. console.log('[LaTeX] Typesetting...')
  204. const msgEles = window._sc_getMsgEles();
  205. msgEles.forEach((msgEle) => {
  206. restoreAllMarkdown(msgEle);
  207. msgEle.setAttribute(PARSED_MARK, "");
  208.  
  209. window._sc_beforeTypesetMsgEle(msgEle);
  210. MathJax.typesetPromise([msgEle]);
  211. window._sc_afterTypesetMsgEle(msgEle);
  212.  
  213. rerenderAllMarkdown(msgEle);
  214. });
  215. } catch (e) {
  216. console.warn(e);
  217. }
  218. };
  219. window._sc_mutationHandler = (mutation) => {
  220. if (mutation.oldValue === "") {
  221. window._sc_typeset();
  222. }
  223. };
  224. window._sc_chatLoaded = () => {
  225. return true;
  226. };
  227. window._sc_getObserveElement = () => {
  228. return null;
  229. };
  230. var observerOptions = {
  231. attributeOldValue: true,
  232. attributeFilter: ["cancelable", "disabled"],
  233. };
  234. var afterMainOvservationStart = () => {
  235. window._sc_typeset();
  236. };
  237.  
  238. // Handle special cases per site.
  239. if (window.location.host === "www.bing.com") {
  240. window._sc_getObserveElement = () => {
  241. const ele = document.querySelector("#b_sydConvCont > cib-serp");
  242. if (!ele) {
  243. return null;
  244. }
  245. return ele.shadowRoot.querySelector("#cib-action-bar-main");
  246. };
  247.  
  248. const getContMsgEles = (cont, isInChat = true) => {
  249. if (!cont) {
  250. return [];
  251. }
  252. const allChatTurn = cont.shadowRoot
  253. .querySelector("#cib-conversation-main")
  254. .shadowRoot.querySelectorAll("cib-chat-turn");
  255. var lastChatTurnSR = allChatTurn[allChatTurn.length - 1];
  256. if (isInChat) {
  257. lastChatTurnSR = lastChatTurnSR.shadowRoot;
  258. }
  259. const allCibMsgGroup =
  260. lastChatTurnSR.querySelectorAll("cib-message-group");
  261. const allCibMsg = Array.from(allCibMsgGroup)
  262. .map((e) => Array.from(e.shadowRoot.querySelectorAll("cib-message")))
  263. .flatMap((e) => e);
  264. return Array.from(allCibMsg)
  265. .map((cibMsg) => cibMsg.shadowRoot.querySelector("cib-shared"))
  266. .filter((e) => e);
  267. };
  268. window._sc_getMsgEles = () => {
  269. try {
  270. const convCont = document.querySelector("#b_sydConvCont > cib-serp");
  271. const tigerCont = document.querySelector("#b_sydTigerCont > cib-serp");
  272. return getContMsgEles(convCont).concat(
  273. getContMsgEles(tigerCont, false)
  274. );
  275. } catch (ignore) {
  276. return [];
  277. }
  278. };
  279. } else if (window.location.host === "chat.openai.com") {
  280. window._sc_getObserveElement = () => {
  281. return document.querySelector("main > div > div > div");
  282. };
  283. window._sc_chatLoaded = () => {
  284. return (
  285. document.querySelector("main div.text-sm>svg.animate-spin") === null
  286. );
  287. };
  288.  
  289. observerOptions = {
  290. attributes: true,
  291. childList: true,
  292. subtree: true,
  293. };
  294.  
  295. window._sc_mutationHandler = (mutation) => {
  296. if (mutation.removedNodes.length) {
  297. return;
  298. }
  299. const target = mutation.target;
  300. if (!target || target.tagName !== "DIV") {
  301. return;
  302. }
  303. const buttons = target.querySelectorAll("button");
  304. if (buttons.length !== 3 || !target.classList.contains("visible")) {
  305. return;
  306. }
  307. if (
  308. mutation.type === "attributes" ||
  309. (mutation.addedNodes.length && mutation.addedNodes[0] == buttons[1])
  310. ) {
  311. window._sc_typeset();
  312. }
  313. };
  314.  
  315. afterMainOvservationStart = () => {
  316. window._sc_typeset();
  317. // Handle conversation switch
  318. new MutationObserver(async (mutationList) => {
  319. for (var mutation of mutationList) {
  320. if (!mutation.addedNodes.length) {
  321. continue;
  322. }
  323. const addedNode = mutation.addedNodes[0];
  324. // Check if first added node is normal node
  325. if (addedNode.nodeType !== 1) {
  326. return;
  327. }
  328. // console.log(mutation);
  329. const mainNode = addedNode.parentElement;
  330. if (mainNode && mainNode.tagName !== 'MAIN') {
  331. continue;
  332. }
  333. startMainOvservation(
  334. await getMainObserveElement(true),
  335. observerOptions
  336. );
  337. window._sc_typeset();
  338. break;
  339. };
  340. }).observe(document.querySelector("#__next"), { childList: true, subtree: true});
  341. };
  342.  
  343. window._sc_getMsgEles = () => {
  344. return document.querySelectorAll(
  345. queryAddNoParsed("div.w-full div.text-base div.items-start")
  346. );
  347. };
  348.  
  349. window._sc_beforeTypesetMsgEle = (msgEle) => {
  350. // Prevent latex typeset conflict
  351. const displayEles = msgEle.querySelectorAll(".math-display");
  352. displayEles.forEach((e) => {
  353. const texEle = e.querySelector(".katex-mathml annotation");
  354. e.removeAttribute("class");
  355. e.textContent = "$$" + texEle.textContent + "$$";
  356. });
  357. const inlineEles = msgEle.querySelectorAll(".math-inline");
  358. inlineEles.forEach((e) => {
  359. const texEle = e.querySelector(".katex-mathml annotation");
  360. e.removeAttribute("class");
  361. // e.textContent = "$" + texEle.textContent + "$";
  362. // Mathjax will typeset this with display mode.
  363. e.textContent = "$$" + texEle.textContent + "$$";
  364. });
  365. };
  366. window._sc_afterTypesetMsgEle = (msgEle) => {
  367. // https://github.com/mathjax/MathJax/issues/3008
  368. msgEle.style.display = "unset";
  369. };
  370. } else if (
  371. window.location.host === "you.com" ||
  372. window.location.host === "www.you.com"
  373. ) {
  374. window._sc_getObserveElement = () => {
  375. return document.querySelector("#chatHistory");
  376. };
  377. window._sc_chatLoaded = () => {
  378. return !!document.querySelector(
  379. "#chatHistory div[data-pinnedconversationturnid]"
  380. );
  381. };
  382. observerOptions = { childList: true };
  383.  
  384. window._sc_mutationHandler = (mutation) => {
  385. mutation.addedNodes.forEach((e) => {
  386. const attr = e.getAttribute("data-testid");
  387. if (attr && attr.startsWith("youchat-convTurn")) {
  388. startTurnAttrObservationForTypesetting(
  389. e,
  390. "data-pinnedconversationturnid"
  391. );
  392. }
  393. });
  394. };
  395.  
  396. window._sc_getMsgEles = () => {
  397. return document.querySelectorAll(
  398. queryAddNoParsed('#chatHistory div[data-testid="youchat-answer"]')
  399. );
  400. };
  401. }
  402. console.log("Waiting for chat loading...");
  403. const mainElement = await getMainObserveElement();
  404. console.log("Chat loaded.");
  405. startMainOvservation(mainElement, observerOptions);
  406. afterMainOvservationStart();
  407. }
  408.  
  409. function enbaleResultPatcher() {
  410. // TODO: refractor all code.
  411. if (window.location.host !== "chat.openai.com") {
  412. return;
  413. }
  414. const oldJSONParse = JSON.parse;
  415. JSON.parse = function _parse() {
  416. const res = oldJSONParse.apply(this, arguments);
  417. if (res.hasOwnProperty("message")) {
  418. const message = res.message;
  419. if (message.hasOwnProperty("end_turn") && message.end_turn) {
  420. message.content.parts[0] = getExtraInfoAddedMKContent(
  421. message.content.parts[0]
  422. );
  423. }
  424. }
  425. return res;
  426. };
  427.  
  428. const responseHandler = (response, result) => {
  429. if (
  430. result.hasOwnProperty("mapping") &&
  431. result.hasOwnProperty("current_node")
  432. ) {
  433. Object.keys(result.mapping).forEach((key) => {
  434. const mapObj = result.mapping[key];
  435. if (mapObj.hasOwnProperty("message")) {
  436. if (mapObj.message.author.role === "user") {
  437. return;
  438. }
  439. const contentObj = mapObj.message.content;
  440. contentObj.parts[0] = getExtraInfoAddedMKContent(contentObj.parts[0]);
  441. }
  442. });
  443. }
  444. };
  445. let oldfetch = fetch;
  446. window.fetch = function patchedFetch() {
  447. return new Promise((resolve, reject) => {
  448. oldfetch
  449. .apply(this, arguments)
  450. .then((response) => {
  451. const oldJson = response.json;
  452. response.json = function () {
  453. return new Promise((resolve, reject) => {
  454. oldJson
  455. .apply(this, arguments)
  456. .then((result) => {
  457. try {
  458. responseHandler(response, result);
  459. } catch (e) {
  460. console.warn(e);
  461. }
  462. resolve(result);
  463. })
  464. .catch((e) => reject(e));
  465. });
  466. };
  467. resolve(response);
  468. })
  469. .catch((e) => reject(e));
  470. });
  471. };
  472.  
  473. // Resote
  474. const oldClipBoardWriteText = navigator.clipboard.writeText;
  475. navigator.clipboard.writeText = function patchedWriteText() {
  476. return new Promise((resolve, reject) => {
  477. arguments[0] = removeMKExtraInfo(arguments[0]);
  478. oldClipBoardWriteText
  479. .apply(this, arguments)
  480. .then((response) => {
  481. resolve(response);
  482. })
  483. .catch((e) => reject(e));
  484. });
  485. };
  486. }
  487.  
  488. // After output completed, the attribute of turn element will be changed,
  489. // only with observer won't be enough, so we have this function for sure.
  490. function startTurnAttrObservationForTypesetting(element, doneWithAttr) {
  491. const tmpObserver = new MutationObserver((mutationList, observer) => {
  492. mutationList.forEach((mutation) => {
  493. if (mutation.oldValue === null) {
  494. window._sc_typeset();
  495. observer.disconnect;
  496. }
  497. });
  498. });
  499. tmpObserver.observe(element, {
  500. attributeOldValue: true,
  501. attributeFilter: [doneWithAttr],
  502. });
  503. if (element.hasAttribute(doneWithAttr)) {
  504. window._sc_typeset();
  505. tmpObserver.disconnect;
  506. }
  507. }
  508.  
  509. function getMainObserveElement(chatLoaded = false) {
  510. return new Promise(async (resolve, reject) => {
  511. const resolver = () => {
  512. const ele = window._sc_getObserveElement();
  513. if (ele && (chatLoaded || window._sc_chatLoaded())) {
  514. return resolve(ele);
  515. }
  516. window.setTimeout(resolver, 500);
  517. };
  518. resolver();
  519. });
  520. }
  521.  
  522. function startMainOvservation(mainElement, observerOptions) {
  523. const callback = (mutationList, observer) => {
  524. mutationList.forEach((mutation) => {
  525. window._sc_mutationHandler(mutation);
  526. });
  527. };
  528. if (window._sc_mainObserver) {
  529. window._sc_mainObserver.disconnect();
  530. }
  531. window._sc_mainObserver = new MutationObserver(callback);
  532. window._sc_mainObserver.observe(mainElement, observerOptions);
  533. }
  534.  
  535. async function waitMathJaxLoaded() {
  536. while (!MathJax.hasOwnProperty("typeset")) {
  537. if (window._sc_ChatLatex.loadCount > 20000 / 200) {
  538. setTipsElementText("Failed to load MathJax, try refresh.", true);
  539. }
  540. await new Promise((x) => setTimeout(x, 500));
  541. window._sc_ChatLatex.loadCount += 1;
  542. }
  543. }
  544.  
  545. function hideTipsElement(timeout = 3) {
  546. window.setTimeout(() => {
  547. window._sc_ChatLatex.tipsElement.hidden = true;
  548. }, 3000);
  549. }
  550.  
  551. async function loadMathJax() {
  552. showTipsElement();
  553. setTipsElementText("Loading MathJax...");
  554. addScript("https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js");
  555. await waitMathJaxLoaded();
  556. setTipsElementText("MathJax Loaded.");
  557. hideTipsElement();
  558. }
  559.  
  560. (async function () {
  561. window._sc_ChatLatex = {
  562. tipsElement: document.createElement("div"),
  563. loadCount: 0,
  564. };
  565. window.MathJax = {
  566. tex: {
  567. inlineMath: [
  568. ["$", "$"],
  569. ["\\(", "\\)"],
  570. ],
  571. displayMath: [["$$", "$$", ["\\[", "\\]"]]],
  572. },
  573. startup: {
  574. typeset: false,
  575. },
  576. };
  577.  
  578. enbaleResultPatcher();
  579. await loadMathJax();
  580. await prepareScript();
  581. })();