// ==UserScript== // @name ChatGPT with Date // @namespace https://github.com/jiang-taibai/chatgpt-with-date // @version 2.0.1 // @description Tampermonkey plugin for displaying ChatGPT historical and real-time conversation time. 显示 ChatGPT 历史对话时间 与 实时对话时间的 Tampermonkey 插件。 // @author CoderJiang // @license MIT // @match *chat.openai.com/* // @match *chatgpt.com/* // @match *jiang-taibai.github.io/chatgpt-with-date-config-page* // @match *project.coderjiang.com/chatgpt-with-date-config-page* // @icon https://cdn.coderjiang.com/project/chatgpt-with-date/logo.svg // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @grant GM_setValue // @grant GM_getValue // @grant GM_listValues // @grant GM_deleteValue // @grant GM_addElement // @grant GM_addStyle // @grant GM_openInTab // @grant unsafeWindow // @run-at document-end // @noframes // @downloadURL none // ==/UserScript== (function () { 'use strict'; const IsConfigPage = window.location.hostname === 'jiang-taibai.github.io' || window.location.hostname === 'project.coderjiang.com' class SystemConfig { static Common = { ApplicationName: 'ChatGPT with Date', GPTPrompt: 'IyAxLiDku7vliqHnroDku4sKCuS9oOmcgOimgeWGmSBIVE1M44CBQ1NT44CBSmF2YVNjcmlwdCDku6PnoIHvvIzlrp7njrDmiJHnmoTpnIDmsYLvvIzlkI7pnaLmiJHlsIbor6bnu4bku4vnu43kvaDlupTor6XmgI7kuYjlhpnku6PnoIHjgIIKCiMgMi4gSFRNTCDopoHmsYIKCuS9oOmcgOimgeWGmeS4gOS4quaXpeacn+aXtumXtOeahOaooeadvyBIVE1MIOWtl+espuS4su+8jOS9oOWPr+S7peS9v+eUqOWNoOS9jeespuadpeihqOekuuaXtumXtOWFg+e0oO+8jOS+i+Wmgu+8mgoKYGBgaHRtbAo8ZGl2IGNsYXNzPSJ0ZXh0LXRhZy1ib3giPgogICAgPHNwYW4gY2xhc3M9ImRhdGUiPnt5eXl5fS17TU19LXtkZH08L3NwYW4+CiAgICA8c3BhbiBjbGFzcz0idGltZSI+e0hIfTp7bW19Ontzc308L3NwYW4+CjwvZGl2PgpgYGAKCuWQjumdouS8muS7i+e7jeS9oOaAjuS5iOeUqCBKYXZhU2NyaXB0IOadpeWunueOsOaYvuekuueJueWumueahOaXtumXtOOAggoKIyAzLiBDU1Mg6KaB5rGCCgooMSkg5LiN5YWB6K645YaZ5qCH562+6YCJ5oup5Zmo77yM5Y+q6IO95YaZ57G76YCJ5oup5Zmo5oiWIElEIOmAieaLqeWZqAooMikg5bC96YeP5YaZ5ZCO5Luj6YCJ5oup5Zmo77yM5LiN5rGh5p+T5YWo5bGA5qC35byPCigzKSDlsL3ph4/kuI3opoHkvb/nlKggYCFpbXBvcnRhbnRgCgojIDQuIEphdmFTY3JpcHQg6KaB5rGCCgojIyA0LjEg5o+Q5L6b55qEIEFQSSDmjqXlj6MKCkFQSSDlrprkuYnlnKggd2luZG93IOS4iu+8jOWmguacieW/heimgeS9oOmcgOimgeWcqCBKUyDohJrmnKzlhoXph43lhpnlh73mlbDjgIIKCi0gd2luZG93LkNoYXRHUFRXaXRoRGF0ZS5ob29rcy5mb3JtYXREYXRlVGltZUJ5RGF0ZShkYXRlLCB0ZW1wbGF0ZSk6IOagueaNriBEYXRlIOWvueixoeWwhuaooeadvyBIVE1MIOWtl+espuS4suS4reeahOWGheWuueabv+aNouS4uiBkYXRlCiAg5a+56LGh5oyH5a6a55qE5pe26Ze0CiAgLSBkYXRlOiDml6XmnJ8gRGF0ZSDlr7nosaEKICAtIHRlbXBsYXRlOiBIVE1MIOWtl+espuS4su+8jOWNs+S9oOWGmeeahCBIVE1MIOS7o+eggQogIC0g6L+U5Zue5YC8OiDmoLzlvI/ljJblkI7nmoQgSFRNTCDku6PnoIEKLSB3aW5kb3cuQ2hhdEdQVFdpdGhEYXRlLmhvb2tzLmJlZm9yZUNyZWF0ZVRpbWVUYWcobWVzc2FnZUlkLCB0aW1lVGFnSFRNTCk6IOWwhiB0ZW1wbGF0ZSDmj5LlhaXliLDpobXpnaLkuYvliY3osIPnlKgKICAtIG1lc3NhZ2VJZDog5raI5oGv55qEIElE77yM5bm26Z2eIEhUTUwg5YWD57Sg55qEIElECiAgLSB0aW1lVGFnSFRNTDog5q2k5pe255qE5a2X56ym5Liy5pivICc8ZGl2IGNsYXNzPSJjaGF0Z3B0LXRpbWUiPicgKyB3aW5kb3cuQ2hhdEdQVFdpdGhEYXRlLmhvb2tzLmZvcm1hdERhdGVUaW1lQnlEYXRlKGRhdGUsCiAgICB0ZW1wbGF0ZSkgKyAnPC9kaXY+JwogIC0g6L+U5Zue5YC8OiDml6AKLSB3aW5kb3cuQ2hhdEdQVFdpdGhEYXRlLmhvb2tzLmFmdGVyQ3JlYXRlVGltZVRhZyhtZXNzYWdlSWQsIHRpbWVUYWdOb2RlKTog5bCGIHRlbXBsYXRlIOaPkuWFpeWIsOmhtemdouS5i+WQjuiwg+eUqAogIC0gbWVzc2FnZUlkOiDmtojmga/nmoQgSUTvvIzlubbpnZ4gSFRNTCDlhYPntKDnmoQgSUQKICAtIHRpbWVUYWdOb2RlOiDmraTml7bnmoToioLngrnmmK8gJzxkaXYgY2xhc3M9ImNoYXRncHQtdGltZSI+JyArIHdpbmRvdy5DaGF0R1BUV2l0aERhdGUuaG9va3MuZm9ybWF0RGF0ZVRpbWVCeURhdGUoZGF0ZSwKICAgIHRlbXBsYXRlKSArICc8L2Rpdj4nIOeahCBET00g6IqC54K5CiAgLSDov5Tlm57lgLw6IOaXoAoKIyMgNC4yIEFQSSDmiafooYzpgLvovpEKCuezu+e7n+S8muaMieeFp+S7peS4i+mhuuW6j+aJp+ihjCBBUEnvvJoKCigxKSB0ZW1wbGF0ZSA9IOS9oOi+k+WFpeeahCBIVE1MIOS7o+eggQooMikgdGVtcGxhdGUgPSB3aW5kb3cuQ2hhdEdQVFdpdGhEYXRlLmhvb2tzLmZvcm1hdERhdGVUaW1lQnlEYXRlKGRhdGUsIHRlbXBsYXRlKQooMykgdGltZVRhZ0hUTUwgPSAnPGRpdiBjbGFzcz0iY2hhdGdwdC10aW1lIj4nICsgdGVtcGxhdGUgKyAnPC9kaXY+JwooNCkgd2luZG93LkNoYXRHUFRXaXRoRGF0ZS5ob29rcy5iZWZvcmVDcmVhdGVUaW1lVGFnKG1lc3NhZ2VJZCwgdGltZVRhZ0hUTUwpCig1KSDlsIYgdGltZVRhZ0hUTUwg5o+S5YWl5Yiw5p+Q5L2N572uCig2KSB0aW1lVGFnTm9kZSA9IOWImuWImuaPkuWFpeeahCB0aW1lVGFnSFRNTCDoioLngrkKKDcpIHdpbmRvdy5DaGF0R1BUV2l0aERhdGUuaG9va3MuYWZ0ZXJDcmVhdGVUaW1lVGFnKG1lc3NhZ2VJZCwgdGltZVRhZ05vZGUpCgojIyA0LjMg5Luj56CB6KeE6IyDCgooMSkg6K+35L2/55SoIEVTNiDor63ms5UKKDIpIOivt+S9v+eUqOS4peagvOaooeW8jyBgJ3VzZSBzdHJpY3QnYAooMykg6K+35L2/55SoIGBjb25zdGAg5ZKMIGBsZXRgIOWjsOaYjuWPmOmHjwooNCkg6K+35L2/55SoIElJRkUg6YG/5YWN5YWo5bGA5Y+Y6YeP5rGh5p+TCig1KSDor7fkvb/nlKggYD09PWAg5ZKMIGAhPT1gIOmBv+WFjeexu+Wei+i9rOaNoumXrumimAooNikg5rOo6YeK5LiA5b6L5YaZ5Lit5paH5rOo6YeKCgojIDUuIOahiOS+iwoK5Lul5LiL5piv5LiA5Liq5qGI5L6L77yM5a6e546w5YWJ5qCH56e75Yqo5Yiw5pe26Ze05qCH562+5LiK5pe277yM5pel5pyf5pi+56S65Li65Yeg5aSp5YmN55qE5pWI5p6c44CCCgpIVE1MIOS7o+egge+8mgoKYGBgaHRtbAo8ZGl2IGNsYXNzPSJ0ZXh0LXRhZy1ib3giPgogICAgPHNwYW4gY2xhc3M9ImRhdGUiPnt5eXl5fS17TU19LXtkZH08L3NwYW4+CiAgICA8c3BhbiBjbGFzcz0idGltZSI+e0hIfTp7bW19Ontzc308L3NwYW4+CjwvZGl2PgpgYGAKCkNTUyDku6PnoIHvvJoKCmBgYGNzcwoudGV4dC10YWctYm94IHsKICAgIGJvcmRlci1yYWRpdXM6IDhweDsKICAgIGNvbG9yOiAjRTBFMEUwOwogICAgZm9udC1zaXplOiAwLjllbTsKICAgIG92ZXJmbG93OiBoaWRkZW47CiAgICBkaXNwbGF5OiBpbmxpbmUtYmxvY2s7Cn0KCi50ZXh0LXRhZy1ib3ggLmRhdGUgewogICAgYmFja2dyb3VuZDogIzMzMzsKICAgIGZsb2F0OiBsZWZ0OwogICAgcGFkZGluZzogMnB4IDhweCAycHggMTBweDsKICAgIGRpc3BsYXk6IGlubGluZS1ibG9jazsKfQoKLnRleHQtdGFnLWJveCAudGltZSB7CiAgICBiYWNrZ3JvdW5kOiAjNjA2MDYwOwogICAgZmxvYXQ6IGxlZnQ7CiAgICBwYWRkaW5nOiAycHggMTBweCAycHggOHB4OwogICAgZGlzcGxheTogaW5saW5lLWJsb2NrOwp9CmBgYAoKSmF2YVNjcmlwdCDku6PnoIHvvJoKCmBgYGphdmFzY3JpcHQKKCgpID0+IHsKICAgIHdpbmRvdy5DaGF0R1BUV2l0aERhdGUuaG9va3MuZm9ybWF0RGF0ZVRpbWVCeURhdGUgPSAoZGF0ZSwgdGVtcGxhdGUpID0+IHsKICAgICAgICBjb25zdCBmb3JtYXRWYWx1ZSA9ICh2YWx1ZSwgZm9ybWF0KSA9PiB2YWx1ZS50b1N0cmluZygpLnBhZFN0YXJ0KGZvcm1hdCA9PT0gJ3l5eXknID8gNCA6IDIsICcwJyk7CiAgICAgICAgY29uc3QgZGF0ZVZhbHVlcyA9IHsKICAgICAgICAgICAgJ3t5eXl5fSc6IGRhdGUuZ2V0RnVsbFllYXIoKSwKICAgICAgICAgICAgJ3tNTX0nOiBkYXRlLmdldE1vbnRoKCkgKyAxLAogICAgICAgICAgICAne2RkfSc6IGRhdGUuZ2V0RGF0ZSgpLAogICAgICAgICAgICAne0hIfSc6IGRhdGUuZ2V0SG91cnMoKSwKICAgICAgICAgICAgJ3ttbX0nOiBkYXRlLmdldE1pbnV0ZXMoKSwKICAgICAgICAgICAgJ3tzc30nOiBkYXRlLmdldFNlY29uZHMoKQogICAgICAgIH07CiAgICAgICAgcmV0dXJuIHRlbXBsYXRlLnJlcGxhY2UoL1x7W159XStcfS9nLCBtYXRjaCA9PiBmb3JtYXRWYWx1ZShkYXRlVmFsdWVzW21hdGNoXSwgbWF0Y2guc2xpY2UoMSwgLTEpKSk7CiAgICB9CiAgICB3aW5kb3cuQ2hhdEdQVFdpdGhEYXRlLmhvb2tzLmFmdGVyQ3JlYXRlVGltZVRhZyA9IChtZXNzYWdlSWQsIHRpbWVUYWdOb2RlKSA9PiB7CiAgICAgICAgY29uc3QgZGF0ZU5vZGUgPSB0aW1lVGFnTm9kZS5xdWVyeVNlbGVjdG9yKCcuZGF0ZScpOwogICAgICAgIGNvbnN0IGRhdGVUZXh0ID0gZGF0ZU5vZGUuaW5uZXJUZXh0OwogICAgICAgIGNvbnN0IGRhdGUgPSBuZXcgRGF0ZShkYXRlVGV4dCk7CiAgICAgICAgdGltZVRhZ05vZGUuYWRkRXZlbnRMaXN0ZW5lcignbW91c2VvdmVyJywgKCkgPT4gewogICAgICAgICAgICBkYXRlTm9kZS5pbm5lclRleHQgPSBgJHtNYXRoLmZsb29yKChuZXcgRGF0ZSgpIC0gZGF0ZSkgLyA4NjQwMDAwMCl95aSp5YmNYDsKICAgICAgICB9KTsKICAgICAgICB0aW1lVGFnTm9kZS5hZGRFdmVudExpc3RlbmVyKCdtb3VzZW91dCcsICgpID0+IHsKICAgICAgICAgICAgZGF0ZU5vZGUuaW5uZXJUZXh0ID0gZGF0ZVRleHQ7CiAgICAgICAgfSk7CiAgICB9Cn0pKCkKYGBgCgojIDUuIOS9oOeahOS7u+WKoQoK546w5Zyo5L2g6ZyA6KaB5YaZ5LiJ5q615Luj56CB77yM5YiG5Yir5Li6SFRNTOOAgUNTU+OAgUphdmFTY3JpcHTvvIzopoHmsYLlpoLkuIvvvJoKCi0gSFRNTO+8muS9oOWPqumcgOimgeWGmeaXtumXtOagh+etvueahCBIVE1MCi0gQ1NT77ya6K+35L2/55So57G76YCJ5oup5Zmo5oiWIElEIOmAieaLqeWZqO+8jOS4jeimgeS9v+eUqOagh+etvumAieaLqeWZqO+8iOmZpOmdnuaYr+WtkOmAieaLqeWZqO+8iQotIEphdmFTY3JpcHTvvJror7flnKggSUlGRSDkuK3nvJblhpnku6PnoIHvvIzkvaDlj6/ku6Xkvb/nlKjkuIrpnaLorrLliLDnmoTkuInkuKrpkqnlrZDlh73mlbDjgIIKLSDkuI3opoHor7Tlup/or53vvIznm7TmjqXkuIrku6PnoIHvvIzliIbkuLrkuInkuKrku6PnoIHlnZfnu5nmiJHvvIzlnKjmr4/kuKrku6PnoIHlnZfkuYvliY3lhpnkuIrigJzov5nmmK9IVE1M5Luj56CB4oCd44CB4oCc6L+Z5pivQ1NT5Luj56CB4oCd44CB4oCc6L+Z5pivSlPku6PnoIHigJ3jgIIKLSDmjqXkuIvmnaXnmoTlr7nor53miJHkuI3kvJrph43lpI3ku6XkuIrnmoTlhoXlrrnvvIzkvaDpnIDopoHorrDkvY/ov5nkupvlhoXlrrnjgIIKLSDmiJHmr4/mrKHkvJrlkYror4nkvaDmiJHpnIDopoHmgI7kuYjmlLnov5vkvaDnmoTku6PnoIHvvIzkvaDpnIDopoHmoLnmja7miJHnmoTopoHmsYLkv67mlLnku6PnoIHjgIIKCiMgNi4g5L2g6ZyA6KaB5a6M5oiQ55qE5oiR55qE6ZyA5rGCCgrml6XmnJ/ml7bpl7TmoLzlvI/vvJoyMDI0LTA0LTAzIDE4OjA5OjAxCuagt+W8j++8muaXtumXtOagh+etvuaYvuekuuS4uuS4gOS4queBsOiJsueahOefqeW9ouahhu+8jOaXpeacn+aYvuekuuWcqOW3pui+ue+8jOaXtumXtOaYvuekuuWcqOWPs+i+ue+8jOaXpeacn+WSjOaXtumXtOS5i+mXtOacieS4gOS4querlue6v+WIhumalArpop3lpJbmlYjmnpzvvJrlvZPpvKDmoIfnp7vliqjliLDml7bpl7TmoIfnrb7kuIrml7bvvIzml7bpl7TmoIfnrb7mipbliqjkuIDkuIvjgII=', } static Main = { WindowRegisterKey: 'ChatGPTWithDate', } static Logger = { TimeFormatTemplate: "{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}.{ms}", } static TimeRender = { Interval: 1000, TimeClassName: 'chatgpt-time-container', BatchSize: 100, BatchTimeout: 200, RenderRetryCount: 3, BasicStyle: ` .chatgpt-time-container.user { display:flex; justify-content: flex-end; } .chatgpt-time-container.assistant { display:flex; justify-content: flex-start; } `, TimeTagTemplates: [ // 默认:2023-10-15 12:01:00 `{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}`, // 美国:Oct 15, 2023 12:01 PM `{MM#shortname@en} {dd}, {yyyy} {HH#12}:{mm} {HH#tag}`, // 英国:01/01/2024 12:01 `{dd}/{MM}/{yyyy} {HH}:{mm}`, // 日本:2023年10月15日 12:01 `{yyyy}年{MM}月{dd}日 {HH}:{mm}`, // 显示毫秒数:2023-10-15 12:01:00.000 `{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}.{ms}`, // 复杂模板 `{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}`, `{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}`, `{yyyy}-{MM}-{dd}{HH}:{mm}:{ss}`, `{yyyy}-{MM}-{dd}{HH}:{mm}:{ss}`, ], BasicStyleKey: 'time-render', AdditionalStyleKey: 'time-render-advanced', AdditionalScriptKey: 'time-render-advanced', } static ConfigPanel = { AppID: 'CWD-Configuration-Panel', Icon: { Close: '', Restore: '', Language: '', Documentation: '', Minimize: '', Maximize: '', }, StyleKey: 'config-panel', ApplicationRegisterKey: 'configPanel', I18N: { default: 'zh', rollback: 'zh', supported: ['zh', 'en'], zh: { 'restore-info': '恢复出厂设置', 'restore-warn': '确定恢复出厂设置?你的所有自定义配置将被清除!', 'toggle-language-info': 'Switch to English', 'documentation-info': '查看教程', 'documentation-international-access': '国际访问', 'documentation-china-access': '中国访问', 'template': '模板', 'preview': '预览', 'code': '代码', 'position': '位置', 'advance': '高级', 'reset': '重置', 'apply': '应用', 'save': '保存并关闭', 'apply-failed': '应用失败', 'input-html': '请输入 HTML 代码', 'input-css': '请输入 CSS 代码', 'input-js': '请输入 JavaScript 代码', 'position-after-role-left': '角色之后(靠左)', 'position-after-role-right': '角色之后(靠右)', 'position-below-role': '角色之下', 'gpt-prompt-info': '不会写代码?复制提示词让 ChatGPT 帮你写!', 'copy-success-info': '复制成功,发给 ChatGPT 吧!', 'js-invalid-info': 'JS 代码无效', }, en: { 'restore-info': 'Restore factory settings', 'restore-warn': 'Are you sure to restore the factory settings? All your custom configurations will be cleared!', 'toggle-language-info': '切换到中文', 'documentation-info': 'View documentation', 'documentation-international-access': 'International Access', 'documentation-china-access': 'China Access', 'template': 'Template', 'preview': 'Preview', 'code': 'Code', 'position': 'Position', 'advance': 'Advance', 'reset': 'Reset', 'apply': 'Apply', 'apply-failed': 'Apply failed', 'save': 'Save and Close', 'input-html': 'Please enter HTML code', 'input-css': 'Please enter CSS code', 'input-js': 'Please enter JavaScript code', 'position-after-role-left': 'After Role (left)', 'position-after-role-right': 'After Role (right)', 'position-below-role': 'Below Role', 'gpt-prompt-info': 'Not good at coding? Copy the prompt words and let ChatGPT help you!', 'copy-success-info': 'Copy successfully, send it to ChatGPT!', 'js-invalid-info': 'Invalid JS code', }, }, } static Hook = { // APP 使用 SystemConfig.Main.WindowRegisterKey 作为键绑定到 window 对象上 // Hook 使用 SystemConfig.Hook.ApplicationRegisterKey 作为键绑定到 APP 对象上 // 即如果要调用钩子 foo() 方法,应该使用 window.ChatGPTWithDate.hooks.foo() ApplicationRegisterKey: 'hooks', } // GM 存储的键 static GMStorageKey = { UserConfig: 'ChatGPTWithDate-UserConfig', ConfigPanel: { Position: 'ChatGPTWithDate-ConfigPanel-Position', Size: 'ChatGPTWithDate-ConfigPanel-Size', }, } } class Utils { /** * 按照模板格式化日期时间 * * @param date Date 对象 * @param template 模板,例如 '{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}' * @returns string 格式化后的日期时间字符串 */ static formatDateTimeByDate(date, template) { const year = date.getFullYear(); const month = date.getMonth() + 1; const day = date.getDate(); const week = date.getDay(); const hours = date.getHours(); const minutes = date.getMinutes(); const seconds = date.getSeconds(); const milliseconds = date.getMilliseconds(); const week2zh = ['', '一', '二', '三', '四', '五', '六', '日'] const week2enFullName = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] const week2enShortName = ['', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] const month2zh = ['', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二'] const month2enFullName = ['', 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] const month2enShortName = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] const getValueByKey = (key) => { switch (key) { case '{yyyy}': return year.toString(); case '{yy}': return (year % 100).toString().padStart(2, '0'); case '{MM}': case '{MM:02}': return month.toString().padStart(2, '0'); case '{MM:01}': return month.toString(); case '{MM#name@zh}': return month2zh[month]; case '{MM#name@en}': case '{MM#fullname@en}': return month2enFullName[month]; case '{MM#shortname@en}': return month2enShortName[month]; case '{dd}': case '{dd:02}': return day.toString().padStart(2, '0'); case '{dd:01}': return day.toString(); case '{HH}': case '{HH:02}': case '{HH#24}': case '{HH#24:02}': return hours.toString().padStart(2, '0'); case '{HH:01}': case '{HH#24:01}': return hours.toString(); case '{HH#12}': case '{HH#12:02}': return (hours % 12 || 12).toString().padStart(2, '0'); case '{HH#12:01}': return (hours % 12 || 12).toString(); case '{HH#tag}': case '{HH#tag@en}': return hours >= 12 ? 'PM' : 'AM'; case '{HH#tag@zh}': return hours >= 12 ? '下午' : '上午'; case '{mm}': case '{mm:02}': return minutes.toString().padStart(2, '0'); case '{mm:01}': return minutes.toString(); case '{ss}': case '{ss:02}': return seconds.toString().padStart(2, '0'); case '{ss:01}': return seconds.toString(); case '{ms}': return milliseconds.toString().padStart(3, '0'); case '{week}': case '{week:02}': return week.toString().padStart(2, '0'); case '{week:01}': return week.toString(); case '{week#name@zh}': return week2zh[week]; case '{week#name@en}': case '{week#fullname@en}': return week2enFullName[week]; case '{week#shortname@en}': return week2enShortName[week]; default: return key; } } return template.replace(/\{[^}]+\}/g, match => getValueByKey(match)); } /** * 深度合并两个对象,将源对象的属性合并到目标对象中,如果属性值为对象则递归合并。 * * @param target 目标对象 * @param source 源对象 * @returns {*} */ static deepMerge(target, source) { if (!source) return target // 遍历源对象的所有属性 Object.keys(source).forEach(key => { if (source[key] && typeof source[key] === 'object') { // 如果源属性是一个对象且目标中也存在同名属性且为对象,则递归合并 if (target[key] && typeof target[key] === 'object') { Utils.deepMerge(target[key], source[key]); } else { // 否则直接复制(对于源中的对象,需要进行深拷贝) target[key] = JSON.parse(JSON.stringify(source[key])); } } else { // 非对象属性直接复制 target[key] = source[key]; } }); return target; } /** * 检查依赖关系图(有向图)是否有循环依赖,如果没有就返回一个先后顺序(即按照此顺序实例化不会出现依赖项为空的情况)。 * 给定依赖关系图为此结构[{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; } /** * 将文本转换为 base64 编码,兼容中文 * @param text * @returns {string} */ static base64Encode(text) { const encodedUriComponent = encodeURIComponent(text); const binaryString = unescape(encodedUriComponent); return btoa(binaryString); } /** * 将 base64 编码的文本解码,兼容中文 * @param encoded * @returns {string} */ static base64Decode(encoded) { const binaryString = atob(encoded); const encodedUriComponent = escape(binaryString); return decodeURIComponent(encodedUriComponent); } /** * 判断 JavaScript 代码字符串是否合法 * @param code * @returns {{valid: boolean, error: *}} */ static isJavaScriptSyntaxValid(code) { try { new Function(code); return { valid: true, error: null }; } catch (e) { return { valid: false, error: e }; } } } class Logger { static EnableLog = true static EnableDebug = false static EnableInfo = true static EnableWarn = true static EnableError = true static EnableTable = false static prefix(type = 'INFO') { const timeFormat = Utils.formatDateTimeByDate(new Date(), SystemConfig.Logger.TimeFormatTemplate); return `[${timeFormat}] - [${SystemConfig.Common.ApplicationName}] - [${type}]` } static log(...args) { if (Logger.EnableLog) { console.log(Logger.prefix('INFO'), ...args); } } static debug(...args) { if (Logger.EnableDebug) { console.debug(Logger.prefix('DEBUG'), ...args); } } static info(...args) { if (Logger.EnableInfo) { console.info(Logger.prefix('INFO'), ...args); } } static warn(...args) { if (Logger.EnableWarn) { console.warn(Logger.prefix('WARN'), ...args); } } static error(...args) { if (Logger.EnableError) { console.error(Logger.prefix('ERROR'), ...args); } } static table(...args) { if (Logger.EnableTable) { console.table(...args); } } } 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 消息的根元素,也就是 messageEle 的父节点 * @param messageEle 消息元素,包含 data-message-id 属性 * 例如
你好
*/ constructor(rootEle, messageEle) { this.rootEle = rootEle; 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() { const defaultConfig = this.getDefaultConfig() this.timeRender = defaultConfig.timeRender this.i18n = defaultConfig.i18n const userConfig = this.load() if (userConfig) { // 不调用 update 方法,因为 update 方法会保存配置 // 为了 (开发者)调试 方便,不保存配置 // 为了 (用户)性能 考虑,不保存配置 Utils.deepMerge(this.timeRender, userConfig.timeRender) if (userConfig.i18n) this.i18n = userConfig.i18n } } getDefaultConfig() { return { timeRender: { format: SystemConfig.TimeRender.TimeTagTemplates[0], advanced: { enable: false, htmlTextContent: `
{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}
`, styleTextContent: `.text-tag-box { border-radius: 8px; color: #E0E0E0; font-size: 0.9em; overflow: hidden; display: inline-block; } .text-tag-box .date { background: #333; float: left; padding: 2px 8px 2px 10px; display: inline-block; transition: width 0.5s ease-out; white-space: nowrap; } .text-tag-box .time { background: #606060; float: left; padding: 2px 10px 2px 8px; display: inline-block; }`, scriptTextContent: `(() => { const getNewWidth = (targetNode, text) => { // 创建一个临时元素来测量文本宽度 const temp = targetNode.cloneNode(); temp.style.width = 'auto'; // 自动宽度 temp.style.visibility = 'hidden'; // 隐藏元素,不影响布局 temp.style.position = 'absolute'; // 避免影响其他元素 temp.style.whiteSpace = 'nowrap'; // 无换行 temp.innerText = text; document.body.appendChild(temp); const newWidth = temp.offsetWidth; document.body.removeChild(temp); return newWidth; } window.ChatGPTWithDate.hooks.afterCreateTimeTag = (messageId, timeTagNode) => { const dateNode = timeTagNode.querySelector('.date'); const date = dateNode.innerText; const originalWidth = getNewWidth(dateNode, date); const paddingWidth = 18; dateNode.style.width = (originalWidth + paddingWidth) + 'px'; timeTagNode.addEventListener('mouseover', () => { const now = new Date(); const offset = now - new Date(date); const days = Math.floor(offset / (24 * 60 * 60 * 1000)); let text = ''; if (days < 1) text = '今天'; else if (days < 2) text = '昨天'; else if (days < 3) text = '前天'; else if (days < 7) text = days + '天前'; else if (days < 30) text = Math.floor(days / 7) + '周前'; else if (days < 365) text = Math.floor(days / 30) + '个月前'; else text = Math.floor(days / 365) + '年前'; dateNode.innerText = text; dateNode.style.width = (getNewWidth(dateNode, text) + paddingWidth) + 'px'; }); // 鼠标移出 timeTagNode 时恢复 dateNode 的内容为原来的日期 timeTagNode.addEventListener('mouseout', () => { dateNode.innerText = date; dateNode.style.width = (originalWidth + paddingWidth) + 'px'; }); } })()`, } }, i18n: SystemConfig.ConfigPanel.I18N.default } } restore() { this.timeRender = this.getDefaultConfig().timeRender this.i18n = SystemConfig.ConfigPanel.I18N.default this.save() } save() { GM_setValue(SystemConfig.GMStorageKey.UserConfig, { timeRender: this.timeRender, i18n: this.i18n }) } load() { return GM_getValue(SystemConfig.GMStorageKey.UserConfig) } /** * 更新配置并保存 * @param newConfig 新的配置 */ update(newConfig) { Utils.deepMerge(this.timeRender, newConfig.timeRender) if (newConfig.i18n) this.i18n = newConfig.i18n this.save() } /** * 更新一个配置项并保存 * @param key 配置项的 key * @param value 配置项的 value */ updateOne(key, value) { if (this[key] instanceof Object) { Utils.deepMerge(this[key], value) } else { this[key] = value } this.save() } } class HookService extends Component { init() { this.defaultHooks = { beforeCreateTimeTag: (messageId, timeTagHTML) => { }, afterCreateTimeTag: (messageId, timeTagNode) => { }, formatDateTimeByDate: Utils.formatDateTimeByDate } this.reset2DefaultHooks() } _checkOldVersion(hookName) { if (unsafeWindow[hookName]) { Logger.warn(`钩子函数 ${hookName} 不应该绑定在 window 对象上,请绑定在 window.${SystemConfig.Main.WindowRegisterKey}.${SystemConfig.Hook.ApplicationRegisterKey} 上!未来版本中将不再兼容此情况。`) return true } return false } _register2Window() { unsafeWindow[SystemConfig.Main.WindowRegisterKey][SystemConfig.Hook.ApplicationRegisterKey] = { beforeCreateTimeTag: this.hooks.beforeCreateTimeTag, afterCreateTimeTag: this.hooks.afterCreateTimeTag, formatDateTimeByDate: this.hooks.formatDateTimeByDate } for (let hookName in this.defaultHooks) { if (this._checkOldVersion(hookName)) { unsafeWindow[hookName] = this.defaultHooks[hookName] } } } reset2DefaultHooks() { this.hooks = this.defaultHooks this._register2Window() } invokeHook(hookName, ...args) { if (!this.defaultHooks[hookName]) { Logger.error(`钩子函数 ${hookName} 非法`) return } if (this._checkOldVersion(hookName)) { unsafeWindow[SystemConfig.Main.WindowRegisterKey][SystemConfig.Hook.ApplicationRegisterKey][hookName] = unsafeWindow[hookName] } try { return unsafeWindow[SystemConfig.Main.WindowRegisterKey][SystemConfig.Hook.ApplicationRegisterKey][hookName](...args) } catch (e) { Logger.error(`调用钩子函数 ${hookName} 失败:`, e) } } } class StyleService extends Component { init() { this.styles = new Map() } /** * 更新样式 * * @param key 样式的 key,字符串对象 * @param styleContent 样式,字符串对象 */ updateStyle(key, styleContent) { this.removeStyle(key) const styleNode = document.createElement('style') styleNode.textContent = styleContent document.head.appendChild(styleNode) this.styles.set(key, styleNode) } /** * 移除样式 * * @param key 样式的 key,字符串对象 */ removeStyle(key) { let styleNode = this.styles.get(key) if (styleNode) { document.head.removeChild(styleNode) this.styles.delete(key) } } } class JavaScriptService extends Component { init() { this.javaScriptNodes = new Map() } updateJavaScript(key, textContent) { this.removeJavaScript(key) const scriptNode = GM_addElement('script', { textContent: textContent }); this.javaScriptNodes.set(key, scriptNode) } removeJavaScript(key) { let scriptNode = this.javaScriptNodes.get(key) if (scriptNode) { document.head.removeChild(scriptNode) this.javaScriptNodes.delete(key) } } } 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 role = messageDiv.getAttribute('data-message-author-role'); const messageElementBO = this.getMessageElement(messageId) if (!messageElementBO) { return; } let timestamp = new Date().getTime(); const message = messageElementBO.messageEle.innerHTML; if (!this.messages.has(messageId)) { const messageBO = new MessageBO(messageId, role, timestamp, message); this.addMessage(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 } /** * 移除消息 * * @param messageId 消息 ID * @returns {boolean} 返回是否移除成功 */ removeMessage(messageId) { return this.messages.delete(messageId) } /** * 通过消息 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; return new MessageElementBO(rootDiv, 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(); this._initConfigPageNode(); } /** * 初始化劫持 fetch 方法,用于监控 ChatGPT 的消息数据 * * @private */ _initMonitorFetch() { const that = this; const urlRegex = new RegExp("^https://(chat\\.openai|chatgpt)\\.com/backend-api/conversation/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); unsafeWindow.fetch = (...args) => { return that.originalFetch.apply(this, args) .then(response => { if (urlRegex.test(response.url)) { // 克隆响应对象以便独立处理响应体 const clonedResponse = response.clone(); 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.reverse()) 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) { let messageDiv = null; if (mutation.type === 'childList') { for (let node of mutation.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE) { messageDiv = node.querySelector('div[data-message-id]'); if (!messageDiv && node.hasAttribute('data-message-id')) { messageDiv = node break } } } } else if (mutation.type === 'attributes' && mutation.attributeName === 'data-message-id') { messageDiv = mutation.target; } if (messageDiv !== null) { const messageBO = that.messageService.parseMessageDiv(messageDiv); if (messageBO) { that.timeRendererService.addMessageToBeRendered(messageBO.messageId); that.messageService.showMessages() } } } const end = new Date().getTime(); that.totalTime += (end - start); }; const observer = new MutationObserver(callback); observer.observe(supervisedNode, {childList: true, subtree: true, attributes: true}); } /** * 初始化配置页面节点,以便显示配置页面的时间渲染效果 * @private */ _initConfigPageNode() { if (IsConfigPage) { const messageIds = [] const messageDivs = document.querySelectorAll('div[data-message-id]'); for (let messageDiv of messageDivs) { const dataMessageId = messageDiv.getAttribute('data-message-id'); const timestamp = parseInt(messageDiv.getAttribute('data-chatgpt-with-date-demo-timestamp')) const messageBO = new MessageBO(dataMessageId, 'ConfigDemo', timestamp) messageIds.push(dataMessageId) this.messageService.addMessage(messageBO, true) } this.timeRendererService.addMessageArrayToBeRendered(messageIds) } } } class TimeRendererService extends Component { constructor() { super(); this.messageService = null this.userConfig = null this.styleService = null this.javaScriptService = null this.hookService = null this.dependencies = [ {field: 'messageService', clazz: MessageService}, {field: 'userConfig', clazz: UserConfig}, {field: 'styleService', clazz: StyleService}, {field: 'javaScriptService', clazz: JavaScriptService}, {field: 'hookService', clazz: HookService}, ] } init() { this.messageToBeRendered = [] this.messageCountOfFailedToRender = new Map() this._setStyleAndJavaScript() this._initRender() } /** * 若为高级模式则设置用户自定义的样式和脚本,否则设置默认样式 * * @private */ _setStyleAndJavaScript() { this.styleService.updateStyle(SystemConfig.TimeRender.BasicStyleKey, SystemConfig.TimeRender.BasicStyle) this.hookService.reset2DefaultHooks() this.styleService.removeStyle(SystemConfig.TimeRender.AdditionalStyleKey) this.javaScriptService.removeJavaScript(SystemConfig.TimeRender.AdditionalScriptKey) if (this.userConfig.timeRender.advanced.enable) { this.styleService.updateStyle(SystemConfig.TimeRender.AdditionalStyleKey, this.userConfig.timeRender.advanced.styleTextContent) this.javaScriptService.updateJavaScript(SystemConfig.TimeRender.AdditionalScriptKey, this.userConfig.timeRender.advanced.scriptTextContent) } } /** * 添加消息 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 } this.messageToBeRendered.push(...messageIdArray) Logger.debug(`添加ID ${messageIdArray} 到待渲染队列,当前队列 ${this.messageToBeRendered}`) } /** * 初始化渲染时间的定时器,每隔 SystemConfig.TimeRender.Interval 毫秒处理一次待渲染队列 * 1. 备份待渲染队列 * 2. 清空待渲染队列 * 3. 遍历备份的队列,逐个渲染 * 3.1 如果渲染失败则重新加入待渲染队列,失败次数加一 * 3.2 如果渲染成功,清空失败次数 * 4. 重复 1-3 步骤 * 5. 如果失败次数超过 SystemConfig.TimeRender.RenderRetryCount 则不再尝试渲染,即不再加入待渲染队列。同时清空失败次数。 * * @private */ _initRender() { const that = this async function processTimeRender() { const start = new Date().getTime(); let completeCount = 0; let totalCount = that.messageToBeRendered.length; const messageToBeRenderedClone = that.messageToBeRendered.slice() that.messageToBeRendered = [] let count = 0; for (let messageId of messageToBeRenderedClone) { count++; if (count <= SystemConfig.TimeRender.BatchSize && new Date().getTime() - start <= SystemConfig.TimeRender.BatchTimeout) { const result = await that._renderTime(messageId) if (!result) { let countOfFailed = that.messageCountOfFailedToRender.get(messageId) if (countOfFailed && countOfFailed >= SystemConfig.TimeRender.RenderRetryCount) { Logger.debug(`ID ${messageId} 渲染失败次数超过 ${SystemConfig.TimeRender.RenderRetryCount} 次,将不再尝试。`) that.messageCountOfFailedToRender.delete(messageId) } else { that.messageToBeRendered.push(messageId); if (countOfFailed) { that.messageCountOfFailedToRender.set(messageId, countOfFailed + 1) } else { that.messageCountOfFailedToRender.set(messageId, 1) } } } else { completeCount++ that.messageCountOfFailedToRender.delete(messageId) } Logger.debug(`ID ${messageId} 渲染${result ? '成功' : '失败'},当前渲染进度 ${completeCount}/${totalCount},该批次耗时 ${new Date().getTime() - start}ms`) } else { for (let i = count; i < messageToBeRenderedClone.length; i++) { that.messageToBeRendered.push(messageToBeRenderedClone[i]) } if (count > SystemConfig.TimeRender.BatchSize) { Logger.debug(`本批次渲染数量超过 ${SystemConfig.TimeRender.BatchSize},将继续下一批次渲染。`) break; } if (new Date().getTime() - start > SystemConfig.TimeRender.BatchTimeout) { Logger.debug(`本批次渲染超时,将继续下一批次渲染。`) break; } } } const end = new Date().getTime(); if (totalCount > 0) { Logger.debug(`处理当前ID队列渲染 ${messageToBeRenderedClone} 耗时 ${end - start}ms`) } setTimeout(processTimeRender, SystemConfig.TimeRender.Interval); } processTimeRender().then(r => Logger.debug('初始化渲染时间定时器完成')) } /** * 将时间渲染到目标位置,如果检测到目标位置已经存在时间元素则更新时间,否则创建时间元素并插入到目标位置。 * * @param messageId 消息 ID * @returns {Promise} 返回是否渲染成功的 Promise 对象 * @private */ _renderTime(messageId) { return new Promise(resolve => { const messageElementBo = this.messageService.getMessageElement(messageId); const messageBo = this.messageService.getMessage(messageId); if (!messageElementBo || !messageBo) resolve(false) const timeElement = messageElementBo.rootEle.querySelector(`.${SystemConfig.TimeRender.TimeClassName}`); const role = messageElementBo.messageEle.getAttribute('data-message-author-role'); const element = this._createTimeElement(messageBo.timestamp, role); // 强制移除时间元素,重新渲染。这样才能保证时间正确的同时也能正确执行用户自定义的脚本。 if (timeElement) { messageElementBo.rootEle.removeChild(timeElement) } this.hookService.invokeHook('beforeCreateTimeTag', messageId, element.timeTagContainer) messageElementBo.rootEle.firstChild.insertAdjacentHTML('beforebegin', element.timeTagContainer); this.hookService.invokeHook('afterCreateTimeTag', messageId, messageElementBo.rootEle.querySelector(`.${SystemConfig.TimeRender.TimeClassName}`)) resolve(true) }) } /** * 创建时间元素,如果开启高级模式则使用用户自定义的时间格式,否则使用默认时间格式。 * * @param timestamp 时间戳,浮点数或整数类型,单位毫秒,例如 1714398759.26881、1714398759 * @returns {{timeTagFormated, timeTagContainer: string}} 返回格式化后的时间标签 和 包含时间标签的容器的 HTML 字符串 * @private */ _createTimeElement(timestamp, role) { let timeTagFormated = '' if (this.userConfig.timeRender.advanced.enable) { timeTagFormated = this.hookService.invokeHook('formatDateTimeByDate', new Date(timestamp), this.userConfig.timeRender.advanced.htmlTextContent) } else { timeTagFormated = this.hookService.invokeHook('formatDateTimeByDate', new Date(timestamp), this.userConfig.timeRender.format); } const timeTagContainer = `${timeTagFormated}`; return { timeTagFormated, timeTagContainer, }; } /** * 清除所有时间元素 * @private */ _cleanAllTimeElements() { const timeElements = document.body.querySelectorAll(`.${SystemConfig.TimeRender.TimeClassName}`); timeElements.forEach(ele => { ele.remove() }) } /** * 重新渲染时间元素,强制拉取所有消息 ID 重新渲染 */ reRender() { this._setStyleAndJavaScript() this._cleanAllTimeElements() this.addMessageArrayToBeRendered(Array.from(this.messageService.messages.keys())) } } class ConfigPanelService extends Component { constructor() { super(); this.userConfig = null this.styleService = null this.timeRendererService = null this.messageService = null this.javascriptService = null this.dependencies = [ {field: 'userConfig', clazz: UserConfig}, {field: 'styleService', clazz: StyleService}, {field: 'timeRendererService', clazz: TimeRendererService}, {field: 'messageService', clazz: MessageService}, {field: 'javascriptService', clazz: JavaScriptService}, ] } /** * 初始化配置面板,强调每个子初始化方法阻塞式的执行,即一个初始化方法执行完毕后再执行下一个初始化方法。 * @returns {Promise} */ async init() { if (IsConfigPage) { // 仅在配置页面初始化配置面板,同时可以防止代码高亮插件对非配置页面的代码块进行处理 this.appID = SystemConfig.ConfigPanel.AppID Logger.debug('开始初始化配置面板') await this._initExternalResources() Logger.debug('初始化脚本完成') this._initVariables() await this._initStyle() Logger.debug('初始化样式完成') await this._initPanel() Logger.debug('初始化面板完成') this._initVue() Logger.debug('初始化Vue完成') this._initConfigPanelSizeAndPosition() this._initConfigPanelEventMonitor() Logger.debug('初始化配置面板事件监控完成') this.show() } this._initMenuCommand() Logger.debug('初始化菜单命令完成') } /** * 初始化配置面板的 HTML 与 Vue 实例的配置属性。集中管理以便方便修改。 * @private */ _initVariables() { const that = this unsafeWindow[SystemConfig.Main.WindowRegisterKey][SystemConfig.ConfigPanel.ApplicationRegisterKey] = {} const GPTPrompt = Utils.base64Decode(SystemConfig.Common.GPTPrompt) const TimeTagComponent = { props: ['html'], render() { return Vue.h('div', {innerHTML: this.html}); }, } this.panelStyle = ` .v-binder-follower-container { position: fixed; } .n-button .n-button__content { white-space: pre-wrap; } #CWD-Configuration-Panel { position: absolute; top: 50px; left: 50px; width: 600px; background-color: #FFFFFF; border: #D7D8D9 1px solid; border-radius: 8px; resize: horizontal; min-width: 200px; overflow: auto; color: black; opacity: 0.95; } #CWD-Configuration-Panel .status-bar { cursor: move; background-color: #ECECEA; 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 .button { cursor: pointer; padding: 10px; transition: color 0.3s; } #CWD-Configuration-Panel .status-bar .button:hover { color: #f00; } #CWD-Configuration-Panel .container { padding: 20px 20px 0; } #CWD-Configuration-Panel .container .code-block { padding: 10px; border: 1px solid #d9d9d9; border-radius: 4px; } #CWD-Configuration-Panel .operation-group { background-color: #ECECEA; border-radius: 8px; display: flex; justify-content: center; gap: 10px; padding: 20px 0; } #CWD-Configuration-Panel .operation-group > button { width: 30%; }` this.panelHTML = ` ` this.appConfig = { el: `#${that.appID}`, data() { return { date: new Date(), hljs: hljs, title: "ChatGPTWithDate", formatOptions: SystemConfig.TimeRender.TimeTagTemplates.map(item => { return {label: item, value: item} }), configForm: { format: that.userConfig.timeRender.format, advanced: { enable: that.userConfig.timeRender.advanced.enable, htmlTextContent: that.userConfig.timeRender.advanced.htmlTextContent, styleTextContent: that.userConfig.timeRender.advanced.styleTextContent, scriptTextContent: that.userConfig.timeRender.advanced.scriptTextContent, }, }, config: { format: that.userConfig.timeRender.format, mode: that.userConfig.timeRender.mode, advanced: { enable: that.userConfig.timeRender.advanced.enable, htmlTextContent: that.userConfig.timeRender.advanced.htmlTextContent, styleTextContent: that.userConfig.timeRender.advanced.styleTextContent, scriptTextContent: that.userConfig.timeRender.advanced.scriptTextContent, }, }, locale: null, i18n: that.userConfig.i18n, folding: false, configDirty: false, configPanel: { display: true, }, }; }, methods: { onApply() { const jsValidResult = Utils.isJavaScriptSyntaxValid(this.configForm.advanced.scriptTextContent) if (!jsValidResult.valid) { that.notification.error({ title: this.map2text('js-invalid-info'), content: jsValidResult.error.toString(), duration: 3000, keepAliveOnHover: true, }); return false } this.config = JSON.parse(JSON.stringify(this.configForm)); that.updateConfig({timeRender: this.config}) this.configDirty = false return true }, onConfirm() { this.onApply() this.onClose() }, onReset() { this.configForm = JSON.parse(JSON.stringify(this.config)); this.configDirty = false }, onConfigUpdate() { this.configDirty = true }, renderLabel(option) { return Vue.h(TimeTagComponent, { html: option.label, }) }, reFormatTimeHTML(html) { return Utils.formatDateTimeByDate(this.date, html) }, insertTab(event) { if (!event.shiftKey) { // 确保不是 Shift + Tab 组合 event.preventDefault(); // 阻止 Tab 键的默认行为 // 尝试使用 execCommand 插入四个空格 if (document.queryCommandSupported('insertText')) { document.execCommand('insertText', false, ' '); } else { // 如果浏览器不支持 execCommand,回退到原始方法(不支持撤销) const start = event.target.selectionStart; const end = event.target.selectionEnd; const value = event.target.value; event.target.value = value.substring(0, start) + " " + value.substring(end); // 移动光标位置到插入的空格后 event.target.selectionStart = event.target.selectionEnd = start + 4; // 触发 input 事件更新 v-model this.$nextTick(() => { event.target.dispatchEvent(new Event('input')); }); } } }, onClose() { that.hide() }, toggleFolding() { this.folding = !this.folding }, onRestore() { // 清空所有 GM 存储的数据 const keys = GM_listValues(); keys.forEach(key => { GM_deleteValue(key); }); // 重置配置至出厂设置 const defaultConfig = that.userConfig.getDefaultConfig() this.config = defaultConfig.timeRender this.i18n = defaultConfig.i18n // 重置 Vue 表单 this.onReset() // 重置样式与脚本 that.updateConfig(defaultConfig) }, toggleLanguage() { let index = SystemConfig.ConfigPanel.I18N.supported.indexOf(this.i18n) if (index === -1) { Logger.error("嗯?当前语言未知?") } index = (index + 1) % SystemConfig.ConfigPanel.I18N.supported.length; this.i18n = SystemConfig.ConfigPanel.I18N.supported[index] that.userConfig.updateOne('i18n', this.i18n) this.setNaiveUILanguage() }, setNaiveUILanguage() { this.locale = this.i18n === 'zh' ? naive.zhCN : null; }, onDocumentation() { }, openUrl(url, target) { window.open(url, target) }, copyGPTPrompt() { navigator.clipboard.writeText(GPTPrompt).then(() => { that.notification.success({ content: this.map2text('copy-success-info'), duration: 1000, }); }) }, }, computed: { map2text() { return key => { const language = this.i18n; if (!SystemConfig.ConfigPanel.I18N[language]) { Logger.error(`当前语言 ${language} 不受支持!已回退至 ${SystemConfig.ConfigPanel.I18N.default}`); return SystemConfig.ConfigPanel.I18N[SystemConfig.ConfigPanel.I18N.default][key] || 'NULL'; } if (!SystemConfig.ConfigPanel.I18N[language][key]) { Logger.debug(`当前语言 ${language} 不存在键为 ${key} 的 i18n 支持,已回退至 ${SystemConfig.ConfigPanel.I18N.default}`); return SystemConfig.ConfigPanel.I18N[SystemConfig.ConfigPanel.I18N.default][key] || 'NULL'; } return SystemConfig.ConfigPanel.I18N[language][key]; }; } }, created() { this.date = new Date() this.formatOptions.forEach(item => { item.label = this.reFormatTimeHTML(item.value) }) }, mounted() { this.timestampInterval = setInterval(() => { // 满足打开状态且高级模式开启时才更新时间,避免不必要的性能消耗 if (that.isShow()) { this.date = new Date() this.formatOptions.forEach(item => { item.label = this.reFormatTimeHTML(item.value) }) } }, 50) this.setNaiveUILanguage() }, beforeUnmount() { clearInterval(this.timestampInterval) }, } } /** * 初始化样式。 * 同时为了避免样式冲突,在 head 元素中加入一个 元素, * 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); this.styleService.updateStyle(SystemConfig.ConfigPanel.StyleKey, this.panelStyle) resolve() }) } /** * 初始化 Vue 与 Naive UI 脚本。无法使用 到页面中。 * * @returns {Promise} * @private */ _initExternalResources() { return new Promise(resolve => { let completeCount = 0; const resources = [ {type: 'js', url: 'https://unpkg.com/vue@3.4.26/dist/vue.global.js'}, {type: 'js', url: 'https://unpkg.com/naive-ui@2.38.1/dist/index.js'}, {type: 'css', url: 'https://unpkg.com/@highlightjs/cdn-assets@11.9.0/styles/default.min.css'}, {type: 'js', url: 'https://unpkg.com/@highlightjs/cdn-assets@11.9.0/highlight.min.js'}, ] // const addScript = (content) => { // let script = document.createElement('script'); // script.textContent = content; // // document.head.appendChild(script); // iframeDocument.head.appendChild(script); // completeCount++; // if (completeCount === resources.length) { // resolve() // } // } // const addStyle = (content) => { // let style = document.createElement('style'); // style.textContent = content; // // document.head.appendChild(style); // iframeDocument.head.appendChild(style); // completeCount++; // if (completeCount === resources.length) { // resolve() // } // } resources.forEach(resource => { let element = null if (resource.type === 'js') { element = GM_addElement('script', { src: resource.url, type: 'text/javascript' }); } else if (resource.type === 'css') { element = GM_addElement('link', { rel: 'stylesheet', type: 'text/css', href: resource.url }); } element.onload = () => { completeCount++; if (completeCount === resources.length) { resolve() } } // GM_xmlhttpRequest({ // method: "GET", url: resource.url, onload: function (response) { // if (resource.type === 'js') { // addScript(response.responseText); // } else if (resource.type === 'css') { // addStyle(response.responseText); // } // } // }); }) // 以下方法有 CSP 限制 // const naiveScript = document.createElement('script'); // naiveScript.setAttribute("type", "text/javascript"); // naiveScript.text = "https://unpkg.com/naive-ui@2.38.1/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) const {notification} = naive.createDiscreteApi( ["notification"], { configProviderProps: { theme: naive.lightTheme } },); this.notification = notification 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 if (IsConfigPage) { GM_registerMenuCommand("配置面板", () => { that.show() }) } else { GM_registerMenuCommand("配置面板(中国访问)", () => { GM_openInTab("https://project.coderjiang.com/chatgpt-with-date-config-page/"); }) GM_registerMenuCommand("Configuration Panel (International Access)", () => { GM_openInTab("https://jiang-taibai.github.io/chatgpt-with-date-config-page/"); }) } } /** * 显示配置面板 */ show() { document.getElementById(this.appID).style.visibility = 'visible'; unsafeWindow[SystemConfig.Main.WindowRegisterKey][SystemConfig.ConfigPanel.ApplicationRegisterKey].visibility = true } /** * 隐藏配置面板 */ hide() { document.getElementById(this.appID).style.visibility = 'hidden'; unsafeWindow[SystemConfig.Main.WindowRegisterKey][SystemConfig.ConfigPanel.ApplicationRegisterKey].visibility = false } isShow() { return unsafeWindow[SystemConfig.Main.WindowRegisterKey][SystemConfig.ConfigPanel.ApplicationRegisterKey].visibility } /** * 更新配置,由 Vue 组件调用来更新配置并重新渲染时间 * @param config */ updateConfig(config) { this.userConfig.update(config) this.timeRendererService.reRender() } } class Main { static ComponentsConfig = [ UserConfig, StyleService, MessageService, MonitorService, TimeRendererService, JavaScriptService, HookService, 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}) } Logger.debug('依赖关系图:', JSON.stringify(dependencyGraph)) return dependencyGraph } /** * 注册全局变量 * @private */ _registerGlobalVariables() { unsafeWindow[SystemConfig.Main.WindowRegisterKey] = {} } start() { this._registerGlobalVariables() 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(); })();