ChatGPT with Date

Tampermonkey plugin for displaying ChatGPT historical and real-time conversation time. 显示 ChatGPT 历史对话时间 与 实时对话时间的 Tampermonkey 插件。

As of 02.05.2024. See ბოლო ვერსია.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name            ChatGPT with Date
// @namespace       https://github.com/jiang-taibai/chatgpt-with-date
// @version         1.1.0
// @description     Tampermonkey plugin for displaying ChatGPT historical and real-time conversation time. 显示 ChatGPT 历史对话时间 与 实时对话时间的 Tampermonkey 插件。
// @author          CoderJiang
// @license         MIT
// @match           https://chat.openai.com/*
// @icon            https://cdn.coderjiang.com/project/chatgpt-with-date/logo.svg
// @grant           GM_xmlhttpRequest
// @grant           GM_registerMenuCommand
// @grant           GM_setValue
// @grant           GM_getValue
// @grant           unsafeWindow
// @run-at          document-end
// ==/UserScript==

(function () {
    'use strict';

    class SystemConfig {
        static TimeRender = {
            Interval: 1000,
            TimeClassName: 'chatgpt-time',
            Selectors: [{
                Selector: '.chatgpt-time', Style: {
                    'font-size': '14px', 'color': '#666', 'margin-left': '5px', 'font-weight': 'normal',
                }
            }],
            RenderRetryCount: 3,
            RenderModes: ['AfterRoleLeft', 'AfterRoleRight', 'BelowRole'],
            RenderModeStyles: {
                'AfterRoleLeft': {
                    'font-size': '14px', 'color': '#666', 'margin-left': '5px', 'font-weight': 'normal',
                }, 'AfterRoleRight': {
                    'font-size': '14px', 'color': '#666', 'font-weight': 'normal', 'float': 'right'
                }, 'BelowRole': {
                    'font-size': '14px', 'color': '#666', 'font-weight': 'normal', 'display': 'block',
                },
            },
            TimeTagTemplates: [
                `<span>{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}</span>`,
                `<span>{MM}/{dd}/{yyyy} {HH}:{mm}:{ss}</span>`,
                `<span>{MM}-{dd} {HH}:{mm}:{ss}</span>`,
                `<span>{MM}-{dd} {HH}:{mm}</span>`,
                `<span>{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}.{ms}</span>`,
                `<span style="background: #2B2B2b;border-radius: 8px;padding: 1px 10px;color: #717171;">{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}</span>`,
                `<span style="border-radius: 8px; color: #E0E0E0; font-size: 0.9em; overflow: hidden; display: inline-block;"><span style="background: #333; padding: 2px 4px 2px 10px; display: inline-block;">{yyyy}-{MM}-{dd}</span><span style="background: #606060; padding: 2px 10px 2px 4px; display: inline-block;">{HH}:{mm}:{ss}</span></span>`,
            ],
        }
        static ConfigPanel = {
            AppID: 'CWD-Configuration-Panel', Icon: {
                Close: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M649.179 512l212.839-212.84c37.881-37.881 37.881-99.298 0-137.179s-99.298-37.881-137.179 0L512 374.821l-212.839-212.84c-37.881-37.881-99.298-37.881-137.179 0s-37.881 99.298 0 137.179L374.821 512 161.982 724.84c-37.881 37.881-37.881 99.297 0 137.179 18.94 18.94 43.765 28.41 68.589 28.41 24.825 0 49.649-9.47 68.589-28.41L512 649.179l212.839 212.84c18.94 18.94 43.765 28.41 68.589 28.41s49.649-9.47 68.59-28.41c37.881-37.882 37.881-99.298 0-137.179L649.179 512z"></path></svg>',
            }
        }
        // GM 存储的键
        static GMStorageKey = {
            UserConfig: 'ChatGPTWithDate-UserConfig', ConfigPanel: {
                Position: 'ChatGPTWithDate-ConfigPanel-Position', Size: 'ChatGPTWithDate-ConfigPanel-Size',
            },
        }
    }

    class Logger {
        static EnableLog = true
        static EnableDebug = true
        static EnableInfo = true
        static EnableWarn = true
        static EnableError = true
        static EnableTable = true

        static log(...args) {
            if (Logger.EnableLog) {
                console.log(...args);
            }
        }

        static debug(...args) {
            if (Logger.EnableDebug) {
                console.debug(...args);
            }
        }

        static info(...args) {
            if (Logger.EnableInfo) {
                console.info(...args);
            }
        }

        static warn(...args) {
            if (Logger.EnableWarn) {
                console.warn(...args);
            }
        }

        static error(...args) {
            if (Logger.EnableError) {
                console.error(...args);
            }
        }

        static table(...args) {
            if (Logger.EnableTable) {
                console.table(...args);
            }
        }
    }

    class Utils {

        /**
         * 计算时间
         *
         * @param timestamp 时间戳,浮点数或整数类型,单位毫秒,例如 1714398759.26881、1714398759
         * @returns {{milliseconds: number, hours: number, seconds: number, month: number, year: number, minutes: number, day: number}}
         */
        static calculateTime(timestamp) {
            const date = new Date(timestamp);
            const year = date.getFullYear();
            const month = date.getMonth() + 1;
            const day = date.getDate();
            const hours = date.getHours();
            const minutes = date.getMinutes();
            const seconds = date.getSeconds();
            const milliseconds = date.getMilliseconds();
            return {
                year, month, day, hours, minutes, seconds, milliseconds
            };
        }

        /**
         * 格式化日期时间
         * @param year      年份
         * @param month     月份
         * @param day       日期
         * @param hour      小时
         * @param minute    分钟
         * @param second    秒
         * @param milliseconds  毫秒
         * @param template  模板,例如 'yyyy-MM-dd HH:mm:ss'
         * @returns string  格式化后的日期时间字符串
         */
        static formatDateTime(year, month, day, hour, minute, second, milliseconds, template) {
            return template
                .replace('{yyyy}', year)
                .replace('{MM}', month.toString().padStart(2, '0'))
                .replace('{dd}', day.toString().padStart(2, '0'))
                .replace('{HH}', hour.toString().padStart(2, '0'))
                .replace('{mm}', minute.toString().padStart(2, '0'))
                .replace('{ss}', second.toString().padStart(2, '0'))
                .replace('{ms}', milliseconds.toString().padStart(3, '0'));
        }

        /**
         * 检查依赖关系图(有向图)是否有循环依赖,如果没有就返回一个先后顺序(即按照此顺序实例化不会出现依赖项为空的情况)。
         * 给定依赖关系图为此结构[{node:'ComponentClass0', dependencies:['ComponentClass2', 'ComponentClass3']}, ...]
         * @param dependencyGraph   依赖关系图
         * @returns {*[]}
         */
        static dependencyAnalysis(dependencyGraph) {
            // 创建一个映射每个节点到其入度的对象
            const inDegree = {};
            const graph = {};
            const order = [];

            // 初始化图和入度表
            dependencyGraph.forEach(item => {
                const {node, dependencies} = item;
                if (!graph[node]) {
                    graph[node] = [];
                    inDegree[node] = 0;
                }
                dependencies.forEach(dependentNode => {
                    if (!graph[dependentNode]) {
                        graph[dependentNode] = [];
                        inDegree[dependentNode] = 0;
                    }
                    graph[dependentNode].push(node);
                    inDegree[node]++;
                });
            });

            // 将所有入度为0的节点加入到队列中
            const queue = [];
            for (const node in inDegree) {
                if (inDegree[node] === 0) {
                    queue.push(node);
                }
            }

            // 处理队列中的节点
            while (queue.length) {
                const current = queue.shift();
                order.push(current);
                graph[current].forEach(neighbour => {
                    inDegree[neighbour]--;
                    if (inDegree[neighbour] === 0) {
                        queue.push(neighbour);
                    }
                });
            }

            // 如果排序后的节点数量不等于图中的节点数量,说明存在循环依赖
            if (order.length !== Object.keys(graph).length) {
                // 找到循环依赖的节点
                const cycleNodes = [];
                for (const node in inDegree) {
                    if (inDegree[node] !== 0) {
                        cycleNodes.push(node);
                    }
                }
                throw new Error("存在循环依赖的节点:" + cycleNodes.join(","));
            }
            return order;
        }

    }

    class MessageBO {
        /**
         * 消息业务对象
         *
         * @param messageId 消息ID,为消息元素的data-message-id属性值
         * @param role      角色
         *                  system:     表示系统消息,并不属于聊天内容。    从 API 获取
         *                  tool:       也表示系统消息。                 从 API 获取
         *                  assistant:  表示 ChatGPT 回答的消息。        从 API 获取
         *                  user:       表示用户输入的消息。              从 API 获取
         *                  You:        表示用户输入的消息。              从页面实时获取
         *                  ChatGPT:    表示 ChatGPT 回答的消息。        从页面实时获取
         * @param timestamp 时间戳,浮点数或整数类型,单位毫秒,例如 1714398759.26881、1714398759
         * @param message   消息内容
         */
        constructor(messageId, role, timestamp, message = '') {
            this.messageId = messageId;
            this.role = role;
            this.timestamp = timestamp;
            this.message = message;
        }
    }

    class MessageElementBO {
        /**
         * 消息元素业务对象
         *
         * @param rootEle       消息的根元素,并非是总根元素,而是 roleEle 和 messageEle 的最近公共祖先元素
         * @param roleEle       角色元素,例如 <div>ChatGPT</div>
         * @param messageEle    消息元素,包含 data-message-id 属性
         *                      例如 <div data-message-id="123456">你好</div>
         */
        constructor(rootEle, roleEle, messageEle) {
            this.rootEle = rootEle;
            this.roleEle = roleEle;
            this.messageEle = messageEle;
        }
    }

    class Component {

        constructor() {
            this.dependencies = []
            Object.defineProperty(this, 'initDependencies', {
                value: function () {
                    this.dependencies.forEach(dependency => {
                        this[dependency.field] = ComponentLocator.get(dependency.clazz)
                    })
                }, writable: false,        // 防止方法被修改
                configurable: false     // 防止属性被重新定义或删除
            });
        }

        init() {
        }
    }

    class ComponentLocator {
        /**
         * 组件注册器,用于注册和获取组件
         */
        static components = {};

        /**
         * 注册组件,要求组件为 Component 的子类
         *
         * @param clazz     Component 的子类
         * @param instance  Component 的子类的实例化对象,必顧是 clazz 的实例
         * @returns obj 返回注册的实例化对象
         */
        static register(clazz, instance) {
            if (!(instance instanceof Component)) {
                throw new Error(`实例化对象 ${instance} 不是 Component 的实例。`);
            }
            if (!(instance instanceof clazz)) {
                throw new Error(`实例化对象 ${instance} 不是 ${clazz} 的实例。`);
            }
            if (ComponentLocator.components[clazz.name]) {
                throw new Error(`组件 ${clazz.name} 已经注册过了。`);
            }
            ComponentLocator.components[clazz.name] = instance;
            return instance
        }

        /**
         * 获取组件,用于完成组件之间的依赖注入
         *
         * @param clazz    Component 的子类
         * @returns {*}    返回注册的实例化对象
         */
        static get(clazz) {
            if (!ComponentLocator.components[clazz.name]) {
                throw new Error(`组件 ${clazz.name} 未注册。`);
            }
            return ComponentLocator.components[clazz.name];
        }
    }

    class UserConfig extends Component {

        init() {
            this.timeRender = {
                mode: 'AfterRoleLeft', format: SystemConfig.TimeRender.TimeTagTemplates[0],
            }
            const userConfig = this.load()
            if (userConfig) {
                Object.assign(this.timeRender, userConfig.timeRender)
            }
        }

        save() {
            GM_setValue(SystemConfig.GMStorageKey.UserConfig, {
                timeRender: this.timeRender
            })
        }

        load() {
            return GM_getValue(SystemConfig.GMStorageKey.UserConfig, {})
        }

        /**
         * 更新配置并保存
         * @param newConfig 新的配置
         */
        update(newConfig) {
            Object.assign(this.timeRender, newConfig.timeRender)
            this.save()
        }
    }

    class StyleService extends Component {
        init() {
            this.styles = new Map()
            this._initStyleElement()
            this._reRenderStyle()
        }

        /**
         * 初始化样式元素,该元素用于存放动态生成的样式
         * @private
         */
        _initStyleElement() {
            const styleElement = document.createElement('style');
            styleElement.type = 'text/css';
            document.head.appendChild(styleElement);
            this.styleEle = styleElement;
        }

        /**
         * 更新样式选择器的样式,合并原有样式和新样式
         *
         * @param selector  选择器,例如 '.chatgpt-time' 表示选择 class 为 chatgpt-time 的元素
         * @param style     样式,字典对象,例如 {'font-size': '14px', 'color': '#666'}
         */
        updateStyle(selector, style) {
            const newStyle = Object.assign({}, this.styles.get(selector), style)
            this.styles.set(selector, newStyle)
            this._reRenderStyle()
        }

        /**
         * 重置一个样式选择器的样式,覆盖原有样式
         *
         * @param selector  选择器,例如 '.chatgpt-time' 表示选择 class 为 chatgpt-time 的元素
         * @param style     样式,字典对象,例如 {'font-size': '14px', 'color': '#666'}
         */
        resetOneSelector(selector, style) {
            this.styles.set(selector, style)
            this._reRenderStyle()
        }

        /**
         * 重置多个样式选择器的样式,覆盖原有样式
         *
         * @param selectors 选择器数组,例如 ['.chatgpt-time', '.chatgpt-time2']
         * @param styles    样式数组,例如 [{'font-size': '14px', 'color': '#666'}, {'font-size': '16px', 'color': '#666'}]
         */
        resetBatchSelectors(selectors, styles) {
            for (let i = 0; i < selectors.length; i++) {
                this.styles.set(selectors[i], styles[i])
            }
            this._reRenderStyle()
        }

        /**
         * 重新渲染样式,即把 this.styles 中的样式同步到 style 元素中。
         * 该方法会清空原有的样式,然后重新生成。
         * @private
         */
        _reRenderStyle() {
            let styleText = ''
            for (let [selector, style] of this.styles) {
                let styleStr = ''
                for (let [key, value] of Object.entries(style)) {
                    styleStr += `${key}: ${value};`
                }
                styleText += `${selector} {${styleStr}}`
            }
            this.styleEle.innerHTML = styleText
        }
    }

    class MessageService extends Component {
        init() {
            this.messages = new Map();
        }

        /**
         * 解析消息元素,获取消息的所有内容。由于网页中不存在时间戳,所以时间戳使用当前时间代替。
         * 调用该方法只需要消息元素,一般用于从页面实时监测获取到的消息。
         *
         * @param messageDiv    消息元素,包含 data-message-id 属性 的 div 元素
         * @returns {MessageBO|undefined}   返回消息业务对象,如果消息元素不存在则返回 undefined
         */
        parseMessageDiv(messageDiv) {
            if (!messageDiv) {
                return;
            }
            const messageId = messageDiv.getAttribute('data-message-id');
            const messageElementBO = this.getMessageElement(messageId)
            if (!messageElementBO) {
                return;
            }
            let timestamp = new Date().getTime();
            const role = messageElementBO.roleEle.innerText;
            const message = messageElementBO.messageEle.innerHTML;
            if (!this.messages.has(messageId)) {
                const messageBO = new MessageBO(messageId, role, timestamp, message);
                this.messages.set(messageId, messageBO);
            }
            return this.messages.get(messageId);
        }

        /**
         * 添加消息,主要用于添加从 API 劫持到的消息列表。
         * 调用该方法需要已知消息的所有内容,如果只知道消息元素则应该使用 parseMessageDiv 方法获取消息业务对象。
         *
         * @param message   消息业务对象
         * @param force     是否强制添加,如果为 true 则强制添加,否则如果消息已经存在则不添加
         * @returns {boolean}   返回是否添加成功
         */
        addMessage(message, force = false) {
            if (this.messages.has(message.messageId) && !force) {
                return false;
            }
            this.messages.set(message.messageId, message);
            return true
        }

        /**
         * 通过消息 ID 获取消息元素业务对象
         *
         * @param messageId 消息 ID
         * @returns {MessageElementBO|undefined}  返回消息元素业务对象
         */
        getMessageElement(messageId) {
            const messageDiv = document.body.querySelector(`div[data-message-id="${messageId}"]`);
            if (!messageDiv) {
                return;
            }
            const rootDiv = messageDiv.parentElement.parentElement.parentElement;
            const roleDiv = rootDiv.firstChild;
            return new MessageElementBO(rootDiv, roleDiv, messageDiv);
        }

        /**
         * 通过消息 ID 获取消息业务对象
         * @param messageId
         * @returns {any}
         */
        getMessage(messageId) {
            return this.messages.get(messageId);
        }

        /**
         * 显示所有消息信息
         */
        showMessages() {
            Logger.table(Array.from(this.messages.values()));
        }
    }

    class MonitorService extends Component {
        constructor() {
            super();
            this.messageService = null
            this.timeRendererService = null
            this.dependencies = [{field: 'messageService', clazz: MessageService}, {
                field: 'timeRendererService', clazz: TimeRendererService
            },]
        }

        init() {
            this.totalTime = 0;
            this.originalFetch = window.fetch;
            this._initMonitorFetch();
            this._initMonitorAddedMessageNode();
        }

        /**
         * 初始化劫持 fetch 方法,用于监控 ChatGPT 的消息数据
         *
         * @private
         */
        _initMonitorFetch() {
            const that = this;
            unsafeWindow.fetch = (...args) => {
                return that.originalFetch.apply(this, args)
                    .then(response => {
                        // 克隆响应对象以便独立处理响应体
                        const clonedResponse = response.clone();
                        if (response.url.includes('https://chat.openai.com/backend-api/conversation/')) {
                            clonedResponse.json().then(data => {
                                that._parseConversationJsonData(data);
                            }).catch(error => Logger.error('解析响应体失败:', error));
                        }
                        return response;
                    });
            };
        }

        /**
         * 解析从 API 获取到的消息数据,该方法存在报错风险,需要在调用时捕获异常以防止中断后续操作。
         *
         * @param obj   从 API 获取到的消息数据
         * @private
         */
        _parseConversationJsonData(obj) {
            const mapping = obj.mapping
            const messageIds = []
            for (let key in mapping) {
                const message = mapping[key].message
                if (message) {
                    const messageId = message.id
                    const role = message.author.role
                    const createTime = message.create_time
                    const messageBO = new MessageBO(messageId, role, createTime * 1000)
                    messageIds.push(messageId)
                    this.messageService.addMessage(messageBO, true)
                }
            }
            this.timeRendererService.addMessageArrayToBeRendered(messageIds)
            this.messageService.showMessages()
        }

        /**
         * 初始化监控节点变化,用于监控在使用 ChatGPT 期间实时输入的消息。
         * 每隔 500ms 检查一次 main 节点是否存在,如果存在则开始监控节点变化。
         * @private
         */
        _initMonitorAddedMessageNode() {
            const interval = setInterval(() => {
                const mainElement = document.querySelector('main');
                if (mainElement) {
                    this._setupMonitorAddedMessageNode(mainElement);
                    clearInterval(interval); // 清除定时器,停止进一步检查
                }
            }, 500);
        }

        /**
         * 设置监控节点变化,用于监控在使用 ChatGPT 期间实时输入的消息。
         * @param supervisedNode    监控在此节点下的节点变化,确保新消息的节点在此节点下
         * @private
         */
        _setupMonitorAddedMessageNode(supervisedNode) {
            const that = this;
            const callback = function (mutationsList, observer) {
                const start = new Date().getTime();
                for (const mutation of mutationsList) {
                    if (mutation.type === 'childList') {
                        mutation.addedNodes.forEach(node => {
                            if (node.nodeType === Node.ELEMENT_NODE) {
                                let messageDiv = node.querySelector('div[data-message-id]');
                                if (!messageDiv && node.hasAttribute('data-message-id')) {
                                    messageDiv = node
                                }
                                if (messageDiv !== null) {
                                    const messageBO = that.messageService.parseMessageDiv(messageDiv);
                                    that.timeRendererService.addMessageToBeRendered(messageBO.messageId);
                                    that.messageService.showMessages()
                                }
                            }
                        });
                    }
                }
                const end = new Date().getTime();
                that.totalTime += (end - start);
                Logger.debug(`监控到节点变化,耗时 ${end - start}ms,总耗时 ${that.totalTime}ms。`);
            };
            const observer = new MutationObserver(callback);
            observer.observe(supervisedNode, {childList: true, subtree: true,});
        }
    }

    class TimeRendererService extends Component {
        constructor() {
            super();
            this.messageService = null
            this.userConfig = null
            this.styleService = null
            this.dependencies = [{field: 'messageService', clazz: MessageService}, {
                field: 'userConfig', clazz: UserConfig
            }, {field: 'styleService', clazz: StyleService},]
        }

        init() {
            this.messageToBeRendered = []
            this.messageCountOfFailedToRender = new Map()
            this._initStyle()
            this._initRender()
        }

        /**
         * 初始化与时间有关的样式
         * @private
         */
        _initStyle() {
            this.styleService.resetBatchSelectors(SystemConfig.TimeRender.Selectors.map(item => item.Selector), SystemConfig.TimeRender.Selectors.map(item => item.Style))
            this.styleService.resetOneSelector(`.${SystemConfig.TimeRender.TimeClassName}`, SystemConfig.TimeRender.RenderModeStyles[this.userConfig.timeRender.mode])
        }

        /**
         * 添加消息 ID 到待渲染队列
         * @param messageId 消息 ID
         */
        addMessageToBeRendered(messageId) {
            if (typeof messageId !== 'string') {
                return
            }
            this.messageToBeRendered.push(messageId)
            Logger.debug(`添加ID ${messageId} 到待渲染队列,当前队列 ${this.messageToBeRendered}`)
        }

        /**
         * 添加消息 ID 到待渲染队列
         * @param messageIdArray 消息 ID数组
         */
        addMessageArrayToBeRendered(messageIdArray) {
            if (!messageIdArray || !(messageIdArray instanceof Array)) {
                return
            }
            messageIdArray.forEach(messageId => this.addMessageToBeRendered(messageId))
        }

        /**
         * 初始化渲染时间的定时器,每隔 SystemConfig.TimeRender.Interval 毫秒处理一次待渲染队列
         * 1. 备份待渲染队列
         * 2. 清空待渲染队列
         * 3. 遍历备份的队列,逐个渲染
         * 3.1 如果渲染失败则重新加入待渲染队列,失败次数加一
         * 3.2 如果渲染成功,清空失败次数
         * 4. 重复 1-3 步骤
         * 5. 如果失败次数超过 SystemConfig.TimeRender.RenderRetryCount 则不再尝试渲染,即不再加入待渲染队列。同时清空失败次数。
         *
         * @private
         */
        _initRender() {
            const that = this

            function processTimeRender() {
                const start = new Date().getTime();
                let completeCount = 0;
                let totalCount = that.messageToBeRendered.length;
                const messageToBeRenderedClone = that.messageToBeRendered.slice()
                that.messageToBeRendered = []
                for (let messageId of messageToBeRenderedClone) {
                    new Promise(resolve => {
                        resolve(that._renderTime(messageId))
                    }).then((result) => {
                        if (!result) {
                            Logger.debug(`ID ${messageId} 渲染失败,当前渲染进度 ${completeCount}/${totalCount}`)
                            let count = that.messageCountOfFailedToRender.get(messageId)
                            if (count && count >= SystemConfig.TimeRender.RenderRetryCount) {
                                Logger.debug(`ID ${messageId} 渲染失败次数超过 ${SystemConfig.TimeRender.RenderRetryCount} 次,将不再尝试。`)
                                that.messageCountOfFailedToRender.delete(messageId)
                            } else {
                                that.messageToBeRendered.push(messageId);
                                if (count) {
                                    that.messageCountOfFailedToRender.set(messageId, count + 1)
                                } else {
                                    that.messageCountOfFailedToRender.set(messageId, 1)
                                }
                            }
                        } else {
                            completeCount++
                            Logger.debug(`ID ${messageId} 渲染完成,当前渲染进度 ${completeCount}/${totalCount}`)
                            that.messageCountOfFailedToRender.delete(messageId)
                        }
                    })
                }
                const end = new Date().getTime();
                Logger.debug(`处理当前ID队列渲染 ${messageToBeRenderedClone} 耗时 ${end - start}ms`)
                setTimeout(processTimeRender, SystemConfig.TimeRender.Interval);
            }

            processTimeRender()
        }

        /**
         * 将时间渲染到目标位置,如果检测到目标位置已经存在时间元素则更新时间,否则创建时间元素并插入到目标位置。
         *
         * @param messageId     消息 ID
         * @returns {boolean}   返回是否渲染成功
         * @private
         */
        _renderTime(messageId) {
            const messageElementBo = this.messageService.getMessageElement(messageId);
            const messageBo = this.messageService.getMessage(messageId);
            if (!messageElementBo || !messageBo) return false;
            const timeElement = messageElementBo.rootEle.querySelector(`.${SystemConfig.TimeRender.TimeClassName}`);
            const element = this._createTimeElement(messageBo.timestamp);
            if (!timeElement) {
                switch (this.userConfig.timeRender.mode) {
                    case 'AfterRoleLeft':
                    case 'AfterRoleRight':
                    case 'BelowRole':
                        messageElementBo.roleEle.innerHTML += element.timeElementHTML
                        break;
                }
            } else {
                timeElement.innerHTML = element.timeString
            }
            return true;
        }

        /**
         * 创建时间元素
         *
         * @param timestamp 时间戳,浮点数或整数类型,单位毫秒,例如 1714398759.26881、1714398759
         * @returns {{timeString, timeElementHTML: string}} 返回时间字符串和时间元素的 HTML
         * @private
         */
        _createTimeElement(timestamp) {
            const time = Utils.calculateTime(timestamp);
            const timeString = Utils.formatDateTime(time.year, time.month, time.day, time.hours, time.minutes, time.seconds, time.milliseconds, this.userConfig.timeRender.format);
            let timeElementHTML = `<span class="${SystemConfig.TimeRender.TimeClassName}">${timeString}</span>`;
            return {
                timeString, timeElementHTML,
            };
        }

        /**
         * 清除所有时间元素
         * @private
         */
        _cleanAllTimeElements() {
            const timeElements = document.body.querySelectorAll(`.${SystemConfig.TimeRender.TimeClassName}`);
            timeElements.forEach(ele => {
                ele.remove()
            })
        }

        /**
         * 重新渲染时间元素,强制拉取所有消息 ID 重新渲染
         */
        reRender() {
            this.styleService.resetOneSelector(`.${SystemConfig.TimeRender.TimeClassName}`, SystemConfig.TimeRender.RenderModeStyles[this.userConfig.timeRender.mode])
            this._cleanAllTimeElements()
            this.addMessageArrayToBeRendered(Array.from(this.messageService.messages.keys()))
        }
    }

    class ConfigPanelService extends Component {

        constructor() {
            super();
            this.userConfig = null
            this.timeRendererService = null
            this.messageService = null
            this.dependencies = [{field: 'userConfig', clazz: UserConfig}, {
                field: 'timeRendererService', clazz: TimeRendererService
            }, {field: 'messageService', clazz: MessageService},]
        }

        /**
         * 初始化配置面板,强调每个子初始化方法阻塞式的执行,即一个初始化方法执行完毕后再执行下一个初始化方法。
         * @returns {Promise<void>}
         */
        async init() {
            this.appID = SystemConfig.ConfigPanel.AppID
            this._initVariables()
            Logger.debug('开始初始化配置面板')
            await this._initStyle()
            Logger.debug('初始化样式完成')
            await this._initScript()
            Logger.debug('初始化脚本完成')
            await this._initPanel()
            Logger.debug('初始化面板完成')
            this._initVue()
            Logger.debug('初始化Vue完成')
            this._initConfigPanelSizeAndPosition()
            this._initConfigPanelEventMonitor()
            Logger.debug('初始化配置面板事件监控完成')
            this._initMenuCommand()
            Logger.debug('初始化菜单命令完成')
        }

        /**
         * 初始化配置面板的 HTML 与 Vue 实例的配置属性。集中管理以便方便修改。
         * @private
         */
        _initVariables() {
            const that = this
            this.panelHTML = `
                <div id="${that.appID}" style="visibility: hidden">
                    <div class="status-bar">
                        <div class="title" id="${that.appID}-DraggableArea">{{title}}</div>
                        <n-button class="close" @click="onClose" text>
                            <n-icon size="20">
                                ${SystemConfig.ConfigPanel.Icon.Close}
                            </n-icon>
                        </n-button>
                    </div>
                    <div class="container">
                        <n-form :label-width="40" :model="configForm" label-placement="left">
                            <n-form-item label="样式" path="format">
                                <n-select v-model:value="configForm.format" filterable tag
                                          :options="formatOptions" @update:value="onConfigUpdate"></n-select>
                            </n-form-item>
                            <n-form-item label="预览" path="format">
                                <div v-html="reFormatTimeHTML(configForm.format)"></div>
                            </n-form-item>
                            <n-form-item label="位置" path="mode">
                                <n-select v-model:value="configForm.mode" 
                                          :options="modeOptions" @update:value="onConfigUpdate"></n-select>
                            </n-form-item>
                        </n-form>
                        <div class="button-group">
                            <n-button @click="onReset" :disabled="!configDirty">重置</n-button>
                            <n-button @click="onApply">应用</n-button>
                            <n-button @click="onConfirm">保存</n-button>
                        </div>
                    </div>
                </div>`
            this.appConfig = {
                el: `#${that.appID}`, data() {
                    return {
                        timestamp: new Date().getTime(),
                        title: "ChatGPTWithDate",
                        formatOptions: SystemConfig.TimeRender.TimeTagTemplates.map(item => {
                            return {label: item, value: item}
                        }),
                        modeOptions: [{label: '角色之后(靠左)', value: 'AfterRoleLeft'}, {
                            label: '角色之后(居右)', value: 'AfterRoleRight'
                        }, {label: '角色之下', value: 'BelowRole'},],
                        configForm: {
                            format: that.userConfig.timeRender.format,
                            mode: that.userConfig.timeRender.mode,
                        },
                        config: {
                            format: that.userConfig.timeRender.format,
                            mode: that.userConfig.timeRender.mode,
                        }, configDirty: false,
                        configPanel: {
                            display: true,
                        },
                    };
                }, methods: {
                    onApply() {
                        this.config = Object.assign({}, this.configForm);
                        that.updateConfig(this.config)
                        this.configDirty = false
                    }, onConfirm() {
                        this.onApply()
                        this.onClose()
                    }, onReset() {
                        this.configForm = Object.assign({}, this.config);
                        this.configDirty = false
                    }, onClose() {
                        that.hide()
                    }, onConfigUpdate() {
                        this.configDirty = true
                    }, renderTag({option, handleClose}) {
                        return Vue.h('div', {
                            innerHTML: this.reFormatTimeHTML(option.value)
                        });
                    }, renderLabel(option) {
                        return Vue.h('div', {
                            innerHTML: this.reFormatTimeHTML(option.value)
                        });
                    }, reFormatTimeHTML(html) {
                        const {
                            year, month, day, hours, minutes, seconds, milliseconds
                        } = Utils.calculateTime(this.timestamp)
                        return Utils.formatDateTime(year, month, day, hours, minutes, seconds, milliseconds, html)
                    },
                },
                created() {
                    this.timestamp = new Date().getTime()
                    this.formatOptions.forEach(item => {
                        item.label = this.reFormatTimeHTML(item.value)
                    })
                },
                mounted() {
                    this.timestampInterval = setInterval(() => {
                        this.timestamp = new Date().getTime()
                        this.formatOptions.forEach(item => {
                            item.label = this.reFormatTimeHTML(item.value)
                        })
                    }, 50)
                },
            }
        }

        /**
         * 初始化样式。
         * 同时为了避免样式冲突,在 head 元素中加入一个 <meta name="naive-ui-style" /> 元素,
         * naive-ui 会把所有的样式刚好插入这个元素的前面。
         * 参考 https://www.naiveui.com/zh-CN/os-theme/docs/style-conflict
         *
         * @returns {Promise}
         * @private
         */
        _initStyle() {
            return new Promise(resolve => {
                const meta = document.createElement('meta');
                meta.name = 'naive-ui-style'
                document.head.appendChild(meta);
                const style = `
                    .v-binder-follower-container {
                        position: fixed;
                    }
                    #CWD-Configuration-Panel {
                        position: absolute;
                        top: 50px;
                        left: 50px;
                        width: 250px;
                        background-color: #FFFFFF;
                        border: #D7D8D9 1px solid;
                        border-radius: 4px;
                        resize: horizontal;
                        min-width: 200px;
                        overflow: auto;
                        color: black;
                        opacity: 0.9;
                    }
            
                    #CWD-Configuration-Panel .status-bar {
                        cursor: move;
                        background-color: #f0f0f0;
                        border-radius: 4px 4px 0 0;
                        display: flex;
                    }
            
                    #CWD-Configuration-Panel .status-bar .title {
                        display: flex;
                        align-items: center;
                        justify-content: left;
                        padding-left: 10px;
                        user-select: none;
                        color: #777;
                        flex: 1;
                        font-weight: bold;
                    }
            
                    #CWD-Configuration-Panel .status-bar .close {
                        cursor: pointer;
                        padding: 10px;
                        transition: color 0.3s;
                    }
            
                    #CWD-Configuration-Panel .status-bar .close:hover {
                        color: #f00;
                    }
            
                    #CWD-Configuration-Panel .container {
                        padding: 20px;
                    }
                    #CWD-Configuration-Panel .container .button-group {
                        display: flex;
                        justify-content: center;
                        gap: 10px;
                    }
                    #CWD-Configuration-Panel .container .button-group > button {
                        width: 30%;
                    }`
                const styleEle = document.createElement('style');
                styleEle.type = 'text/css'
                styleEle.innerHTML = style;
                document.head.appendChild(styleEle);
                resolve()
            })
        }

        /**
         * 初始化 Vue 与 Naive UI 脚本。无法使用 <script src="https://xxx"> 的方式插入,因为 ChatGPT 有 CSP 限制。
         * 采用 GM_xmlhttpRequest 的方式获取 Vue 与 Naive UI 的脚本内容,然后插入 <script>脚本内容</script> 到页面中。
         *
         * @returns {Promise}
         * @private
         */
        _initScript() {
            return new Promise(resolve => {
                let completeCount = 0;
                const addScript = (content) => {
                    let script = document.createElement('script');
                    script.textContent = content;
                    document.body.appendChild(script);
                    completeCount++;
                    if (completeCount === 2) {
                        resolve()
                    }
                }
                GM_xmlhttpRequest({
                    method: "GET", url: "https://unpkg.com/[email protected]/dist/vue.global.js", onload: function (response) {
                        addScript(response.responseText);
                    }
                });
                GM_xmlhttpRequest({
                    method: "GET", url: "https://unpkg.com/[email protected]/dist/index.js", onload: function (response) {
                        addScript(response.responseText);
                    }
                });
                // 以下方法有 CSP 限制
                // const naiveScript = document.createElement('script');
                // naiveScript.setAttribute("type", "text/javascript");
                // naiveScript.text = "https://unpkg.com/[email protected]/dist/index.js";
                // document.documentElement.appendChild(naiveScript);
            })
        }

        /**
         * 初始化配置面板,插入配置面板的 HTML 到 body 中。
         *
         * @returns {Promise}
         * @private
         */
        _initPanel() {
            const that = this
            return new Promise(resolve => {
                const panelRoot = document.createElement('div');
                panelRoot.innerHTML = that.panelHTML;
                document.body.appendChild(panelRoot);
                resolve()
            })
        }

        /**
         * 初始化 Vue 实例,挂载到配置面板的 HTML 元素上。
         *
         * @private
         */
        _initVue() {
            const app = Vue.createApp(this.appConfig);
            app.use(naive)
            app.mount(`#${this.appID}`);
        }

        /**
         * 初始化配置面板大小与位置
         *
         * @private
         */
        _initConfigPanelSizeAndPosition() {
            const panel = document.getElementById(this.appID)

            // 获取存储的大小
            const size = GM_getValue(SystemConfig.GMStorageKey.ConfigPanel.Size, {})
            if (size && size.width && !isNaN(size.width)) {
                panel.style.width = size.width + 'px';
            }

            // 获取存储的位置
            const position = GM_getValue(SystemConfig.GMStorageKey.ConfigPanel.Position, {})
            if (position && position.left && position.top && !isNaN(position.left) && !isNaN(position.top)) {
                const {left, top} = position
                const {refineLeft, refineTop} = this.refinePosition(left, top, panel.offsetWidth, panel.offsetHeight)
                panel.style.left = refineLeft + 'px';
                panel.style.top = refineTop + 'px';
            }

            // 如果面板任何一边超出屏幕,则重置位置
            // const rect = panel.getBoundingClientRect()
            // const leftTop = {
            //     x: rect.left,
            //     y: rect.top
            // }
            // const rightBottom = {
            //     x: rect.left + rect.width,
            //     y: rect.top + rect.height
            // }
            // const screenWidth = window.innerWidth;
            // const screenHeight = window.innerHeight;
            // if (leftTop.x < 0 || leftTop.y < 0 || rightBottom.x > screenWidth || rightBottom.y > screenHeight) {
            //     panel.style.left = '50px';
            //     panel.style.top = '50px';
            // }

        }

        /**
         * 初始化配置面板事件监控,包括面板拖动、面板大小变化等事件。
         *
         * @private
         */
        _initConfigPanelEventMonitor() {
            const that = this
            const panel = document.getElementById(this.appID)
            const draggableArea = document.getElementById(`${this.appID}-DraggableArea`)

            // 监听面板宽度变化
            const resizeObserver = new ResizeObserver(entries => {
                for (let entry of entries) {
                    if (entry.contentRect.width) {
                        GM_setValue(SystemConfig.GMStorageKey.ConfigPanel.Size, {
                            width: entry.contentRect.width,
                        })
                    }
                }
            });
            resizeObserver.observe(panel);

            // 监听面板位置
            draggableArea.addEventListener('mousedown', function (e) {
                const offsetX = e.clientX - draggableArea.getBoundingClientRect().left;
                const offsetY = e.clientY - draggableArea.getBoundingClientRect().top;

                function mouseMoveHandler(e) {
                    const left = e.clientX - offsetX;
                    const top = e.clientY - offsetY;
                    const {
                        refineLeft, refineTop
                    } = that.refinePosition(left, top, panel.offsetWidth, panel.offsetHeight);
                    panel.style.left = refineLeft + 'px';
                    panel.style.top = refineTop + 'px';
                    GM_setValue(SystemConfig.GMStorageKey.ConfigPanel.Position, {
                        left: refineLeft, top: refineTop,
                    })
                }

                document.addEventListener('mousemove', mouseMoveHandler);
                document.addEventListener('mouseup', function () {
                    document.removeEventListener('mousemove', mouseMoveHandler);
                });
            });
        }

        /**
         * 限制面板位置,使其任意一部分都不超出屏幕
         *
         * @param left      面板左上角 x 坐标
         * @param top       面板左上角 y 坐标
         * @param width     面板宽度
         * @param height    面板高度
         * @returns {{refineLeft: number, refineTop: number}}   返回修正后的坐标
         */
        refinePosition(left, top, width, height) {
            const screenWidth = window.innerWidth;
            const screenHeight = window.innerHeight;
            return {
                refineLeft: Math.min(Math.max(0, left), screenWidth - width),
                refineTop: Math.min(Math.max(0, top), screenHeight - height),
            }
        }

        /**
         * 初始化菜单命令,用于在 Tampermonkey 的菜单中添加一个配置面板的命令。
         *
         * @private
         */
        _initMenuCommand() {
            let that = this
            GM_registerMenuCommand("配置面板", () => {
                that.show()
            })
        }

        /**
         * 显示配置面板
         */
        show() {
            document.getElementById(this.appID).style.visibility = 'visible';
        }

        /**
         * 隐藏配置面板
         */
        hide() {
            document.getElementById(this.appID).style.visibility = 'hidden';
        }

        /**
         * 更新配置,由 Vue 组件调用来更新配置并重新渲染时间
         * @param config
         */
        updateConfig(config) {
            this.userConfig.update({timeRender: config})
            this.timeRendererService.reRender()
        }
    }

    class Main {
        static ComponentsConfig = [UserConfig, StyleService, MessageService, MonitorService, TimeRendererService, ConfigPanelService,]

        constructor() {
            for (let componentClazz of Main.ComponentsConfig) {
                const instance = new componentClazz();
                this[componentClazz.name] = instance
                ComponentLocator.register(componentClazz, instance)
            }
        }

        /**
         * 获取依赖关系图
         * @returns {[]} 依赖关系图,例如 [{node:'ComponentClass0', dependencies:['ComponentClass2', 'ComponentClass3']}, ...]
         * @private
         */
        _getDependencyGraph() {
            const dependencyGraph = []
            for (let componentClazz of Main.ComponentsConfig) {
                const dependencies = this[componentClazz.name].dependencies.map(dependency => dependency.clazz.name)
                dependencyGraph.push({node: componentClazz.name, dependencies})
            }
            return dependencyGraph
        }

        start() {
            const dependencyGraph = this._getDependencyGraph()
            const order = Utils.dependencyAnalysis(dependencyGraph)
            Logger.debug('初始化顺序:', order.join(' -> '))
            for (let componentName of order) {
                this[componentName].initDependencies()
            }
            for (let componentName of order) {
                this[componentName].init()
            }
        }
    }

    const main = new Main();
    main.start();

})();