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