// ==UserScript==
// @name AskChatGPT
// @name:zh 问问 ChatGPT
// @namespace https://youthlin.com/?p=1850
// @version 0.2
// @description Ask ChatGPT
// @description:zh 划词提问 ChatGPT
// @author Youth.霖
// @license MIT
// @match *://*/*
// @include *://*/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @grant GM_xmlhttpRequest
// ==/UserScript==
(function () {
'use strict';
// https://violentmonkey.github.io/api/gm/
// 选中内容自动弹出复制、翻译按钮,怎么实现的?js获取页面光标选中的内容
// https://juejin.cn/post/7083680217494978597
const ApiMapKey = 'api_map'
const DefaultApiMap = JSON.stringify({
"原站": {
session_url: 'https://chat.openai.com/api/auth/session',
token: '',
conversation_url: 'https://chat.openai.com/backend-api/conversation',
conversation_mode: false,
},
"gpt.chatapi.art": {
session_url: '',// 暂时无需鉴权
token: '',
conversation_url: 'https://gpt.chatapi.art/backend-api/conversation',
conversation_mode: true,
}
})
const ready = function (fn) {
if (document.readyState !== 'loading') {
fn();
} else {
document.addEventListener('DOMContentLoaded', fn);
}
}
let selectApi // 当前 api
let selectText = '' // 选中的文字
let conversationID = '' // 一次会话的标记
setTimeout(start, 1000) // 入口
function start() {
ready(() => {
initHtml()
})
}
class AskChatGpt extends HTMLElement {
constructor() {// 构造方法
super()
this.shadow = this.attachShadow({ mode: 'closed' })
}
connectedCallback() {// 添加到文档时回调
this.shadow.innerHTML = `<div class='ask-chat-gpt-wrapper'>
<style>
button, select {
padding: .3em;
margin-right: .3em;
color: #1d1d2e;
background-color: #f7f7fa;
border-radius: 4px;
cursor: pointer;
height: 2em;
}
.icon {
display: none;
position: fixed;
top: 0;
left: 0;
z-index: 9999;
}
.wrap {
display: none;
position: absolute;
top: 0;
left: 0;
padding: 1em 1em 0;
border: 1px solid #ccc;
background: #eee;
color: #000;
box-shadow: 3px 3px 3px #ccc;
width: 400px;
max-width: 100%;
z-index: 9999;
}
.msg {
color: red;
}
ol {
padding: 0;
max-height: 50vh;
overflow-y: auto;
}
li {
list-style: none;
}
li div {
padding: .5em;
}
.question {
background: #ccc;
}
.question:before {}
.answer:before {}
textarea {
width: 100%;
background: transparent;
resize: vertical; /*只能上下拉伸*/
}
.bar {
cursor: grabbing; /*手型拖动*/
}
.ask {
background: #3d71ff;
color: #fff;
}
.right {
float: right;
}
.footer {
border-top: 1px solid #ccc;
margin-buttom: 0;
padding-top: 1em;
}
</style>
<button class='icon'>Ask</button>
<div class='wrap'>
<div>
<p class='msg'></p>
<ol id='list'></ol>
<textarea class='q'></textarea>
</div>
<div class='bar'>
<button class='ask'>Ask</button>
<button class='reset'>Reset</button>
<button class='close right'>关闭</button>
<select class='api-list right'></select>
<p class='footer'>
© 2022 Powered by
<a href='https://youthlin.com' target='_blank'>Youth.霖</a>
| <a href='https://youthlin.com/?p=1850' target='_blank'>About</a>
| <a href='https://github.com/youthlin/examples/raw/master/html/demo/tampermonkey/chatgpt.user.js' target='_blank'>Update</a>
</p>
</div>
</div>
</div>`
this.initApiList()
this.setEvents()// 设置各事件处理方法
}
initApiList() {
const select = this.getDom('.api-list')
const apiMap = this.getApiMap()
console.log(apiMap)
if (apiMap.size == 0) {
this.showMsg('无接口可用,请查看帮助文档')
return
}
let lastSelectName = this.getLastSelectName()
for (let key of apiMap.keys()) {
let selected = ''
if (lastSelectName == key) {
selected = 'selected'
}
select.insertAdjacentHTML('beforeend', `<option value="${key}" ${selected}>${key}</option>`)
}
const that = this
function onSelectChange() {
lastSelectName = select.selectedOptions[0].value
selectApi = apiMap.get(lastSelectName)
console.log('selectApi', selectApi)
that.setLastSelectName(lastSelectName)
that.reset()
}
onSelectChange()
select.addEventListener('change', onSelectChange)
}
getApiMap() {
let m = GM_getValue(ApiMapKey, '')
if (m == '') {
m = DefaultApiMap
GM_setValue(ApiMapKey, m)// 保存到脚本数据中,可以通过脚本管理器修改
}
const apiMap = new Map(Object.entries(JSON.parse(m)))
return apiMap
}
getDom(selector) {
return this.shadow.querySelector(selector)
}
showMsg(msg) { this.getDom('.msg').innerText = msg }
clearMsg() { this.getDom('.msg').innerText = '' }
getLastSelectName() { return GM_getValue('selectApi', '') }
setLastSelectName(name) { GM_setValue('selectApi', name) }
setEvents() {
// 选中文本弹出悬浮按钮
this.setOnSelection()
// 点击悬浮按钮事件
this.getDom('.icon').addEventListener('click', this.onClickIcon.bind(this))
// 关闭按钮
this.getDom('.close').addEventListener('click', this.onClose.bind(this))
// 使面板可拖动
this.enableDrag(this.getDom('.bar'), this.getDom('.wrap'))
// 发起查询
this.getDom('.ask').addEventListener('click', this.onAsk.bind(this))
// 重置会话
this.getDom('.reset').addEventListener('click', this.reset.bind(this))
}
setOnSelection() {
window.addEventListener('mouseup', e => {// 鼠标松开
const btn = this.getDom('.icon')
btn.style.display = 'none'// 默认不显示悬浮按钮
try {
const selection = window.getSelection()
const text = selection.toString()
if (!text) { return }
selectText = text// 记住选中文字
// 显示悬浮按钮
btn.style.display = 'block'
btn.style.left = (e.x - 10) + 'px'
btn.style.top = e.y + 10 + 'px'
} catch (err) {
console.log(`onMouseUp err=${err}`)
}
})
}
onClickIcon(e) {
console.log(`click icon`)
console.log(e)
const dom = this.getDom('.wrap')
dom.style.display = 'block'// 显示悬浮面板
dom.style.left = e.pageX + 'px'
dom.style.top = e.pageY + 'px'
this.getDom('.q').value = selectText// 将之前记录的选中文本填充到文本框中
if (conversationID == '') {
this.getDom('.ask').click()// 发起查询
}// 已经有会话时不自动查询选中文字
}
onClose(e) {
const dom = this.getDom('.wrap')
dom.style.display = 'none'
}
enableDrag(dragElement, moveElement) {
if (!moveElement) { moveElement = dragElement }
// https://zh.javascript.info/mouse-drag-and-drop
dragElement.onmousedown = e => {// 在元素上按下时
// clientX 离浏览器左边的距离
// getBoundingClientRect 一个矩形. left=左边离视口的距离, top=顶边离视口距离
// pageX, pageY 里文档左上角的距离
let shiftX = e.clientX - moveElement.getBoundingClientRect().left;
let shiftY = e.clientY - moveElement.getBoundingClientRect().top;
function moveAt(pageX, pageY) {
// pageX - clientX + RectX:
// pageY - clientY + RectY:
moveElement.style.left = pageX - shiftX + 'px'
moveElement.style.top = pageY - shiftY + 'px'
}
function onMove(e) {
moveAt(e.pageX, e.pageY)
}
// moveAt(e.pageX, e.pageY) 不要按下时就漂移
document.addEventListener('mousemove', onMove)
function onUp(e) {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
document.addEventListener('mouseup', onUp)// 任意位置松开
}
dragElement.ondragstart = () => false;
}
async onAsk(e) {
const textarea = this.getDom('.q')
const question = textarea.value
if (question == '') { return }
textarea.value = ''
this.getDom('#list').insertAdjacentHTML('beforeend', `<li>
<div class='question'></div>
<div class='answer'></div>
</li>`)
const list = this.getDom('#list li:last-child')
list.querySelector('.question').innerText = question
const answer = list.querySelector('.answer')
// answer.scrollIntoView() // 会移动整个页面
try {
await doAsk(question, r => answer.innerText = r)
} catch (err) {
console.log(err)
answer.innerText = `Error: ${err}`
}
}
reset() {
conversationID = '';// 会话 id 重置
clearToken()
this.clearMsg()
this.getDom('#list').innerHTML = ''// 对话列表清空
}
}
function initHtml() {
window.customElements.define('ask-chat-gpt', AskChatGpt)
const dom = document.createElement('ask-chat-gpt')
const body = document.getElementsByTagName('body')[0]
body.insertAdjacentElement('beforeend', dom)
}
function getTokenKey() { return `TokenOf_${selectApi.session_url}` }
function clearToken() { GM_setValue(getTokenKey(), '') }
async function getToken() {
let token = GM_getValue(getTokenKey(), '')
if (token == '') {
token = await doGetToken()
GM_setValue(getTokenKey(), token)
}
return token
}
async function doGetToken() {
return new Promise((ok, fail) => {
GM_xmlhttpRequest({
url: selectApi.session_url,
onload: function (response) {
const r = JSON.parse(response.responseText)
ok(r.accessToken)
},
onerror: function (err) {
fail(new Error(`Please Login first`))
},
})
})
}
async function doAsk(question, callback) {
let token = ''
if (selectApi.session_url) { // 需要 token
token = await getToken()
}
const data = {
action: "next",
messages: [
{
id: generateUUID(),
role: "user",
content: {
content_type: "text",
parts: [question],
},
},
],
parent_message_id: generateUUID(),
model: "text-davinci-002-render",
}
if (conversationID != '') {
if (selectApi.conversation_mode) {
data.conversation_id = conversationID
} else {
console.log('当前 API 还不支持会话模式')
}
}
const url = new URL(selectApi.conversation_url)
let headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
Accept: 'text/event-stream',
Origin: url.origin,
Referer: url.origin,
'x-openai-assistant-app-id': '',
}
console.log(`request, headers:`, data, headers)
callback(`思考中...`)
// 不能用 EventSource, 会有跨域问题, 只能通过脚本管理器的 GM_xmlhttpRequest 发起网络请求
GM_xmlhttpRequest({
url: selectApi.conversation_url,
method: 'POST',
headers: headers,
data: JSON.stringify(data),
onprogress: function (response) {
callback(`${response.loaded} 接收数据中...`)
// 这里读取不到 response.response? Why?
},
onreadystatechange: function (e) {
// console.log(`state=${e.readyState}`, e)
},
onerror: function (err) {
callback(`Error: ${err}`)
},
onload: function (response) {
callback(`${response.loaded} 接收数据完毕`)
console.log('response:', response)
const status = response.status
const data = response.response
if (status != 200) {
callback(`Error. status=${status}. \n${data}`)
if (status == 401) {
try {
const j = JSON.parse(data)
if (j.detail.code == 'token_expired') {
console.log('token expired')
callback('Token expired, retry...')
clearToken()
getToken().then(token => {
doAsk(token, question, callback)
})
}
} catch (ignore) { }
}
return
}
try {
const r = transData(data)
conversationID = r.conversation_id
callback(r.message?.content?.parts?.[0])
} catch (err) {
callback(`Error: ${err}. \nresponse=${data}`)
}
},
})
}
function transData(data) {
const arr = data.split('\n\n')
let r = '{}'
for (let i = arr.length - 1; i >= 0; i--) {
if (arr[i] == '' || arr[i] == 'data: [DONE]') {
continue
}
r = arr[i].substring('data: '.length)
break
}
return JSON.parse(r)
}
function generateUUID() {// 这个是 ChatGPT 给出的算法
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
);
}
})();