AskChatGPT

Ask ChatGPT

  1. // ==UserScript==
  2. // @name AskChatGPT
  3. // @name:zh 问问 ChatGPT
  4. // @namespace https://youthlin.com/?p=1850
  5. // @version 0.2
  6. // @description Ask ChatGPT
  7. // @description:zh 划词提问 ChatGPT
  8. // @author Youth.霖
  9. // @license MIT
  10. // @match *://*/*
  11. // @include *://*/*
  12. // @grant GM_getValue
  13. // @grant GM_setValue
  14. // @grant GM_addStyle
  15. // @grant GM_registerMenuCommand
  16. // @grant GM_xmlhttpRequest
  17. // ==/UserScript==
  18. (function () {
  19. 'use strict';
  20.  
  21. // https://violentmonkey.github.io/api/gm/
  22.  
  23. // 选中内容自动弹出复制、翻译按钮,怎么实现的?js获取页面光标选中的内容
  24. // https://juejin.cn/post/7083680217494978597
  25.  
  26.  
  27. const ApiMapKey = 'api_map'
  28. const DefaultApiMap = JSON.stringify({
  29. "原站": {
  30. session_url: 'https://chat.openai.com/api/auth/session',
  31. token: '',
  32. conversation_url: 'https://chat.openai.com/backend-api/conversation',
  33. conversation_mode: false,
  34. },
  35. "gpt.chatapi.art": {
  36. session_url: '',// 暂时无需鉴权
  37. token: '',
  38. conversation_url: 'https://gpt.chatapi.art/backend-api/conversation',
  39. conversation_mode: true,
  40. }
  41. })
  42.  
  43. const ready = function (fn) {
  44. if (document.readyState !== 'loading') {
  45. fn();
  46. } else {
  47. document.addEventListener('DOMContentLoaded', fn);
  48. }
  49. }
  50.  
  51. let selectApi // 当前 api
  52. let selectText = '' // 选中的文字
  53. let conversationID = '' // 一次会话的标记
  54.  
  55. setTimeout(start, 1000) // 入口
  56.  
  57. function start() {
  58. ready(() => {
  59. initHtml()
  60. })
  61. }
  62.  
  63. class AskChatGpt extends HTMLElement {
  64.  
  65. constructor() {// 构造方法
  66. super()
  67. this.shadow = this.attachShadow({ mode: 'closed' })
  68. }
  69.  
  70. connectedCallback() {// 添加到文档时回调
  71. this.shadow.innerHTML = `<div class='ask-chat-gpt-wrapper'>
  72. <style>
  73. button, select {
  74. padding: .3em;
  75. margin-right: .3em;
  76. color: #1d1d2e;
  77. background-color: #f7f7fa;
  78. border-radius: 4px;
  79. cursor: pointer;
  80. height: 2em;
  81. }
  82. .icon {
  83. display: none;
  84. position: fixed;
  85. top: 0;
  86. left: 0;
  87. z-index: 9999;
  88. }
  89. .wrap {
  90. display: none;
  91. position: absolute;
  92. top: 0;
  93. left: 0;
  94. padding: 1em 1em 0;
  95. border: 1px solid #ccc;
  96. background: #eee;
  97. color: #000;
  98. box-shadow: 3px 3px 3px #ccc;
  99. width: 400px;
  100. max-width: 100%;
  101. z-index: 9999;
  102. }
  103. .msg {
  104. color: red;
  105. }
  106. ol {
  107. padding: 0;
  108. max-height: 50vh;
  109. overflow-y: auto;
  110. }
  111. li {
  112. list-style: none;
  113. }
  114. li div {
  115. padding: .5em;
  116. }
  117. .question {
  118. background: #ccc;
  119. }
  120. .question:before {}
  121. .answer:before {}
  122. textarea {
  123. width: 100%;
  124. background: transparent;
  125. resize: vertical; /*只能上下拉伸*/
  126. }
  127. .bar {
  128. cursor: grabbing; /*手型拖动*/
  129. }
  130. .ask {
  131. background: #3d71ff;
  132. color: #fff;
  133. }
  134. .right {
  135. float: right;
  136. }
  137. .footer {
  138. border-top: 1px solid #ccc;
  139. margin-buttom: 0;
  140. padding-top: 1em;
  141. }
  142. </style>
  143. <button class='icon'>Ask</button>
  144. <div class='wrap'>
  145. <div>
  146. <p class='msg'></p>
  147. <ol id='list'></ol>
  148. <textarea class='q'></textarea>
  149. </div>
  150. <div class='bar'>
  151. <button class='ask'>Ask</button>
  152. <button class='reset'>Reset</button>
  153. <button class='close right'>关闭</button>
  154. <select class='api-list right'></select>
  155. <p class='footer'>
  156. &copy; 2022 Powered by
  157. <a href='https://youthlin.com' target='_blank'>Youth.霖</a>
  158. | <a href='https://youthlin.com/?p=1850' target='_blank'>About</a>
  159. | <a href='https://github.com/youthlin/examples/raw/master/html/demo/tampermonkey/chatgpt.user.js' target='_blank'>Update</a>
  160. </p>
  161. </div>
  162. </div>
  163. </div>`
  164. this.initApiList()
  165. this.setEvents()// 设置各事件处理方法
  166. }
  167.  
  168. initApiList() {
  169. const select = this.getDom('.api-list')
  170. const apiMap = this.getApiMap()
  171. console.log(apiMap)
  172. if (apiMap.size == 0) {
  173. this.showMsg('无接口可用,请查看帮助文档')
  174. return
  175. }
  176. let lastSelectName = this.getLastSelectName()
  177. for (let key of apiMap.keys()) {
  178. let selected = ''
  179. if (lastSelectName == key) {
  180. selected = 'selected'
  181. }
  182. select.insertAdjacentHTML('beforeend', `<option value="${key}" ${selected}>${key}</option>`)
  183. }
  184. const that = this
  185. function onSelectChange() {
  186. lastSelectName = select.selectedOptions[0].value
  187. selectApi = apiMap.get(lastSelectName)
  188. console.log('selectApi', selectApi)
  189. that.setLastSelectName(lastSelectName)
  190. that.reset()
  191. }
  192. onSelectChange()
  193. select.addEventListener('change', onSelectChange)
  194. }
  195.  
  196. getApiMap() {
  197. let m = GM_getValue(ApiMapKey, '')
  198. if (m == '') {
  199. m = DefaultApiMap
  200. GM_setValue(ApiMapKey, m)// 保存到脚本数据中,可以通过脚本管理器修改
  201. }
  202. const apiMap = new Map(Object.entries(JSON.parse(m)))
  203. return apiMap
  204. }
  205.  
  206. getDom(selector) {
  207. return this.shadow.querySelector(selector)
  208. }
  209.  
  210. showMsg(msg) { this.getDom('.msg').innerText = msg }
  211. clearMsg() { this.getDom('.msg').innerText = '' }
  212.  
  213. getLastSelectName() { return GM_getValue('selectApi', '') }
  214. setLastSelectName(name) { GM_setValue('selectApi', name) }
  215.  
  216. setEvents() {
  217. // 选中文本弹出悬浮按钮
  218. this.setOnSelection()
  219. // 点击悬浮按钮事件
  220. this.getDom('.icon').addEventListener('click', this.onClickIcon.bind(this))
  221. // 关闭按钮
  222. this.getDom('.close').addEventListener('click', this.onClose.bind(this))
  223. // 使面板可拖动
  224. this.enableDrag(this.getDom('.bar'), this.getDom('.wrap'))
  225. // 发起查询
  226. this.getDom('.ask').addEventListener('click', this.onAsk.bind(this))
  227. // 重置会话
  228. this.getDom('.reset').addEventListener('click', this.reset.bind(this))
  229. }
  230.  
  231. setOnSelection() {
  232. window.addEventListener('mouseup', e => {// 鼠标松开
  233. const btn = this.getDom('.icon')
  234. btn.style.display = 'none'// 默认不显示悬浮按钮
  235. try {
  236. const selection = window.getSelection()
  237. const text = selection.toString()
  238. if (!text) { return }
  239. selectText = text// 记住选中文字
  240. // 显示悬浮按钮
  241. btn.style.display = 'block'
  242. btn.style.left = (e.x - 10) + 'px'
  243. btn.style.top = e.y + 10 + 'px'
  244. } catch (err) {
  245. console.log(`onMouseUp err=${err}`)
  246. }
  247. })
  248. }
  249.  
  250. onClickIcon(e) {
  251. console.log(`click icon`)
  252. console.log(e)
  253. const dom = this.getDom('.wrap')
  254. dom.style.display = 'block'// 显示悬浮面板
  255. dom.style.left = e.pageX + 'px'
  256. dom.style.top = e.pageY + 'px'
  257. this.getDom('.q').value = selectText// 将之前记录的选中文本填充到文本框中
  258. if (conversationID == '') {
  259. this.getDom('.ask').click()// 发起查询
  260. }// 已经有会话时不自动查询选中文字
  261. }
  262.  
  263. onClose(e) {
  264. const dom = this.getDom('.wrap')
  265. dom.style.display = 'none'
  266. }
  267.  
  268. enableDrag(dragElement, moveElement) {
  269. if (!moveElement) { moveElement = dragElement }
  270. // https://zh.javascript.info/mouse-drag-and-drop
  271. dragElement.onmousedown = e => {// 在元素上按下时
  272. // clientX 离浏览器左边的距离
  273. // getBoundingClientRect 一个矩形. left=左边离视口的距离, top=顶边离视口距离
  274. // pageX, pageY 里文档左上角的距离
  275.  
  276. let shiftX = e.clientX - moveElement.getBoundingClientRect().left;
  277. let shiftY = e.clientY - moveElement.getBoundingClientRect().top;
  278.  
  279. function moveAt(pageX, pageY) {
  280. // pageX - clientX + RectX:
  281. // pageY - clientY + RectY:
  282. moveElement.style.left = pageX - shiftX + 'px'
  283. moveElement.style.top = pageY - shiftY + 'px'
  284. }
  285. function onMove(e) {
  286. moveAt(e.pageX, e.pageY)
  287. }
  288.  
  289. // moveAt(e.pageX, e.pageY) 不要按下时就漂移
  290.  
  291. document.addEventListener('mousemove', onMove)
  292.  
  293. function onUp(e) {
  294. document.removeEventListener('mousemove', onMove)
  295. document.removeEventListener('mouseup', onUp)
  296. }
  297. document.addEventListener('mouseup', onUp)// 任意位置松开
  298. }
  299.  
  300. dragElement.ondragstart = () => false;
  301. }
  302.  
  303. async onAsk(e) {
  304. const textarea = this.getDom('.q')
  305. const question = textarea.value
  306. if (question == '') { return }
  307.  
  308. textarea.value = ''
  309. this.getDom('#list').insertAdjacentHTML('beforeend', `<li>
  310. <div class='question'></div>
  311. <div class='answer'></div>
  312. </li>`)
  313. const list = this.getDom('#list li:last-child')
  314. list.querySelector('.question').innerText = question
  315. const answer = list.querySelector('.answer')
  316. // answer.scrollIntoView() // 会移动整个页面
  317. try {
  318. await doAsk(question, r => answer.innerText = r)
  319. } catch (err) {
  320. console.log(err)
  321. answer.innerText = `Error: ${err}`
  322. }
  323. }
  324.  
  325. reset() {
  326. conversationID = '';// 会话 id 重置
  327. clearToken()
  328. this.clearMsg()
  329. this.getDom('#list').innerHTML = ''// 对话列表清空
  330. }
  331. }
  332.  
  333. function initHtml() {
  334. window.customElements.define('ask-chat-gpt', AskChatGpt)
  335. const dom = document.createElement('ask-chat-gpt')
  336. const body = document.getElementsByTagName('body')[0]
  337. body.insertAdjacentElement('beforeend', dom)
  338. }
  339.  
  340. function getTokenKey() { return `TokenOf_${selectApi.session_url}` }
  341. function clearToken() { GM_setValue(getTokenKey(), '') }
  342.  
  343. async function getToken() {
  344. let token = GM_getValue(getTokenKey(), '')
  345. if (token == '') {
  346. token = await doGetToken()
  347. GM_setValue(getTokenKey(), token)
  348. }
  349. return token
  350. }
  351.  
  352. async function doGetToken() {
  353. return new Promise((ok, fail) => {
  354. GM_xmlhttpRequest({
  355. url: selectApi.session_url,
  356. onload: function (response) {
  357. const r = JSON.parse(response.responseText)
  358. ok(r.accessToken)
  359. },
  360. onerror: function (err) {
  361. fail(new Error(`Please Login first`))
  362. },
  363. })
  364. })
  365. }
  366.  
  367. async function doAsk(question, callback) {
  368. let token = ''
  369. if (selectApi.session_url) { // 需要 token
  370. token = await getToken()
  371. }
  372. const data = {
  373. action: "next",
  374. messages: [
  375. {
  376. id: generateUUID(),
  377. role: "user",
  378. content: {
  379. content_type: "text",
  380. parts: [question],
  381. },
  382. },
  383. ],
  384. parent_message_id: generateUUID(),
  385. model: "text-davinci-002-render",
  386. }
  387. if (conversationID != '') {
  388. if (selectApi.conversation_mode) {
  389. data.conversation_id = conversationID
  390. } else {
  391. console.log('当前 API 还不支持会话模式')
  392. }
  393. }
  394. const url = new URL(selectApi.conversation_url)
  395. let headers = {
  396. 'Content-Type': 'application/json',
  397. Authorization: `Bearer ${token}`,
  398. Accept: 'text/event-stream',
  399. Origin: url.origin,
  400. Referer: url.origin,
  401. 'x-openai-assistant-app-id': '',
  402. }
  403. console.log(`request, headers:`, data, headers)
  404. callback(`思考中...`)
  405. // 不能用 EventSource, 会有跨域问题, 只能通过脚本管理器的 GM_xmlhttpRequest 发起网络请求
  406. GM_xmlhttpRequest({
  407. url: selectApi.conversation_url,
  408. method: 'POST',
  409. headers: headers,
  410. data: JSON.stringify(data),
  411. onprogress: function (response) {
  412. callback(`${response.loaded} 接收数据中...`)
  413. // 这里读取不到 response.response? Why?
  414. },
  415. onreadystatechange: function (e) {
  416. // console.log(`state=${e.readyState}`, e)
  417. },
  418. onerror: function (err) {
  419. callback(`Error: ${err}`)
  420. },
  421. onload: function (response) {
  422. callback(`${response.loaded} 接收数据完毕`)
  423. console.log('response:', response)
  424. const status = response.status
  425. const data = response.response
  426. if (status != 200) {
  427. callback(`Error. status=${status}. \n${data}`)
  428. if (status == 401) {
  429. try {
  430. const j = JSON.parse(data)
  431. if (j.detail.code == 'token_expired') {
  432. console.log('token expired')
  433. callback('Token expired, retry...')
  434. clearToken()
  435. getToken().then(token => {
  436. doAsk(token, question, callback)
  437. })
  438. }
  439. } catch (ignore) { }
  440. }
  441. return
  442. }
  443. try {
  444. const r = transData(data)
  445. conversationID = r.conversation_id
  446. callback(r.message?.content?.parts?.[0])
  447. } catch (err) {
  448.  
  449. callback(`Error: ${err}. \nresponse=${data}`)
  450. }
  451. },
  452. })
  453. }
  454.  
  455. function transData(data) {
  456. const arr = data.split('\n\n')
  457. let r = '{}'
  458. for (let i = arr.length - 1; i >= 0; i--) {
  459. if (arr[i] == '' || arr[i] == 'data: [DONE]') {
  460. continue
  461. }
  462. r = arr[i].substring('data: '.length)
  463. break
  464. }
  465. return JSON.parse(r)
  466. }
  467.  
  468. function generateUUID() {// 这个是 ChatGPT 给出的算法
  469. return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
  470. (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
  471. );
  472. }
  473.  
  474. })();