ChatGPT with Date

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

当前为 2024-05-02 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name ChatGPT with Date
  3. // @namespace https://github.com/jiang-taibai/chatgpt-with-date
  4. // @version 1.0.0
  5. // @description Tampermonkey plugin for displaying ChatGPT historical and real-time conversation time. 显示 ChatGPT 历史对话时间 与 实时对话时间的 Tampermonkey 插件。
  6. // @author CoderJiang
  7. // @license MIT
  8. // @match https://chat.openai.com/*
  9. // @icon https://cdn.coderjiang.com/project/chatgpt-with-date/logo.svg
  10. // @grant GM_xmlhttpRequest
  11. // @grant GM_registerMenuCommand
  12. // @grant GM_setValue
  13. // @grant GM_getValue
  14. // @grant unsafeWindow
  15. // @run-at document-end
  16. // ==/UserScript==
  17.  
  18. (function () {
  19. 'use strict';
  20.  
  21. class SystemConfig {
  22. static TimeRender = {
  23. Interval: 1000, TimeClassName: 'chatgpt-time', Selectors: [{
  24. Selector: '.chatgpt-time', Style: {
  25. 'font-size': '14px', 'color': '#666', 'margin-left': '5px', 'font-weight': 'normal',
  26. }
  27. }], RenderRetryCount: 3, RenderModes: ['AfterRoleLeft', 'AfterRoleRight', 'BelowRole'], RenderModeStyles: {
  28. 'AfterRoleLeft': {
  29. 'font-size': '14px', 'color': '#666', 'margin-left': '5px', 'font-weight': 'normal',
  30. }, 'AfterRoleRight': {
  31. 'font-size': '14px', 'color': '#666', 'font-weight': 'normal', 'float': 'right'
  32. }, 'BelowRole': {
  33. 'font-size': '14px', 'color': '#666', 'font-weight': 'normal', 'display': 'block',
  34. },
  35. }
  36. }
  37. static ConfigPanel = {
  38. AppID: 'CWD-Configuration-Panel', Icon: {
  39. 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>',
  40. }
  41. }
  42. // GM 存储的键
  43. static GMStorageKey = {
  44. UserConfig: 'ChatGPTWithDate-UserConfig', ConfigPanel: {
  45. Position: 'ChatGPTWithDate-ConfigPanel-Position', Size: 'ChatGPTWithDate-ConfigPanel-Size',
  46. },
  47. }
  48. }
  49.  
  50. class Logger {
  51. static EnableLog = true
  52. static EnableDebug = true
  53. static EnableInfo = true
  54. static EnableWarn = true
  55. static EnableError = true
  56. static EnableTable = true
  57.  
  58. static log(...args) {
  59. if (Logger.EnableLog) {
  60. console.log(...args);
  61. }
  62. }
  63.  
  64. static debug(...args) {
  65. if (Logger.EnableDebug) {
  66. console.debug(...args);
  67. }
  68. }
  69.  
  70. static info(...args) {
  71. if (Logger.EnableInfo) {
  72. console.info(...args);
  73. }
  74. }
  75.  
  76. static warn(...args) {
  77. if (Logger.EnableWarn) {
  78. console.warn(...args);
  79. }
  80. }
  81.  
  82. static error(...args) {
  83. if (Logger.EnableError) {
  84. console.error(...args);
  85. }
  86. }
  87.  
  88. static table(...args) {
  89. if (Logger.EnableTable) {
  90. console.table(...args);
  91. }
  92. }
  93. }
  94.  
  95. class Utils {
  96.  
  97. /**
  98. * 计算时间
  99. *
  100. * @param timestamp 时间戳,浮点数或整数类型,单位毫秒,例如 1714398759.26881、1714398759
  101. * @returns {{milliseconds: number, hours: number, seconds: number, month: number, year: number, minutes: number, day: number}}
  102. */
  103. static calculateTime(timestamp) {
  104. const date = new Date(timestamp);
  105. const year = date.getFullYear();
  106. const month = date.getMonth() + 1;
  107. const day = date.getDate();
  108. const hours = date.getHours();
  109. const minutes = date.getMinutes();
  110. const seconds = date.getSeconds();
  111. const milliseconds = date.getMilliseconds();
  112. return {
  113. year, month, day, hours, minutes, seconds, milliseconds
  114. };
  115. }
  116.  
  117. /**
  118. * 格式化日期时间
  119. * @param year 年份
  120. * @param month 月份
  121. * @param day 日期
  122. * @param hour 小时
  123. * @param minute 分钟
  124. * @param second 秒
  125. * @param milliseconds 毫秒
  126. * @param template 模板,例如 'yyyy-MM-dd HH:mm:ss'
  127. * @returns string 格式化后的日期时间字符串
  128. */
  129. static formatDateTime(year, month, day, hour, minute, second, milliseconds, template) {
  130. return template
  131. .replace('{yyyy}', year)
  132. .replace('{MM}', month.toString().padStart(2, '0'))
  133. .replace('{dd}', day.toString().padStart(2, '0'))
  134. .replace('{HH}', hour.toString().padStart(2, '0'))
  135. .replace('{mm}', minute.toString().padStart(2, '0'))
  136. .replace('{ss}', second.toString().padStart(2, '0'))
  137. .replace('{ms}', milliseconds.toString().padStart(3, '0'));
  138. }
  139.  
  140. /**
  141. * 检查依赖关系图(有向图)是否有循环依赖,如果没有就返回一个先后顺序(即按照此顺序实例化不会出现依赖项为空的情况)。
  142. * 给定依赖关系图为此结构[{node:'ComponentClass0', dependencies:['ComponentClass2', 'ComponentClass3']}, ...]
  143. * @param dependencyGraph 依赖关系图
  144. * @returns {*[]}
  145. */
  146. static dependencyAnalysis(dependencyGraph) {
  147. // 创建一个映射每个节点到其入度的对象
  148. const inDegree = {};
  149. const graph = {};
  150. const order = [];
  151.  
  152. // 初始化图和入度表
  153. dependencyGraph.forEach(item => {
  154. const {node, dependencies} = item;
  155. if (!graph[node]) {
  156. graph[node] = [];
  157. inDegree[node] = 0;
  158. }
  159. dependencies.forEach(dependentNode => {
  160. if (!graph[dependentNode]) {
  161. graph[dependentNode] = [];
  162. inDegree[dependentNode] = 0;
  163. }
  164. graph[dependentNode].push(node);
  165. inDegree[node]++;
  166. });
  167. });
  168.  
  169. // 将所有入度为0的节点加入到队列中
  170. const queue = [];
  171. for (const node in inDegree) {
  172. if (inDegree[node] === 0) {
  173. queue.push(node);
  174. }
  175. }
  176.  
  177. // 处理队列中的节点
  178. while (queue.length) {
  179. const current = queue.shift();
  180. order.push(current);
  181. graph[current].forEach(neighbour => {
  182. inDegree[neighbour]--;
  183. if (inDegree[neighbour] === 0) {
  184. queue.push(neighbour);
  185. }
  186. });
  187. }
  188.  
  189. // 如果排序后的节点数量不等于图中的节点数量,说明存在循环依赖
  190. if (order.length !== Object.keys(graph).length) {
  191. // 找到循环依赖的节点
  192. const cycleNodes = [];
  193. for (const node in inDegree) {
  194. if (inDegree[node] !== 0) {
  195. cycleNodes.push(node);
  196. }
  197. }
  198. throw new Error("存在循环依赖的节点:" + cycleNodes.join(","));
  199. }
  200. return order;
  201. }
  202.  
  203. }
  204.  
  205. class MessageBO {
  206. /**
  207. * 消息业务对象
  208. *
  209. * @param messageId 消息ID,为消息元素的data-message-id属性值
  210. * @param role 角色
  211. * system: 表示系统消息,并不属于聊天内容。 从 API 获取
  212. * tool: 也表示系统消息。 从 API 获取
  213. * assistant: 表示 ChatGPT 回答的消息。 从 API 获取
  214. * user: 表示用户输入的消息。 从 API 获取
  215. * You: 表示用户输入的消息。 从页面实时获取
  216. * ChatGPT: 表示 ChatGPT 回答的消息。 从页面实时获取
  217. * @param timestamp 时间戳,浮点数或整数类型,单位毫秒,例如 1714398759.26881、1714398759
  218. * @param message 消息内容
  219. */
  220. constructor(messageId, role, timestamp, message = '') {
  221. this.messageId = messageId;
  222. this.role = role;
  223. this.timestamp = timestamp;
  224. this.message = message;
  225. }
  226. }
  227.  
  228. class MessageElementBO {
  229. /**
  230. * 消息元素业务对象
  231. *
  232. * @param rootEle 消息的根元素,并非是总根元素,而是 roleEle 和 messageEle 的最近公共祖先元素
  233. * @param roleEle 角色元素,例如 <div>ChatGPT</div>
  234. * @param messageEle 消息元素,包含 data-message-id 属性
  235. * 例如 <div data-message-id="123456">你好</div>
  236. */
  237. constructor(rootEle, roleEle, messageEle) {
  238. this.rootEle = rootEle;
  239. this.roleEle = roleEle;
  240. this.messageEle = messageEle;
  241. }
  242. }
  243.  
  244. class Component {
  245.  
  246. constructor() {
  247. this.dependencies = []
  248. Object.defineProperty(this, 'initDependencies', {
  249. value: function () {
  250. this.dependencies.forEach(dependency => {
  251. this[dependency.field] = ComponentLocator.get(dependency.clazz)
  252. })
  253. }, writable: false, // 防止方法被修改
  254. configurable: false // 防止属性被重新定义或删除
  255. });
  256. }
  257.  
  258. init() {
  259. }
  260. }
  261.  
  262. class ComponentLocator {
  263. /**
  264. * 组件注册器,用于注册和获取组件
  265. */
  266. static components = {};
  267.  
  268. /**
  269. * 注册组件,要求组件为 Component 的子类
  270. *
  271. * @param clazz Component 的子类
  272. * @param instance Component 的子类的实例化对象,必顧是 clazz 的实例
  273. * @returns obj 返回注册的实例化对象
  274. */
  275. static register(clazz, instance) {
  276. if (!(instance instanceof Component)) {
  277. throw new Error(`实例化对象 ${instance} 不是 Component 的实例。`);
  278. }
  279. if (!(instance instanceof clazz)) {
  280. throw new Error(`实例化对象 ${instance} 不是 ${clazz} 的实例。`);
  281. }
  282. if (ComponentLocator.components[clazz.name]) {
  283. throw new Error(`组件 ${clazz.name} 已经注册过了。`);
  284. }
  285. ComponentLocator.components[clazz.name] = instance;
  286. return instance
  287. }
  288.  
  289. /**
  290. * 获取组件,用于完成组件之间的依赖注入
  291. *
  292. * @param clazz Component 的子类
  293. * @returns {*} 返回注册的实例化对象
  294. */
  295. static get(clazz) {
  296. if (!ComponentLocator.components[clazz.name]) {
  297. throw new Error(`组件 ${clazz.name} 未注册。`);
  298. }
  299. return ComponentLocator.components[clazz.name];
  300. }
  301. }
  302.  
  303. class UserConfig extends Component {
  304.  
  305. init() {
  306. this.timeRender = {
  307. mode: 'AfterRoleLeft', format: 'yyyy-MM-dd HH:mm:ss',
  308. }
  309. const userConfig = this.load()
  310. if (userConfig) {
  311. Object.assign(this.timeRender, userConfig.timeRender)
  312. }
  313. }
  314.  
  315. save() {
  316. GM_setValue(SystemConfig.GMStorageKey.UserConfig, {
  317. timeRender: this.timeRender
  318. })
  319. }
  320.  
  321. load() {
  322. return GM_getValue(SystemConfig.GMStorageKey.UserConfig, {})
  323. }
  324.  
  325. /**
  326. * 更新配置并保存
  327. * @param newConfig 新的配置
  328. */
  329. update(newConfig) {
  330. Object.assign(this.timeRender, newConfig.timeRender)
  331. this.save()
  332. }
  333. }
  334.  
  335. class StyleService extends Component {
  336. init() {
  337. this.styles = new Map()
  338. this._initStyleElement()
  339. this._reRenderStyle()
  340. }
  341.  
  342. /**
  343. * 初始化样式元素,该元素用于存放动态生成的样式
  344. * @private
  345. */
  346. _initStyleElement() {
  347. const styleElement = document.createElement('style');
  348. styleElement.type = 'text/css';
  349. document.head.appendChild(styleElement);
  350. this.styleEle = styleElement;
  351. }
  352.  
  353. /**
  354. * 更新样式选择器的样式,合并原有样式和新样式
  355. *
  356. * @param selector 选择器,例如 '.chatgpt-time' 表示选择 class 为 chatgpt-time 的元素
  357. * @param style 样式,字典对象,例如 {'font-size': '14px', 'color': '#666'}
  358. */
  359. updateStyle(selector, style) {
  360. const newStyle = Object.assign({}, this.styles.get(selector), style)
  361. this.styles.set(selector, newStyle)
  362. this._reRenderStyle()
  363. }
  364.  
  365. /**
  366. * 重置一个样式选择器的样式,覆盖原有样式
  367. *
  368. * @param selector 选择器,例如 '.chatgpt-time' 表示选择 class 为 chatgpt-time 的元素
  369. * @param style 样式,字典对象,例如 {'font-size': '14px', 'color': '#666'}
  370. */
  371. resetOneSelector(selector, style) {
  372. this.styles.set(selector, style)
  373. this._reRenderStyle()
  374. }
  375.  
  376. /**
  377. * 重置多个样式选择器的样式,覆盖原有样式
  378. *
  379. * @param selectors 选择器数组,例如 ['.chatgpt-time', '.chatgpt-time2']
  380. * @param styles 样式数组,例如 [{'font-size': '14px', 'color': '#666'}, {'font-size': '16px', 'color': '#666'}]
  381. */
  382. resetBatchSelectors(selectors, styles) {
  383. for (let i = 0; i < selectors.length; i++) {
  384. this.styles.set(selectors[i], styles[i])
  385. }
  386. this._reRenderStyle()
  387. }
  388.  
  389. /**
  390. * 重新渲染样式,即把 this.styles 中的样式同步到 style 元素中。
  391. * 该方法会清空原有的样式,然后重新生成。
  392. * @private
  393. */
  394. _reRenderStyle() {
  395. let styleText = ''
  396. for (let [selector, style] of this.styles) {
  397. let styleStr = ''
  398. for (let [key, value] of Object.entries(style)) {
  399. styleStr += `${key}: ${value};`
  400. }
  401. styleText += `${selector} {${styleStr}}`
  402. }
  403. this.styleEle.innerHTML = styleText
  404. }
  405. }
  406.  
  407. class MessageService extends Component {
  408. init() {
  409. this.messages = new Map();
  410. }
  411.  
  412. /**
  413. * 解析消息元素,获取消息的所有内容。由于网页中不存在时间戳,所以时间戳使用当前时间代替。
  414. * 调用该方法只需要消息元素,一般用于从页面实时监测获取到的消息。
  415. *
  416. * @param messageDiv 消息元素,包含 data-message-id 属性 的 div 元素
  417. * @returns {MessageBO|undefined} 返回消息业务对象,如果消息元素不存在则返回 undefined
  418. */
  419. parseMessageDiv(messageDiv) {
  420. if (!messageDiv) {
  421. return;
  422. }
  423. const messageId = messageDiv.getAttribute('data-message-id');
  424. const messageElementBO = this.getMessageElement(messageId)
  425. if (!messageElementBO) {
  426. return;
  427. }
  428. let timestamp = new Date().getTime();
  429. const role = messageElementBO.roleEle.innerText;
  430. const message = messageElementBO.messageEle.innerHTML;
  431. if (!this.messages.has(messageId)) {
  432. const messageBO = new MessageBO(messageId, role, timestamp, message);
  433. this.messages.set(messageId, messageBO);
  434. }
  435. return this.messages.get(messageId);
  436. }
  437.  
  438. /**
  439. * 添加消息,主要用于添加从 API 劫持到的消息列表。
  440. * 调用该方法需要已知消息的所有内容,如果只知道消息元素则应该使用 parseMessageDiv 方法获取消息业务对象。
  441. *
  442. * @param message 消息业务对象
  443. * @param force 是否强制添加,如果为 true 则强制添加,否则如果消息已经存在则不添加
  444. * @returns {boolean} 返回是否添加成功
  445. */
  446. addMessage(message, force = false) {
  447. if (this.messages.has(message.messageId) && !force) {
  448. return false;
  449. }
  450. this.messages.set(message.messageId, message);
  451. return true
  452. }
  453.  
  454. /**
  455. * 通过消息 ID 获取消息元素业务对象
  456. *
  457. * @param messageId 消息 ID
  458. * @returns {MessageElementBO|undefined} 返回消息元素业务对象
  459. */
  460. getMessageElement(messageId) {
  461. const messageDiv = document.body.querySelector(`div[data-message-id="${messageId}"]`);
  462. if (!messageDiv) {
  463. return;
  464. }
  465. const rootDiv = messageDiv.parentElement.parentElement.parentElement;
  466. const roleDiv = rootDiv.firstChild;
  467. return new MessageElementBO(rootDiv, roleDiv, messageDiv);
  468. }
  469.  
  470. /**
  471. * 通过消息 ID 获取消息业务对象
  472. * @param messageId
  473. * @returns {any}
  474. */
  475. getMessage(messageId) {
  476. return this.messages.get(messageId);
  477. }
  478.  
  479. /**
  480. * 显示所有消息信息
  481. */
  482. showMessages() {
  483. Logger.table(Array.from(this.messages.values()));
  484. }
  485. }
  486.  
  487. class MonitorService extends Component {
  488. constructor() {
  489. super();
  490. this.messageService = null
  491. this.timeRendererService = null
  492. this.dependencies = [{field: 'messageService', clazz: MessageService}, {
  493. field: 'timeRendererService', clazz: TimeRendererService
  494. },]
  495. }
  496.  
  497. init() {
  498. this.totalTime = 0;
  499. this.originalFetch = window.fetch;
  500. this._initMonitorFetch();
  501. this._initMonitorAddedMessageNode();
  502. }
  503.  
  504. /**
  505. * 初始化劫持 fetch 方法,用于监控 ChatGPT 的消息数据
  506. *
  507. * @private
  508. */
  509. _initMonitorFetch() {
  510. const that = this;
  511. unsafeWindow.fetch = (...args) => {
  512. return that.originalFetch.apply(this, args)
  513. .then(response => {
  514. // 克隆响应对象以便独立处理响应体
  515. const clonedResponse = response.clone();
  516. if (response.url.includes('https://chat.openai.com/backend-api/conversation/')) {
  517. clonedResponse.json().then(data => {
  518. that._parseConversationJsonData(data);
  519. }).catch(error => Logger.error('解析响应体失败:', error));
  520. }
  521. return response;
  522. });
  523. };
  524. }
  525.  
  526. /**
  527. * 解析从 API 获取到的消息数据,该方法存在报错风险,需要在调用时捕获异常以防止中断后续操作。
  528. *
  529. * @param obj 从 API 获取到的消息数据
  530. * @private
  531. */
  532. _parseConversationJsonData(obj) {
  533. const mapping = obj.mapping
  534. const messageIds = []
  535. for (let key in mapping) {
  536. const message = mapping[key].message
  537. if (message) {
  538. const messageId = message.id
  539. const role = message.author.role
  540. const createTime = message.create_time
  541. const messageBO = new MessageBO(messageId, role, createTime * 1000)
  542. messageIds.push(messageId)
  543. this.messageService.addMessage(messageBO, true)
  544. }
  545. }
  546. this.timeRendererService.addMessageArrayToBeRendered(messageIds)
  547. this.messageService.showMessages()
  548. }
  549.  
  550. /**
  551. * 初始化监控节点变化,用于监控在使用 ChatGPT 期间实时输入的消息。
  552. * 每隔 500ms 检查一次 main 节点是否存在,如果存在则开始监控节点变化。
  553. * @private
  554. */
  555. _initMonitorAddedMessageNode() {
  556. const interval = setInterval(() => {
  557. const mainElement = document.querySelector('main');
  558. if (mainElement) {
  559. this._setupMonitorAddedMessageNode(mainElement);
  560. clearInterval(interval); // 清除定时器,停止进一步检查
  561. }
  562. }, 500);
  563. }
  564.  
  565. /**
  566. * 设置监控节点变化,用于监控在使用 ChatGPT 期间实时输入的消息。
  567. * @param supervisedNode 监控在此节点下的节点变化,确保新消息的节点在此节点下
  568. * @private
  569. */
  570. _setupMonitorAddedMessageNode(supervisedNode) {
  571. const that = this;
  572. const callback = function (mutationsList, observer) {
  573. const start = new Date().getTime();
  574. for (const mutation of mutationsList) {
  575. if (mutation.type === 'childList') {
  576. mutation.addedNodes.forEach(node => {
  577. if (node.nodeType === Node.ELEMENT_NODE) {
  578. let messageDiv = node.querySelector('div[data-message-id]');
  579. if (!messageDiv && node.hasAttribute('data-message-id')) {
  580. messageDiv = node
  581. }
  582. if (messageDiv !== null) {
  583. const messageBO = that.messageService.parseMessageDiv(messageDiv);
  584. that.timeRendererService.addMessageToBeRendered(messageBO.messageId);
  585. that.messageService.showMessages()
  586. }
  587. }
  588. });
  589. }
  590. }
  591. const end = new Date().getTime();
  592. that.totalTime += (end - start);
  593. Logger.debug(`监控到节点变化,耗时 ${end - start}ms,总耗时 ${that.totalTime}ms。`);
  594. };
  595. const observer = new MutationObserver(callback);
  596. observer.observe(supervisedNode, {childList: true, subtree: true,});
  597. }
  598. }
  599.  
  600. class TimeRendererService extends Component {
  601. constructor() {
  602. super();
  603. this.messageService = null
  604. this.userConfig = null
  605. this.styleService = null
  606. this.dependencies = [{field: 'messageService', clazz: MessageService}, {
  607. field: 'userConfig', clazz: UserConfig
  608. }, {field: 'styleService', clazz: StyleService},]
  609. }
  610.  
  611. init() {
  612. this.messageToBeRendered = []
  613. this.messageCountOfFailedToRender = new Map()
  614. this._initStyle()
  615. this._initRender()
  616. }
  617.  
  618. /**
  619. * 初始化与时间有关的样式
  620. * @private
  621. */
  622. _initStyle() {
  623. this.styleService.resetBatchSelectors(SystemConfig.TimeRender.Selectors.map(item => item.Selector), SystemConfig.TimeRender.Selectors.map(item => item.Style))
  624. this.styleService.resetOneSelector(`.${SystemConfig.TimeRender.TimeClassName}`, SystemConfig.TimeRender.RenderModeStyles[this.userConfig.timeRender.mode])
  625. }
  626.  
  627. /**
  628. * 添加消息 ID 到待渲染队列
  629. * @param messageId 消息 ID
  630. */
  631. addMessageToBeRendered(messageId) {
  632. if (typeof messageId !== 'string') {
  633. return
  634. }
  635. this.messageToBeRendered.push(messageId)
  636. Logger.debug(`添加ID ${messageId} 到待渲染队列,当前队列 ${this.messageToBeRendered}`)
  637. }
  638.  
  639. /**
  640. * 添加消息 ID 到待渲染队列
  641. * @param messageIdArray 消息 ID数组
  642. */
  643. addMessageArrayToBeRendered(messageIdArray) {
  644. if (!messageIdArray || !(messageIdArray instanceof Array)) {
  645. return
  646. }
  647. messageIdArray.forEach(messageId => this.addMessageToBeRendered(messageId))
  648. }
  649.  
  650. /**
  651. * 初始化渲染时间的定时器,每隔 SystemConfig.TimeRender.Interval 毫秒处理一次待渲染队列
  652. * 1. 备份待渲染队列
  653. * 2. 清空待渲染队列
  654. * 3. 遍历备份的队列,逐个渲染
  655. * 3.1 如果渲染失败则重新加入待渲染队列,失败次数加一
  656. * 3.2 如果渲染成功,清空失败次数
  657. * 4. 重复 1-3 步骤
  658. * 5. 如果失败次数超过 SystemConfig.TimeRender.RenderRetryCount 则不再尝试渲染,即不再加入待渲染队列。同时清空失败次数。
  659. *
  660. * @private
  661. */
  662. _initRender() {
  663. const that = this
  664.  
  665. function processTimeRender() {
  666. const start = new Date().getTime();
  667. let completeCount = 0;
  668. let totalCount = that.messageToBeRendered.length;
  669. const messageToBeRenderedClone = that.messageToBeRendered.slice()
  670. that.messageToBeRendered = []
  671. for (let messageId of messageToBeRenderedClone) {
  672. new Promise(resolve => {
  673. resolve(that._renderTime(messageId))
  674. }).then((result) => {
  675. if (!result) {
  676. Logger.debug(`ID ${messageId} 渲染失败,当前渲染进度 ${completeCount}/${totalCount}`)
  677. let count = that.messageCountOfFailedToRender.get(messageId)
  678. if (count && count >= SystemConfig.TimeRender.RenderRetryCount) {
  679. Logger.debug(`ID ${messageId} 渲染失败次数超过 ${SystemConfig.TimeRender.RenderRetryCount} 次,将不再尝试。`)
  680. that.messageCountOfFailedToRender.delete(messageId)
  681. } else {
  682. that.messageToBeRendered.push(messageId);
  683. if (count) {
  684. that.messageCountOfFailedToRender.set(messageId, count + 1)
  685. } else {
  686. that.messageCountOfFailedToRender.set(messageId, 1)
  687. }
  688. }
  689. } else {
  690. completeCount++
  691. Logger.debug(`ID ${messageId} 渲染完成,当前渲染进度 ${completeCount}/${totalCount}`)
  692. that.messageCountOfFailedToRender.delete(messageId)
  693. }
  694. })
  695. }
  696. const end = new Date().getTime();
  697. Logger.debug(`处理当前ID队列渲染 ${messageToBeRenderedClone} 耗时 ${end - start}ms`)
  698. setTimeout(processTimeRender, SystemConfig.TimeRender.Interval);
  699. }
  700.  
  701. processTimeRender()
  702. }
  703.  
  704. /**
  705. * 将时间渲染到目标位置,如果检测到目标位置已经存在时间元素则更新时间,否则创建时间元素并插入到目标位置。
  706. *
  707. * @param messageId 消息 ID
  708. * @returns {boolean} 返回是否渲染成功
  709. * @private
  710. */
  711. _renderTime(messageId) {
  712. const messageElementBo = this.messageService.getMessageElement(messageId);
  713. const messageBo = this.messageService.getMessage(messageId);
  714. if (!messageElementBo || !messageBo) return false;
  715. const timeElement = messageElementBo.rootEle.querySelector(`.${SystemConfig.TimeRender.TimeClassName}`);
  716. const element = this._createTimeElement(messageBo.timestamp);
  717. if (!timeElement) {
  718. switch (this.userConfig.timeRender.mode) {
  719. case 'AfterRoleLeft':
  720. case 'AfterRoleRight':
  721. case 'BelowRole':
  722. messageElementBo.roleEle.innerHTML += element.timeElementHTML
  723. break;
  724. }
  725. } else {
  726. timeElement.innerHTML = element.timeString
  727. }
  728. return true;
  729. }
  730.  
  731. /**
  732. * 创建时间元素
  733. *
  734. * @param timestamp 时间戳,浮点数或整数类型,单位毫秒,例如 1714398759.26881、1714398759
  735. * @returns {{timeString, timeElementHTML: string}} 返回时间字符串和时间元素的 HTML
  736. * @private
  737. */
  738. _createTimeElement(timestamp) {
  739. const time = Utils.calculateTime(timestamp);
  740. const timeString = Utils.formatDateTime(time.year, time.month, time.day, time.hours, time.minutes, time.seconds, time.milliseconds, this.userConfig.timeRender.format);
  741. let timeElementHTML = `<span class="${SystemConfig.TimeRender.TimeClassName}">${timeString}</span>`;
  742. return {
  743. timeString, timeElementHTML,
  744. };
  745. }
  746.  
  747. /**
  748. * 清除所有时间元素
  749. * @private
  750. */
  751. _cleanAllTimeElements() {
  752. const timeElements = document.body.querySelectorAll(`.${SystemConfig.TimeRender.TimeClassName}`);
  753. timeElements.forEach(ele => {
  754. ele.remove()
  755. })
  756. }
  757.  
  758. /**
  759. * 重新渲染时间元素,强制拉取所有消息 ID 重新渲染
  760. */
  761. reRender() {
  762. this.styleService.resetOneSelector(`.${SystemConfig.TimeRender.TimeClassName}`, SystemConfig.TimeRender.RenderModeStyles[this.userConfig.timeRender.mode])
  763. this._cleanAllTimeElements()
  764. this.addMessageArrayToBeRendered(Array.from(this.messageService.messages.keys()))
  765. }
  766. }
  767.  
  768. class ConfigPanelService extends Component {
  769.  
  770. constructor() {
  771. super();
  772. this.userConfig = null
  773. this.timeRendererService = null
  774. this.messageService = null
  775. this.dependencies = [{field: 'userConfig', clazz: UserConfig}, {
  776. field: 'timeRendererService', clazz: TimeRendererService
  777. }, {field: 'messageService', clazz: MessageService},]
  778. }
  779.  
  780. /**
  781. * 初始化配置面板,强调每个子初始化方法阻塞式的执行,即一个初始化方法执行完毕后再执行下一个初始化方法。
  782. * @returns {Promise<void>}
  783. */
  784. async init() {
  785. this.appID = SystemConfig.ConfigPanel.AppID
  786. this._initVariables()
  787. Logger.debug('开始初始化配置面板')
  788. await this._initStyle()
  789. Logger.debug('初始化样式完成')
  790. await this._initScript()
  791. Logger.debug('初始化脚本完成')
  792. await this._initPanel()
  793. Logger.debug('初始化面板完成')
  794. this._initVue()
  795. Logger.debug('初始化Vue完成')
  796. this._initConfigPanelSizeAndPosition()
  797. this._initConfigPanelEventMonitor()
  798. Logger.debug('初始化配置面板事件监控完成')
  799. this._initMenuCommand()
  800. Logger.debug('初始化菜单命令完成')
  801. }
  802.  
  803. /**
  804. * 初始化配置面板的 HTML 与 Vue 实例的配置属性。集中管理以便方便修改。
  805. * @private
  806. */
  807. _initVariables() {
  808. const that = this
  809. this.panelHTML = `
  810. <div id="${that.appID}" style="visibility: hidden">
  811. <div class="status-bar">
  812. <div class="title" id="${that.appID}-DraggableArea">{{title}}</div>
  813. <n-button class="close" @click="onClose" text>
  814. <n-icon size="20">
  815. ${SystemConfig.ConfigPanel.Icon.Close}
  816. </n-icon>
  817. </n-button>
  818. </div>
  819. <div class="container">
  820. <n-form :label-width="40" :model="configForm" label-placement="left">
  821. <n-form-item label="样式" path="format">
  822. <n-select v-model:value="configForm.format" filterable tag
  823. :options="formatOptions" @update:value="onConfigUpdate"></n-select>
  824. </n-form-item>
  825. <n-form-item label="预览" path="format">
  826. <div v-html="reFormatTimeHTML(configForm.format)"></div>
  827. </n-form-item>
  828. <n-form-item label="位置" path="mode">
  829. <n-select v-model:value="configForm.mode"
  830. :options="modeOptions" @update:value="onConfigUpdate"></n-select>
  831. </n-form-item>
  832. </n-form>
  833. <div class="button-group">
  834. <n-button @click="onReset" :disabled="!configDirty">重置</n-button>
  835. <n-button @click="onApply">应用</n-button>
  836. <n-button @click="onConfirm">保存</n-button>
  837. </div>
  838. </div>
  839. </div>`
  840. this.appConfig = {
  841. el: `#${that.appID}`, data() {
  842. return {
  843. timestamp: new Date().getTime(),
  844. title: "ChatGPTWithDate",
  845. formatOptions: [{
  846. label: '<span>2000-12-31 00:00:00</span>',
  847. value: '<span>{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}</span>'
  848. }, {
  849. label: '<span>12/31/2000 00:00:00</span>',
  850. value: '<span>{MM}/{dd}/{yyyy} {HH}:{mm}:{ss}</span>'
  851. }, {
  852. label: '<span>12-31 00:00:00</span>', value: '<span>{MM}-{dd} {HH}:{mm}:{ss}</span>'
  853. }, {
  854. label: '<span>12-31 00:00</span>', value: '<span><span>{MM}-{dd} {HH}:{mm}</span>'
  855. }, {
  856. label: '<span>2000-12-31 00:00:00.000</span>',
  857. value: '<span>{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}.{ms}</span>'
  858. }, {
  859. label: '<span style="background: #2B2B2b;border-radius: 8px;padding: 1px 10px;color: #717171;">2000-12-31 00:00:00</span>',
  860. value: '<span style="background: #2B2B2b;border-radius: 8px;padding: 1px 10px;color: #717171;">{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}</span>'
  861. }], modeOptions: [{label: '角色之后(靠左)', value: 'AfterRoleLeft'}, {
  862. label: '角色之后(居右)', value: 'AfterRoleRight'
  863. }, {label: '角色之下', value: 'BelowRole'},], configForm: {
  864. format: that.userConfig.timeRender.format, mode: that.userConfig.timeRender.mode,
  865. }, config: {
  866. format: that.userConfig.timeRender.format, mode: that.userConfig.timeRender.mode,
  867. }, configDirty: false, configPanel: {
  868. display: true,
  869. },
  870. };
  871. }, methods: {
  872. onApply() {
  873. this.config = Object.assign({}, this.configForm);
  874. that.updateConfig(this.config)
  875. this.configDirty = false
  876. }, onConfirm() {
  877. this.onApply()
  878. this.onClose()
  879. }, onReset() {
  880. this.configForm = Object.assign({}, this.config);
  881. this.configDirty = false
  882. }, onClose() {
  883. that.hide()
  884. }, onConfigUpdate() {
  885. this.configDirty = true
  886. }, renderTag({option, handleClose}) {
  887. return Vue.h('div', {
  888. innerHTML: this.reFormatTimeHTML(option.value)
  889. });
  890. }, renderLabel(option) {
  891. return Vue.h('div', {
  892. innerHTML: this.reFormatTimeHTML(option.value)
  893. });
  894. }, reFormatTimeHTML(html) {
  895. const {
  896. year, month, day, hours, minutes, seconds, milliseconds
  897. } = Utils.calculateTime(this.timestamp)
  898. return Utils.formatDateTime(year, month, day, hours, minutes, seconds, milliseconds, html)
  899. },
  900. }, mounted() {
  901. this.timestamp = new Date().getTime()
  902. this.formatOptions.forEach(item => {
  903. item.label = this.reFormatTimeHTML(item.value)
  904. })
  905. this.timestampInterval = setInterval(() => {
  906. this.timestamp = new Date().getTime()
  907. this.formatOptions.forEach(item => {
  908. item.label = this.reFormatTimeHTML(item.value)
  909. })
  910. }, 50)
  911. },
  912. }
  913. }
  914.  
  915. /**
  916. * 初始化样式。
  917. * 同时为了避免样式冲突,在 head 元素中加入一个 <meta name="naive-ui-style" /> 元素,
  918. * naive-ui 会把所有的样式刚好插入这个元素的前面。
  919. * 参考 https://www.naiveui.com/zh-CN/os-theme/docs/style-conflict
  920. *
  921. * @returns {Promise}
  922. * @private
  923. */
  924. _initStyle() {
  925. return new Promise(resolve => {
  926. const meta = document.createElement('meta');
  927. meta.name = 'naive-ui-style'
  928. document.head.appendChild(meta);
  929. const style = `
  930. .v-binder-follower-container {
  931. position: fixed;
  932. }
  933. #CWD-Configuration-Panel {
  934. position: absolute;
  935. top: 50px;
  936. left: 50px;
  937. width: 250px;
  938. background-color: #FFFFFF;
  939. border: #D7D8D9 1px solid;
  940. border-radius: 4px;
  941. resize: horizontal;
  942. min-width: 200px;
  943. overflow: auto;
  944. color: black;
  945. opacity: 0.9;
  946. }
  947. #CWD-Configuration-Panel .status-bar {
  948. cursor: move;
  949. background-color: #f0f0f0;
  950. border-radius: 4px 4px 0 0;
  951. display: flex;
  952. }
  953. #CWD-Configuration-Panel .status-bar .title {
  954. display: flex;
  955. align-items: center;
  956. justify-content: left;
  957. padding-left: 10px;
  958. user-select: none;
  959. color: #777;
  960. flex: 1;
  961. font-weight: bold;
  962. }
  963. #CWD-Configuration-Panel .status-bar .close {
  964. cursor: pointer;
  965. padding: 10px;
  966. transition: color 0.3s;
  967. }
  968. #CWD-Configuration-Panel .status-bar .close:hover {
  969. color: #f00;
  970. }
  971. #CWD-Configuration-Panel .container {
  972. padding: 20px;
  973. }
  974. #CWD-Configuration-Panel .container .button-group {
  975. display: flex;
  976. justify-content: center;
  977. gap: 10px;
  978. }
  979. #CWD-Configuration-Panel .container .button-group > button {
  980. width: 30%;
  981. }`
  982. const styleEle = document.createElement('style');
  983. styleEle.type = 'text/css'
  984. styleEle.innerHTML = style;
  985. document.head.appendChild(styleEle);
  986. resolve()
  987. })
  988. }
  989.  
  990. /**
  991. * 初始化 Vue 与 Naive UI 脚本。无法使用 <script src="https://xxx"> 的方式插入,因为 ChatGPT 有 CSP 限制。
  992. * 采用 GM_xmlhttpRequest 的方式获取 Vue 与 Naive UI 的脚本内容,然后插入 <script>脚本内容</script> 到页面中。
  993. *
  994. * @returns {Promise}
  995. * @private
  996. */
  997. _initScript() {
  998. return new Promise(resolve => {
  999. let completeCount = 0;
  1000. const addScript = (content) => {
  1001. let script = document.createElement('script');
  1002. script.textContent = content;
  1003. document.body.appendChild(script);
  1004. completeCount++;
  1005. if (completeCount === 2) {
  1006. resolve()
  1007. }
  1008. }
  1009. GM_xmlhttpRequest({
  1010. method: "GET", url: "https://unpkg.com/vue@3.4.26/dist/vue.global.js", onload: function (response) {
  1011. addScript(response.responseText);
  1012. }
  1013. });
  1014. GM_xmlhttpRequest({
  1015. method: "GET", url: "https://unpkg.com/naive-ui@2.38.1/dist/index.js", onload: function (response) {
  1016. addScript(response.responseText);
  1017. }
  1018. });
  1019. // 以下方法有 CSP 限制
  1020. // const naiveScript = document.createElement('script');
  1021. // naiveScript.setAttribute("type", "text/javascript");
  1022. // naiveScript.text = "https://unpkg.com/naive-ui@2.38.1/dist/index.js";
  1023. // document.documentElement.appendChild(naiveScript);
  1024. })
  1025. }
  1026.  
  1027. /**
  1028. * 初始化配置面板,插入配置面板的 HTML 到 body 中。
  1029. *
  1030. * @returns {Promise}
  1031. * @private
  1032. */
  1033. _initPanel() {
  1034. const that = this
  1035. return new Promise(resolve => {
  1036. const panelRoot = document.createElement('div');
  1037. panelRoot.innerHTML = that.panelHTML;
  1038. document.body.appendChild(panelRoot);
  1039. resolve()
  1040. })
  1041. }
  1042.  
  1043. /**
  1044. * 初始化 Vue 实例,挂载到配置面板的 HTML 元素上。
  1045. *
  1046. * @private
  1047. */
  1048. _initVue() {
  1049. const app = Vue.createApp(this.appConfig);
  1050. app.use(naive)
  1051. app.mount(`#${this.appID}`);
  1052. }
  1053.  
  1054. /**
  1055. * 初始化配置面板大小与位置
  1056. *
  1057. * @private
  1058. */
  1059. _initConfigPanelSizeAndPosition() {
  1060. const panel = document.getElementById(this.appID)
  1061.  
  1062. // 获取存储的大小
  1063. const size = GM_getValue(SystemConfig.GMStorageKey.ConfigPanel.Size, {})
  1064. if (size && size.width && !isNaN(size.width)) {
  1065. panel.style.width = size.width + 'px';
  1066. }
  1067.  
  1068. // 获取存储的位置
  1069. const position = GM_getValue(SystemConfig.GMStorageKey.ConfigPanel.Position, {})
  1070. if (position && position.left && position.top && !isNaN(position.left) && !isNaN(position.top)) {
  1071. const {left, top} = position
  1072. const {refineLeft, refineTop} = this.refinePosition(left, top, panel.offsetWidth, panel.offsetHeight)
  1073. panel.style.left = refineLeft + 'px';
  1074. panel.style.top = refineTop + 'px';
  1075. }
  1076.  
  1077. // 如果面板任何一边超出屏幕,则重置位置
  1078. // const rect = panel.getBoundingClientRect()
  1079. // const leftTop = {
  1080. // x: rect.left,
  1081. // y: rect.top
  1082. // }
  1083. // const rightBottom = {
  1084. // x: rect.left + rect.width,
  1085. // y: rect.top + rect.height
  1086. // }
  1087. // const screenWidth = window.innerWidth;
  1088. // const screenHeight = window.innerHeight;
  1089. // if (leftTop.x < 0 || leftTop.y < 0 || rightBottom.x > screenWidth || rightBottom.y > screenHeight) {
  1090. // panel.style.left = '50px';
  1091. // panel.style.top = '50px';
  1092. // }
  1093.  
  1094. }
  1095.  
  1096. /**
  1097. * 初始化配置面板事件监控,包括面板拖动、面板大小变化等事件。
  1098. *
  1099. * @private
  1100. */
  1101. _initConfigPanelEventMonitor() {
  1102. const that = this
  1103. const panel = document.getElementById(this.appID)
  1104. const draggableArea = document.getElementById(`${this.appID}-DraggableArea`)
  1105.  
  1106. // 监听面板宽度变化
  1107. const resizeObserver = new ResizeObserver(entries => {
  1108. for (let entry of entries) {
  1109. if (entry.contentRect.width) {
  1110. GM_setValue(SystemConfig.GMStorageKey.ConfigPanel.Size, {
  1111. width: entry.contentRect.width,
  1112. })
  1113. }
  1114. }
  1115. });
  1116. resizeObserver.observe(panel);
  1117.  
  1118. // 监听面板位置
  1119. draggableArea.addEventListener('mousedown', function (e) {
  1120. const offsetX = e.clientX - draggableArea.getBoundingClientRect().left;
  1121. const offsetY = e.clientY - draggableArea.getBoundingClientRect().top;
  1122.  
  1123. function mouseMoveHandler(e) {
  1124. const left = e.clientX - offsetX;
  1125. const top = e.clientY - offsetY;
  1126. const {
  1127. refineLeft, refineTop
  1128. } = that.refinePosition(left, top, panel.offsetWidth, panel.offsetHeight);
  1129. panel.style.left = refineLeft + 'px';
  1130. panel.style.top = refineTop + 'px';
  1131. GM_setValue(SystemConfig.GMStorageKey.ConfigPanel.Position, {
  1132. left: refineLeft, top: refineTop,
  1133. })
  1134. }
  1135.  
  1136. document.addEventListener('mousemove', mouseMoveHandler);
  1137. document.addEventListener('mouseup', function () {
  1138. document.removeEventListener('mousemove', mouseMoveHandler);
  1139. });
  1140. });
  1141. }
  1142.  
  1143. /**
  1144. * 限制面板位置,使其任意一部分都不超出屏幕
  1145. *
  1146. * @param left 面板左上角 x 坐标
  1147. * @param top 面板左上角 y 坐标
  1148. * @param width 面板宽度
  1149. * @param height 面板高度
  1150. * @returns {{refineLeft: number, refineTop: number}} 返回修正后的坐标
  1151. */
  1152. refinePosition(left, top, width, height) {
  1153. const screenWidth = window.innerWidth;
  1154. const screenHeight = window.innerHeight;
  1155. return {
  1156. refineLeft: Math.min(Math.max(0, left), screenWidth - width),
  1157. refineTop: Math.min(Math.max(0, top), screenHeight - height),
  1158. }
  1159. }
  1160.  
  1161. /**
  1162. * 初始化菜单命令,用于在 Tampermonkey 的菜单中添加一个配置面板的命令。
  1163. *
  1164. * @private
  1165. */
  1166. _initMenuCommand() {
  1167. let that = this
  1168. GM_registerMenuCommand("配置面板", () => {
  1169. that.show()
  1170. })
  1171. }
  1172.  
  1173. /**
  1174. * 显示配置面板
  1175. */
  1176. show() {
  1177. document.getElementById(this.appID).style.visibility = 'visible';
  1178. }
  1179.  
  1180. /**
  1181. * 隐藏配置面板
  1182. */
  1183. hide() {
  1184. document.getElementById(this.appID).style.visibility = 'hidden';
  1185. }
  1186.  
  1187. /**
  1188. * 更新配置,由 Vue 组件调用来更新配置并重新渲染时间
  1189. * @param config
  1190. */
  1191. updateConfig(config) {
  1192. this.userConfig.update({timeRender: config})
  1193. this.timeRendererService.reRender()
  1194. }
  1195. }
  1196.  
  1197. class Main {
  1198. static ComponentsConfig = [UserConfig, StyleService, MessageService, MonitorService, TimeRendererService, ConfigPanelService,]
  1199.  
  1200. constructor() {
  1201. for (let componentClazz of Main.ComponentsConfig) {
  1202. const instance = new componentClazz();
  1203. this[componentClazz.name] = instance
  1204. ComponentLocator.register(componentClazz, instance)
  1205. }
  1206. }
  1207.  
  1208. /**
  1209. * 获取依赖关系图
  1210. * @returns {[]} 依赖关系图,例如 [{node:'ComponentClass0', dependencies:['ComponentClass2', 'ComponentClass3']}, ...]
  1211. * @private
  1212. */
  1213. _getDependencyGraph() {
  1214. const dependencyGraph = []
  1215. for (let componentClazz of Main.ComponentsConfig) {
  1216. const dependencies = this[componentClazz.name].dependencies.map(dependency => dependency.clazz.name)
  1217. dependencyGraph.push({node: componentClazz.name, dependencies})
  1218. }
  1219. return dependencyGraph
  1220. }
  1221.  
  1222. start() {
  1223. const dependencyGraph = this._getDependencyGraph()
  1224. const order = Utils.dependencyAnalysis(dependencyGraph)
  1225. Logger.debug('初始化顺序:', order.join(' -> '))
  1226. for (let componentName of order) {
  1227. this[componentName].initDependencies()
  1228. }
  1229. for (let componentName of order) {
  1230. this[componentName].init()
  1231. }
  1232. }
  1233. }
  1234.  
  1235. const main = new Main();
  1236. main.start();
  1237.  
  1238. })();