Greasy Fork

AskChatGPT

Ask ChatGPT

目前为 2022-12-09 提交的版本。查看 最新版本

// ==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'>
                        &copy; 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)
        );
    }

})();