Greasy Fork 支持简体中文。

Summarize with AI

Single-button AI summarization (OpenAI/Gemini) with model selection dropdown for articles/news. Uses Alt+S shortcut. Long press 'S' (or tap-and-hold on mobile) to select model. Allows adding custom models. Adapts summary overlay to system dark mode and mobile viewports.

安裝腳本?
作者推薦腳本

您可能也會喜歡 Remove URL trackers

安裝腳本
  1. // ==UserScript==
  2. // @name Summarize with AI
  3. // @namespace https://github.com/insign/userscripts
  4. // @version 2025.05.04.1546
  5. // @description Single-button AI summarization (OpenAI/Gemini) with model selection dropdown for articles/news. Uses Alt+S shortcut. Long press 'S' (or tap-and-hold on mobile) to select model. Allows adding custom models. Adapts summary overlay to system dark mode and mobile viewports.
  6. // @author Hélio <open@helio.me>
  7. // @license WTFPL
  8. // @match *://*/*
  9. // @grant GM.addStyle
  10. // @grant GM.xmlHttpRequest
  11. // @grant GM.setValue
  12. // @grant GM.getValue
  13. // @connect api.openai.com
  14. // @connect generativelanguage.googleapis.com
  15. // @require https://cdnjs.cloudflare.com/ajax/libs/readability/0.6.0/Readability.min.js
  16. // @require https://cdnjs.cloudflare.com/ajax/libs/readability/0.6.0/Readability-readerable.min.js
  17. // ==/UserScript==
  18.  
  19. (function () {
  20. 'use strict'
  21.  
  22. // --- Constantes ---
  23. // IDs dos elementos da interface do script
  24. const BUTTON_ID = 'summarize-button' // Botão principal flutuante 'S'
  25. const DROPDOWN_ID = 'model-dropdown' // Dropdown de seleção de modelo
  26. const OVERLAY_ID = 'summarize-overlay' // Overlay de fundo para o sumário
  27. const CLOSE_BUTTON_ID = 'summarize-close' // Botão de fechar no overlay
  28. const CONTENT_ID = 'summarize-content' // Div que contém o texto do sumário
  29. const ERROR_ID = 'summarize-error' // Div para exibir notificações de erro
  30. const ADD_MODEL_ITEM_ID = 'add-custom-model' // ID para o item "Adicionar Modelo" no dropdown
  31. const RETRY_BUTTON_ID = 'summarize-retry-button' // ID para o botão "Tentar Novamente" no overlay de erro
  32.  
  33. // Chave para armazenar modelos customizados no GM storage
  34. const CUSTOM_MODELS_KEY = 'custom_ai_models'
  35.  
  36. // Limite de tokens padrão
  37. const DEFAULT_MAX_TOKENS = 1000
  38. // Limite de tokens alto (para modelos específicos)
  39. const HIGH_MAX_TOKENS = 1500
  40. // Tempo para considerar long press (em milissegundos)
  41. const LONG_PRESS_DURATION = 500
  42.  
  43. // Configuração dos serviços e modelos de IA *padrão* suportados
  44. // Estrutura: models é um array de objetos com id, name (opcional), params (opcional)
  45. const MODEL_GROUPS = {
  46. openai: {
  47. name: 'OpenAI',
  48. baseUrl: 'https://api.openai.com/v1/chat/completions',
  49. models: [
  50. {id: 'o4-mini', name: 'o4 mini (better)', params: {max_completion_tokens: HIGH_MAX_TOKENS}},
  51. {id: 'o3-mini', name: 'o3 mini', params: {max_completion_tokens: HIGH_MAX_TOKENS}},
  52. {id: 'gpt-4.1', name: 'GPT-4.1'}, // Usa params padrão (DEFAULT_MAX_TOKENS)
  53. {id: 'gpt-4.1-mini', name: 'GPT-4.1 mini'}, // Usa params padrão
  54. {id: 'gpt-4.1-nano', name: 'GPT-4.1 nano (faster)'}, // Usa params padrão
  55. ],
  56. // Parâmetros padrão específicos para OpenAI (se não definidos no modelo)
  57. defaultParams: {max_completion_tokens: DEFAULT_MAX_TOKENS}
  58. },
  59. gemini: {
  60. name: 'Gemini',
  61. baseUrl: 'https://generativelanguage.googleapis.com/v1beta/models/',
  62. models: [
  63. {id: 'gemini-2.5-flash-preview-04-17',
  64. name: 'Gemini 2.5 Flash (faster)',
  65. params: {maxOutputTokens: HIGH_MAX_TOKENS}
  66. },
  67. {id: 'gemini-2.5-pro-exp-03-25', name: 'Gemini 2.5 Pro (better)', params: {maxOutputTokens: HIGH_MAX_TOKENS}},
  68. ],
  69. // Parâmetros padrão específicos para Gemini (se não definidos no modelo)
  70. defaultParams: {maxOutputTokens: DEFAULT_MAX_TOKENS} // Mantemos o padrão original aqui
  71. },
  72. }
  73.  
  74. // Template do prompt enviado para a IA
  75. // Instruções atualizadas para usar as classes CSS específicas de qualidade
  76. const PROMPT_TEMPLATE = (title, content, lang) => `You are a summarizer bot that provides clear and affirmative explanations of content.
  77. Generate a concise summary that includes:
  78. - 2-sentence introduction
  79. - Relevant emojis as bullet points
  80. - No section headers
  81. - Use HTML formatting, never use \`\`\` code blocks, never use markdown.
  82. - After the last bullet point add a 2-sentence conclusion with your own opinion based on your general knowledge, including if you agree or disagree and why. Give your opinion as a human. Start this conclusion with "<strong>Opinion:</strong> ". Do not add things like "I agree" or "I disagree", instead just your opinion.
  83. - User language to be used in the entire summary: ${lang}
  84. - Before everything, add quality of the article, like "<strong>Article Quality:</strong> <span class=article-good>8/10</span>", where 1 is bad and 10 is excellent.
  85. - For the quality class use:
  86. <span class=article-excellent>9/10</span> (or 10)
  87. <span class=article-good>8/10</span>
  88. <span class=article-average>7/10</span>
  89. <span class=article-bad>6/10</span>
  90. <span class=article-very-bad>5/10</span> (or less)
  91. - "Opinion:", "Article Quality:" should be in user language, e.g. "Opinião:", "Qualidade do artigo:" for Português.
  92.  
  93. Article Title: ${title}
  94. Article Content: ${content}`
  95.  
  96. // --- Variáveis de Estado ---
  97. let activeModel = 'gemini-2.5-flash-preview-04-17' // ID do modelo ativo selecionado por padrão ou pelo usuário
  98. let articleData = null // Armazena o título e conteúdo extraído do artigo { title, content }
  99. let customModels = [] // Array para armazenar modelos customizados carregados do storage { id, service }
  100. let longPressTimer = null // Timer para detectar long press (ou tap-and-hold) no botão 'S'
  101. let isLongPress = false // Flag para indicar se ocorreu long press/tap-and-hold
  102.  
  103. // --- Funções Principais ---
  104.  
  105. /**
  106. * Função principal de inicialização do script.
  107. * Carrega modelos customizados, adiciona listener de teclado,
  108. * tenta extrair dados do artigo, e se bem-sucedido, adiciona o botão e listeners de foco.
  109. */
  110. async function initialize() {
  111. customModels = await getCustomModels() // Carrega modelos customizados do storage
  112. document.addEventListener('keydown', handleKeyPress) // Listener para atalhos (Alt+S, Esc)
  113. articleData = getArticleData() // Tenta extrair o conteúdo do artigo
  114. if (articleData) { // Se encontrou conteúdo legível:
  115. addSummarizeButton() // Adiciona o botão flutuante e o dropdown
  116. showElement(BUTTON_ID) // Torna o botão visível
  117. setupFocusListeners() // Configura para esconder/mostrar botão em campos de input
  118. // Define o último modelo usado (ou padrão) como ativo
  119. activeModel = await GM.getValue('last_used_model', activeModel)
  120. }
  121. }
  122.  
  123. /**
  124. * Tenta extrair o conteúdo principal da página usando a biblioteca Readability.js.
  125. * @returns {object|null} - Um objeto { title, content } se bem-sucedido, ou null se não for legível ou ocorrer erro.
  126. */
  127. function getArticleData() {
  128. try {
  129. const docClone = document.cloneNode(true) // Clona o documento para não modificar o original
  130. // Remove elementos que podem interferir com a extração (scripts, estilos, imagens, etc.)
  131. docClone.querySelectorAll('script, style, noscript, iframe, figure, img, svg, header, footer, nav').forEach(el => el.remove())
  132. // Verifica se a página é provavelmente legível usando a heurística da biblioteca
  133. if (!isProbablyReaderable(docClone)) {
  134. console.log('Summarize with AI: Page not detected as readerable.')
  135. return null // Retorna nulo se não parecer um artigo
  136. }
  137. const reader = new Readability(docClone) // Instancia o Readability
  138. const article = reader.parse() // Tenta extrair o conteúdo principal
  139. // Retorna dados se o conteúdo foi extraído e não está vazio/apenas espaços
  140. return (article?.content && article.textContent?.trim())
  141. ? {title: article.title, content: article.textContent.trim()} // Retorna título e texto limpo
  142. : null // Retorna nulo se não conseguiu extrair conteúdo de texto
  143. } catch (error) {
  144. console.error('Summarize with AI: Article parsing failed:', error)
  145. return null // Retorna null em caso de erro na extração
  146. }
  147. }
  148.  
  149. /**
  150. * Adiciona o botão flutuante 'S' e o dropdown de seleção de modelo ao DOM.
  151. * Configura os event listeners do botão (click, long press, touch) e injeta estilos.
  152. */
  153. function addSummarizeButton() {
  154. // Evita adicionar o botão múltiplas vezes se o script for executado novamente por algum motivo
  155. if (document.getElementById(BUTTON_ID)) return
  156.  
  157. // Cria o botão 'S'
  158. const button = document.createElement('div')
  159. button.id = BUTTON_ID
  160. button.textContent = 'S' // Texto simples e pequeno 'S'
  161. button.title = 'Summarize (Alt+S) / Long Press or Tap & Hold to Select Model' // Tooltip atualizado
  162. document.body.appendChild(button)
  163.  
  164. // Cria o dropdown (inicialmente oculto)
  165. const dropdown = createDropdownElement() // Cria o elemento base do dropdown
  166. document.body.appendChild(dropdown)
  167. populateDropdown(dropdown) // Preenche o dropdown com os modelos disponíveis
  168.  
  169. // Listener para clique simples (ou tap): Inicia a sumarização com o modelo ativo
  170. button.addEventListener('click', () => {
  171. // Só executa a sumarização se *não* foi um long press/tap-and-hold que abriu o menu
  172. if (!isLongPress) {
  173. processSummarization() // Chama a função principal de sumarização
  174. }
  175. // Reseta a flag de long press para o próximo clique/toque
  176. isLongPress = false
  177. })
  178.  
  179. // ---- Lógica para Long Press (Mouse) e Tap & Hold (Touch) ----
  180.  
  181. // Função para iniciar o timer de long press/tap-and-hold
  182. const startLongPressTimer = (event) => {
  183. isLongPress = false // Reseta a flag
  184. clearTimeout(longPressTimer) // Limpa timer anterior, se houver
  185. longPressTimer = setTimeout(() => {
  186. isLongPress = true // Marca que ocorreu long press/tap-and-hold
  187. toggleDropdown(event) // Abre/fecha o dropdown
  188. }, LONG_PRESS_DURATION)
  189. }
  190.  
  191. // Função para cancelar o timer
  192. const cancelLongPressTimer = () => {
  193. clearTimeout(longPressTimer)
  194. }
  195.  
  196. // Listeners de Mouse
  197. button.addEventListener('mousedown', startLongPressTimer)
  198. button.addEventListener('mouseup', cancelLongPressTimer)
  199. button.addEventListener('mouseleave', cancelLongPressTimer) // Cancela se o mouse sair
  200.  
  201. // Listeners de Touch (para dispositivos móveis)
  202. button.addEventListener('touchstart', startLongPressTimer, {passive: true}) // Inicia o timer ao tocar
  203. button.addEventListener('touchend', cancelLongPressTimer) // Cancela ao soltar o dedo
  204. button.addEventListener('touchmove', cancelLongPressTimer) // Cancela se o dedo mover (evita abrir menu ao rolar)
  205. button.addEventListener('touchcancel', cancelLongPressTimer) // Cancela se o toque for interrompido
  206.  
  207. // -------------------------------------------------------------
  208.  
  209. // Listener global para clique fora do dropdown para fechá-lo
  210. document.addEventListener('click', handleOutsideClick)
  211.  
  212. // Injeta os estilos CSS necessários para a interface (botão, dropdown, overlay)
  213. injectStyles()
  214. }
  215.  
  216.  
  217. // --- Funções de UI (Dropdown, Overlay, Notificações) ---
  218.  
  219. /**
  220. * Cria o elemento base (container) do dropdown.
  221. * @returns {HTMLElement} - O elemento div do dropdown, inicialmente vazio e oculto.
  222. */
  223. function createDropdownElement() {
  224. const dropdown = document.createElement('div')
  225. dropdown.id = DROPDOWN_ID
  226. dropdown.style.display = 'none' // Começa oculto por padrão
  227. return dropdown
  228. }
  229.  
  230. /**
  231. * Preenche o elemento dropdown com os grupos de modelos (padrão e customizados)
  232. * e a opção para adicionar novos modelos. Adiciona links de reset de API Key.
  233. * @param {HTMLElement} dropdownElement - O elemento do dropdown a ser preenchido.
  234. */
  235. function populateDropdown(dropdownElement) {
  236. dropdownElement.innerHTML = '' // Limpa conteúdo anterior para reconstruir
  237.  
  238. // Itera sobre cada grupo de serviço (OpenAI, Gemini) definido em MODEL_GROUPS
  239. Object.entries(MODEL_GROUPS).forEach(([service, group]) => {
  240. // Combina modelos padrão e customizados para este serviço específico
  241. const standardModels = group.models || [] // Modelos padrão do grupo
  242. const serviceCustomModels = customModels
  243. .filter(m => m.service === service) // Filtra modelos customizados pelo serviço atual
  244. .map(m => ({id: m.id})) // Mapeia para o formato {id}, pois customizados não têm 'name' ou 'params' definidos aqui
  245.  
  246. // Combina as listas e remove duplicatas baseadas no ID (ignorando maiúsculas/minúsculas)
  247. const allModelObjects = [...standardModels, ...serviceCustomModels]
  248. .reduce((acc, model) => {
  249. // Adiciona o modelo ao acumulador 'acc' apenas se um modelo com o mesmo ID (case-insensitive) ainda não existir
  250. if (!acc.some(existing => existing.id.toLowerCase() === model.id.toLowerCase())) {
  251. acc.push(model)
  252. }
  253. return acc
  254. }, [])
  255. .sort((a, b) => a.id.localeCompare(b.id)) // Ordena os modelos alfabeticamente pelo ID
  256.  
  257. // Se houver modelos para este serviço após a combinação e filtragem
  258. if (allModelObjects.length > 0) {
  259. const groupDiv = document.createElement('div') // Cria um container para o grupo
  260. groupDiv.className = 'model-group' // Classe para estilização
  261. // Cria o cabeçalho do grupo (Nome do Serviço + Link de Reset Key)
  262. groupDiv.appendChild(createHeader(group.name, service))
  263. // Adiciona cada item de modelo ao container do grupo
  264. allModelObjects.forEach(modelObj => groupDiv.appendChild(createModelItem(modelObj)))
  265. dropdownElement.appendChild(groupDiv) // Adiciona o grupo completo ao dropdown
  266. }
  267. })
  268.  
  269. // Adiciona um separador visual antes do item "+ Adicionar"
  270. const separator = document.createElement('hr')
  271. separator.style.margin = '8px 0'
  272. separator.style.border = 'none'
  273. separator.style.borderTop = '1px solid #eee' // Linha cinza clara
  274. dropdownElement.appendChild(separator)
  275. // Adiciona o item "+ Adicionar Modelo Customizado" ao final do dropdown
  276. dropdownElement.appendChild(createAddModelItem())
  277. }
  278.  
  279. /**
  280. * Cria um elemento de cabeçalho para um grupo de modelos no dropdown,
  281. * incluindo o nome do serviço e um link funcional para resetar a API Key associada.
  282. * @param {string} text - O texto do cabeçalho (nome do serviço, ex: "OpenAI").
  283. * @param {string} service - A chave do serviço ('openai' ou 'gemini').
  284. * @returns {HTMLElement} - O elemento div do cabeçalho completo.
  285. */
  286. function createHeader(text, service) {
  287. // Container principal para alinhar o texto e o link usando flexbox
  288. const headerContainer = document.createElement('div')
  289. headerContainer.className = 'group-header-container'
  290.  
  291. // Span para exibir o nome do serviço
  292. const headerText = document.createElement('span')
  293. headerText.className = 'group-header-text'
  294. headerText.textContent = text // Ex: "OpenAI"
  295.  
  296. // Link 'a' para a funcionalidade de resetar a API Key
  297. const resetLink = document.createElement('a')
  298. resetLink.href = '#' // Link vazio, a ação é via JS
  299. resetLink.textContent = 'Reset Key' // Texto do link
  300. resetLink.className = 'reset-key-link' // Classe para estilização
  301. resetLink.title = `Reset ${text} API Key` // Tooltip informativo
  302. // Listener de clique no link de reset
  303. resetLink.addEventListener('click', (e) => {
  304. e.preventDefault() // Previne a navegação padrão do link '#'
  305. e.stopPropagation() // Impede que o clique feche o dropdown imediatamente
  306. handleApiKeyReset(service) // Chama a função que lida com o reset da chave para este serviço
  307. })
  308.  
  309. // Adiciona o texto e o link ao container
  310. headerContainer.appendChild(headerText)
  311. headerContainer.appendChild(resetLink)
  312. return headerContainer // Retorna o container completo do cabeçalho
  313. }
  314.  
  315. /**
  316. * Cria um item clicável para um modelo específico dentro do dropdown.
  317. * Ao ser clicado, seleciona o modelo, fecha o dropdown e inicia a sumarização.
  318. * @param {object} modelObj - O objeto do modelo contendo { id, name?, params? }.
  319. * @returns {HTMLElement} - O elemento div do item do modelo.
  320. */
  321. function createModelItem(modelObj) {
  322. const item = document.createElement('div') // Cria o elemento div para o item
  323. item.className = 'model-item' // Classe para estilização
  324. // Define o texto do item: usa o nome amigável (modelObj.name) se existir, caso contrário, usa o ID do modelo
  325. item.textContent = modelObj.name || modelObj.id
  326. // Adiciona um destaque visual (negrito e cor) se este item corresponde ao modelo ativo atualmente
  327. if (modelObj.id === activeModel) {
  328. item.style.fontWeight = 'bold' // Negrito
  329. item.style.color = '#1A73E8' // Azul para destacar
  330. }
  331. // Listener de clique no item do modelo
  332. item.addEventListener('click', async () => {
  333. activeModel = modelObj.id // Atualiza a variável global 'activeModel' com o ID selecionado
  334. await GM.setValue('last_used_model', activeModel) // Salva o ID do modelo selecionado no storage para persistência
  335. hideElement(DROPDOWN_ID) // Esconde o dropdown após a seleção
  336. processSummarization() // Inicia imediatamente o processo de sumarização com o novo modelo ativo
  337. })
  338. return item // Retorna o elemento do item criado
  339. }
  340.  
  341. /**
  342. * Cria o item clicável "+ Add Custom Model" no final do dropdown.
  343. * Ao ser clicado, esconde o dropdown e inicia o fluxo para adicionar um novo modelo customizado.
  344. * @returns {HTMLElement} - O elemento div do item "+ Add Custom Model".
  345. */
  346. function createAddModelItem() {
  347. const item = document.createElement('div') // Cria o elemento div
  348. item.id = ADD_MODEL_ITEM_ID // ID específico para este item
  349. item.className = 'model-item add-model-item' // Classes para estilização (geral e específica)
  350. item.textContent = '+ Add Custom Model' // Texto do item
  351. // Listener de clique no item "+ Add Custom Model"
  352. item.addEventListener('click', async (e) => {
  353. e.stopPropagation() // Impede que o clique feche o dropdown (que seria o comportamento padrão do handleOutsideClick)
  354. hideElement(DROPDOWN_ID) // Esconde o dropdown antes de mostrar os prompts para adicionar modelo
  355. await handleAddModel() // Chama a função que gerencia a adição de um modelo customizado
  356. })
  357. return item // Retorna o elemento do item criado
  358. }
  359.  
  360. /**
  361. * Mostra ou esconde o dropdown de seleção de modelo.
  362. * Repopula o dropdown ao mostrar para garantir que a lista de modelos e o estado do link de reset estejam atualizados.
  363. * @param {Event} [e] - O objeto do evento de clique/mousedown/touchstart (opcional, usado para stopPropagation).
  364. */
  365. function toggleDropdown(e) {
  366. if (e) e.stopPropagation() // Impede que o evento (que abriu o dropdown) também o feche imediatamente via handleOutsideClick
  367. const dropdown = document.getElementById(DROPDOWN_ID)
  368. if (dropdown) {
  369. const isHidden = dropdown.style.display === 'none' // Verifica se o dropdown está atualmente oculto
  370. if (isHidden) {
  371. // Se estiver oculto, primeiro repopula com os dados mais recentes (modelos, status ativo)
  372. populateDropdown(dropdown)
  373. // Depois mostra o dropdown
  374. showElement(DROPDOWN_ID)
  375. } else {
  376. // Se estiver visível, apenas esconde
  377. hideElement(DROPDOWN_ID)
  378. }
  379. }
  380. }
  381.  
  382. /**
  383. * Fecha o dropdown se um clique ocorrer fora da área do dropdown e fora do botão 'S'.
  384. * Previne o fechamento acidental ao clicar dentro do próprio dropdown ou no botão.
  385. * @param {Event} event - O objeto do evento de clique global.
  386. */
  387. function handleOutsideClick(event) {
  388. const dropdown = document.getElementById(DROPDOWN_ID)
  389. const button = document.getElementById(BUTTON_ID)
  390. // Verifica se o dropdown existe, está visível, E se o alvo do clique NÃO está contido no dropdown NEM no botão 'S'
  391. if (dropdown && dropdown.style.display !== 'none' &&
  392. !dropdown.contains(event.target) && // O clique não foi dentro do dropdown
  393. !button?.contains(event.target)) { // O clique não foi no botão 'S' (usa optional chaining por segurança)
  394. hideElement(DROPDOWN_ID) // Esconde o dropdown
  395. }
  396. }
  397.  
  398. /**
  399. * Exibe o overlay de sumarização com o conteúdo HTML fornecido.
  400. * Cria os elementos do overlay (fundo, container de conteúdo, botão de fechar) se não existirem.
  401. * Adiciona um botão "Try Again" se `isError` for verdadeiro.
  402. * @param {string} contentHTML - O conteúdo HTML a ser exibido (pode ser mensagem de loading, sumário ou erro).
  403. * @param {boolean} [isError=false] - Indica se o conteúdo é uma mensagem de erro, para adicionar o botão "Try Again".
  404. */
  405. function showSummaryOverlay(contentHTML, isError = false) {
  406. // Se o overlay já existe na página (por exemplo, de uma tentativa anterior),
  407. // apenas atualiza seu conteúdo em vez de criar um novo.
  408. if (document.getElementById(OVERLAY_ID)) {
  409. updateSummaryOverlay(contentHTML, isError)
  410. return
  411. }
  412.  
  413. // Cria o elemento div principal do overlay (fundo escuro)
  414. const overlay = document.createElement('div')
  415. overlay.id = OVERLAY_ID // Define o ID para estilização e referência
  416.  
  417. // Cria o conteúdo interno do overlay, incluindo o botão de fechar ('×') e o conteúdo dinâmico (loading/sumário/erro)
  418. let finalContentHTML = `<div id="${CLOSE_BUTTON_ID}" title="Close (Esc)">×</div>${contentHTML}`
  419. // Se for uma mensagem de erro, adiciona o botão "Try Again" abaixo do conteúdo
  420. if (isError) {
  421. finalContentHTML += `<button id="${RETRY_BUTTON_ID}" class="retry-button">Try Again</button>`
  422. }
  423. // Define o HTML interno do container de conteúdo (caixa branca/escura no centro)
  424. overlay.innerHTML = `<div id="${CONTENT_ID}">${finalContentHTML}</div>`
  425.  
  426. // Adiciona o overlay completo ao body do documento
  427. document.body.appendChild(overlay)
  428. // Trava o scroll da página principal enquanto o overlay estiver visível
  429. document.body.style.overflow = 'hidden'
  430.  
  431. // Adiciona listeners de evento para fechar o overlay:
  432. // 1. Clicar no botão '×'
  433. document.getElementById(CLOSE_BUTTON_ID).addEventListener('click', closeOverlay)
  434. // 2. Clicar no fundo escuro do overlay (fora da caixa de conteúdo)
  435. overlay.addEventListener('click', e => e.target === overlay && closeOverlay()) // Fecha apenas se o clique for no próprio overlay
  436.  
  437. // Adiciona listener para o botão "Try Again", se ele existir (em caso de erro)
  438. // Ao clicar, simplesmente chama a função processSummarization() novamente para tentar refazer a sumarização.
  439. document.getElementById(RETRY_BUTTON_ID)?.addEventListener('click', processSummarization)
  440. }
  441.  
  442. /**
  443. * Fecha e remove completamente o overlay de sumarização do DOM.
  444. * Restaura o scroll normal da página principal.
  445. */
  446. function closeOverlay() {
  447. const overlay = document.getElementById(OVERLAY_ID) // Encontra o elemento do overlay
  448. if (overlay) {
  449. overlay.remove() // Remove o overlay do DOM
  450. document.body.style.overflow = '' // Libera o scroll do body, restaurando o estado anterior
  451. }
  452. }
  453.  
  454. /**
  455. * Atualiza o conteúdo dentro de um overlay de sumarização já existente.
  456. * Usado para mudar de "Loading..." para o sumário final ou para exibir uma mensagem de erro após o loading.
  457. * Garante que o botão de fechar e o botão "Try Again" (se aplicável) sejam recriados corretamente com seus listeners.
  458. * @param {string} contentHTML - O novo conteúdo HTML a ser inserido.
  459. * @param {boolean} [isError=false] - Indica se o novo conteúdo é uma mensagem de erro, para adicionar o botão "Try Again".
  460. */
  461. function updateSummaryOverlay(contentHTML, isError = false) {
  462. const contentDiv = document.getElementById(CONTENT_ID) // Encontra o container interno do conteúdo
  463. if (contentDiv) {
  464. // Recria o HTML interno, garantindo que o botão de fechar '×' sempre exista
  465. let finalContentHTML = `<div id="${CLOSE_BUTTON_ID}" title="Close (Esc)">×</div>${contentHTML}`
  466. // Adiciona o botão "Try Again" se for um erro
  467. if (isError) {
  468. finalContentHTML += `<button id="${RETRY_BUTTON_ID}" class="retry-button">Try Again</button>`
  469. }
  470. contentDiv.innerHTML = finalContentHTML // Substitui o conteúdo antigo pelo novo
  471.  
  472. // Reatribui o listener de clique ao novo botão de fechar (o antigo foi removido com innerHTML)
  473. document.getElementById(CLOSE_BUTTON_ID)?.addEventListener('click', closeOverlay)
  474. // Reatribui o listener de clique ao novo botão "Try Again", se ele existir
  475. document.getElementById(RETRY_BUTTON_ID)?.addEventListener('click', processSummarization)
  476. }
  477. }
  478.  
  479. /**
  480. * Exibe uma notificação de erro temporária na parte inferior central da tela.
  481. * Usada para erros que não justificam mostrar o overlay completo (ex: falha ao obter API key).
  482. * @param {string} message - A mensagem de erro a ser exibida.
  483. */
  484. function showErrorNotification(message) {
  485. document.getElementById(ERROR_ID)?.remove() // Remove qualquer notificação de erro anterior
  486.  
  487. // Cria o elemento div para a notificação
  488. const errorDiv = document.createElement('div')
  489. errorDiv.id = ERROR_ID // ID para estilização e referência
  490. errorDiv.innerText = message // Define o texto da mensagem
  491. document.body.appendChild(errorDiv) // Adiciona ao body
  492.  
  493. // Define um timer para remover automaticamente a notificação após 4 segundos
  494. setTimeout(() => errorDiv.remove(), 4000)
  495. }
  496.  
  497. /**
  498. * Esconde um elemento do DOM definindo seu estilo `display` como 'none'.
  499. * @param {string} id - O ID do elemento a ser escondido.
  500. */
  501. function hideElement(id) {
  502. const el = document.getElementById(id)
  503. if (el) el.style.display = 'none'
  504. }
  505.  
  506. /**
  507. * Mostra um elemento do DOM. Usa 'flex' para o botão 'S' (para centralizar o texto)
  508. * e 'block' para outros elementos como o dropdown e o overlay.
  509. * @param {string} id - O ID do elemento a ser mostrado.
  510. */
  511. function showElement(id) {
  512. const el = document.getElementById(id)
  513. if (el) {
  514. // Define 'display' como 'flex' para o botão (conforme estilo CSS) e 'block' para outros (dropdown/overlay)
  515. el.style.display = (id === BUTTON_ID) ? 'flex' : 'block'
  516. }
  517. }
  518.  
  519. // --- Funções de Lógica (Sumarização, API, Modelos) ---
  520.  
  521. /**
  522. * Encontra o objeto de configuração completo para o modelo atualmente ativo (`activeModel`).
  523. * Busca primeiro nos modelos padrão (`MODEL_GROUPS`) e depois nos modelos customizados (`customModels`).
  524. * @returns {object|null} Um objeto contendo { id, service, name?, params? } se encontrado, ou null caso contrário.
  525. * 'name' e 'params' podem não estar presentes para modelos customizados.
  526. */
  527. function getActiveModelConfig() {
  528. // Itera sobre os serviços definidos (openai, gemini)
  529. for (const service in MODEL_GROUPS) {
  530. const group = MODEL_GROUPS[service] // Acessa a configuração do grupo (baseUrl, models, defaultParams)
  531. // Tenta encontrar o modelo ativo dentro dos modelos padrão deste serviço
  532. const modelConfig = group.models.find(m => m.id === activeModel)
  533. if (modelConfig) {
  534. // Se encontrado, retorna uma cópia do objeto de configuração do modelo,
  535. // adicionando a chave 'service' para saber a qual serviço pertence.
  536. return {...modelConfig, service: service}
  537. }
  538. }
  539. // Se não encontrado nos modelos padrão, procura nos modelos customizados
  540. const customConfig = customModels.find(m => m.id === activeModel)
  541. if (customConfig) {
  542. // Se encontrado nos customizados, retorna uma cópia do objeto customizado { id, service }.
  543. // Modelos customizados, por padrão, não armazenam 'name' ou 'params' neste script.
  544. return {...customConfig}
  545. }
  546. // Se não encontrado em nenhum lugar, loga um erro e retorna null
  547. console.error(`Summarize with AI: Active model configuration not found for ID: ${activeModel}`)
  548. return null
  549. }
  550.  
  551. /**
  552. * Orquestra todo o processo de sumarização:
  553. * 1. Verifica se os dados do artigo foram extraídos.
  554. * 2. Obtém a configuração do modelo ativo.
  555. * 3. Obtém a chave da API para o serviço correspondente.
  556. * 4. Mostra o overlay com uma mensagem de "Loading..." e o nome do modelo.
  557. * 5. Prepara e envia a requisição para a API de IA.
  558. * 6. Trata a resposta da API (sucesso ou erro).
  559. * 7. Exibe o sumário ou uma mensagem de erro no overlay.
  560. */
  561. async function processSummarization() {
  562. try {
  563. // Etapa 1: Verifica se temos o conteúdo do artigo
  564. if (!articleData) {
  565. showErrorNotification('Article content not found or not readable.') // Notificação se não há artigo
  566. return // Interrompe se não há o que sumarizar
  567. }
  568.  
  569. // Etapa 2: Obtém a configuração do modelo ativo
  570. const modelConfig = getActiveModelConfig()
  571. if (!modelConfig) {
  572. // Exibe erro se a configuração do modelo selecionado não for encontrada (pode acontecer se for removido)
  573. showErrorNotification(`Configuration for model "${activeModel}" not found. Please select another model.`)
  574. return // Interrompe
  575. }
  576.  
  577. // Determina o nome a ser exibido no overlay (usa 'name' se disponível, senão 'id')
  578. const modelDisplayName = modelConfig.name || modelConfig.id
  579. const service = modelConfig.service // Obtém o serviço ('openai' ou 'gemini') da configuração
  580.  
  581. // Etapa 3: Obtém a chave da API
  582. const apiKey = await getApiKey(service)
  583. if (!apiKey) { // Se a chave não for encontrada ou estiver vazia
  584. // Monta mensagem de erro instruindo o usuário
  585. const errorMsg = `API key for ${service.toUpperCase()} is required. Click the 'Reset Key' link in the model selection menu (long-press 'S' button).`
  586. // Verifica se o overlay já está aberto (pode ser um retry após falha de chave)
  587. if (document.getElementById(OVERLAY_ID)) {
  588. // Mostra o erro dentro do overlay existente, sem botão de retry para este caso específico
  589. updateSummaryOverlay(`<p style="color: red;">${errorMsg}</p>`, false)
  590. } else {
  591. // Se o overlay não estava aberto, mostra como uma notificação flutuante
  592. showErrorNotification(errorMsg)
  593. }
  594. return // Interrompe se não houver chave de API
  595. }
  596.  
  597. // Etapa 4: Mostra feedback de "Loading" no overlay
  598. const loadingMessage = `<p class="glow">Summarizing with ${modelDisplayName}... </p>` // Mensagem com efeito 'glow'
  599. // Verifica se o overlay já existe (caso seja um retry)
  600. if (document.getElementById(OVERLAY_ID)) {
  601. updateSummaryOverlay(loadingMessage) // Atualiza o overlay existente com a mensagem de loading
  602. } else {
  603. showSummaryOverlay(loadingMessage) // Cria um novo overlay com a mensagem de loading
  604. }
  605.  
  606. // Etapa 5: Prepara e envia a requisição para a API
  607. // Prepara o payload com título, conteúdo do artigo e idioma do navegador
  608. const payload = {title: articleData.title, content: articleData.content, lang: navigator.language || 'en-US'}
  609. // Envia a requisição passando serviço, chave, payload e a configuração do modelo
  610. const response = await sendApiRequest(service, apiKey, payload, modelConfig)
  611.  
  612. // Etapa 6: Trata a resposta da API
  613. handleApiResponse(response, service) // Processa a resposta (extrai sumário ou lança erro)
  614.  
  615. } catch (error) {
  616. // Etapa 7: Exibe erros no overlay
  617. const errorMsg = `Error: ${error.message}` // Mensagem de erro concisa
  618. console.error('Summarize with AI:', errorMsg, error) // Loga o erro completo no console para depuração
  619. // Mostra a mensagem de erro no overlay (criando um novo se não existir)
  620. // e inclui o botão "Try Again" (isError = true)
  621. showSummaryOverlay(`<p style="color: red;">${errorMsg}</p>`, true)
  622. hideElement(DROPDOWN_ID) // Garante que o dropdown esteja oculto em caso de erro
  623. }
  624. }
  625.  
  626. /**
  627. * Envia a requisição HTTP para a API de IA (OpenAI ou Gemini) usando GM.xmlHttpRequest.
  628. * @param {string} service - O nome do serviço ('openai' ou 'gemini').
  629. * @param {string} apiKey - A chave da API para autenticação.
  630. * @param {object} payload - Objeto contendo { title, content, lang } do artigo.
  631. * @param {object} modelConfig - A configuração completa do modelo ativo { id, service, name?, params? }.
  632. * @returns {Promise<object>} - Uma promessa que resolve com um objeto contendo { status, data, statusText } da resposta HTTP.
  633. * 'data' será o objeto JSON parseado ou um objeto vazio em caso de falha no parse.
  634. */
  635. async function sendApiRequest(service, apiKey, payload, modelConfig) {
  636. const group = MODEL_GROUPS[service] // Obtém a configuração base do serviço (URL, etc.)
  637. // Define a URL da API específica para o serviço
  638. const url = service === 'openai'
  639. ? group.baseUrl // URL base da API OpenAI (modelo é enviado no corpo)
  640. : `${group.baseUrl}${modelConfig.id}:generateContent?key=${apiKey}` // URL da API Gemini (ID do modelo e chave na URL)
  641.  
  642. // Retorna uma nova Promise que encapsula a chamada GM.xmlHttpRequest
  643. return new Promise((resolve, reject) => {
  644. GM.xmlHttpRequest({
  645. method: 'POST', // Método HTTP para enviar dados
  646. url: url, // URL da API definida acima
  647. headers: getHeaders(service, apiKey), // Obtém os cabeçalhos HTTP necessários (Content-Type, Authorization se OpenAI)
  648. // Constrói o corpo da requisição (JSON) específico para o serviço e modelo
  649. data: JSON.stringify(buildRequestBody(service, payload, modelConfig)),
  650. responseType: 'json', // Indica ao Tampermonkey para tentar parsear a resposta como JSON automaticamente
  651. timeout: 60000, // Define um timeout de 60 segundos para a requisição
  652. // Callback executado quando a requisição é concluída com sucesso (status HTTP recebido)
  653. onload: response => {
  654. // GM.xmlHttpRequest pode retornar o JSON parseado em 'response.response'
  655. // ou a string original em 'response.responseText'. Precisamos lidar com ambos.
  656. const responseData = response.response || response.responseText
  657. // Resolve a Promise com um objeto contendo o status HTTP, os dados (parseados ou string) e o statusText
  658. resolve({
  659. status: response.status,
  660. // Tenta garantir que 'data' seja um objeto, mesmo que 'responseType: json' falhe
  661. data: typeof responseData === 'object' ? responseData : JSON.parse(responseData || '{}'),
  662. statusText: response.statusText,
  663. })
  664. },
  665. // Callbacks para diferentes tipos de erro na requisição
  666. onerror: error => reject(new Error(`Network error: ${error.statusText || 'Failed to connect'}`)), // Erro de rede
  667. onabort: () => reject(new Error('Request aborted')), // Requisição abortada
  668. ontimeout: () => reject(new Error('Request timed out after 60 seconds')), // Timeout atingido
  669. })
  670. })
  671. }
  672.  
  673. /**
  674. * Processa a resposta recebida da API de IA.
  675. * Verifica o status HTTP, extrai o conteúdo do sumário do corpo da resposta (dependendo do serviço),
  676. * lida com possíveis erros da API (como bloqueio por segurança ou limites de token),
  677. * limpa o texto do sumário e atualiza o overlay com o resultado final.
  678. * @param {object} response - O objeto de resposta resolvido da Promise de `sendApiRequest` (contém status, data, statusText).
  679. * @param {string} service - O nome do serviço que respondeu ('openai' ou 'gemini').
  680. * @throws {Error} - Lança um erro se a API retornar um status não-2xx, se a resposta não contiver um sumário válido,
  681. * ou se ocorrer um bloqueio por segurança.
  682. */
  683. function handleApiResponse(response, service) {
  684. const {status, data, statusText} = response // Desestrutura o objeto de resposta
  685.  
  686. // Verifica se o status HTTP indica sucesso (códigos 200-299)
  687. if (status < 200 || status >= 300) {
  688. // Tenta extrair uma mensagem de erro mais específica do corpo da resposta JSON
  689. // (OpenAI usa data.error.message, Gemini pode usar data.message ou data.error.message)
  690. const errorDetails = data?.error?.message || data?.message || statusText || 'Unknown API error'
  691. // Lança um erro que será capturado pelo 'catch' em processSummarization
  692. throw new Error(`API Error (${status}): ${errorDetails}`)
  693. }
  694.  
  695. // Extrai o texto bruto do sumário da resposta, dependendo da estrutura de cada API
  696. let rawSummary = '' // Inicializa a variável para o sumário
  697. if (service === 'openai') {
  698. // Para OpenAI, o sumário está em choices[0].message.content
  699. const choice = data?.choices?.[0]
  700. rawSummary = choice?.message?.content
  701.  
  702. // Loga o motivo pelo qual a geração parou (útil para depuração)
  703. const finishReason = choice?.finish_reason
  704. console.log(`Summarize with AI: OpenAI Finish Reason: ${finishReason} (Model: ${activeModel})`)
  705. // Adiciona um aviso se o sumário foi cortado por atingir o limite de tokens
  706. if (finishReason === 'length') {
  707. console.warn('Summarize with AI: Summary may be incomplete because the max token limit was reached.')
  708. }
  709.  
  710. } else if (service === 'gemini') {
  711. // Para Gemini, o sumário está em candidates[0].content.parts[0].text
  712. const candidate = data?.candidates?.[0]
  713. const finishReason = candidate?.finishReason // Motivo da finalização (STOP, MAX_TOKENS, SAFETY, etc.)
  714. console.log(`Summarize with AI: Gemini Finish Reason: ${finishReason} (Model: ${activeModel})`)
  715.  
  716. // Verifica se a finalização foi devido a bloqueio de segurança
  717. if (finishReason === 'SAFETY') {
  718. // Tenta obter detalhes das categorias de segurança que causaram o bloqueio
  719. const safetyRatings = candidate.safetyRatings?.map(r => `${r.category}: ${r.probability}`).join(', ')
  720. // Lança um erro específico para bloqueio de segurança
  721. throw new Error(`Content blocked due to safety concerns (${safetyRatings || 'No details'}).`)
  722. }
  723. // Adiciona um aviso se o sumário foi cortado por atingir o limite de tokens
  724. if (finishReason === 'MAX_TOKENS') {
  725. console.warn('Summarize with AI: Summary may be incomplete because the max token limit was reached.')
  726. }
  727.  
  728. // Extrai o texto da parte principal da resposta do candidato
  729. // Verificação robusta para garantir que 'parts' existe e contém texto
  730. if (candidate?.content?.parts?.length > 0 && candidate.content.parts[0].text) {
  731. rawSummary = candidate.content.parts[0].text
  732. } else if (finishReason && !['STOP', 'SAFETY', 'MAX_TOKENS'].includes(finishReason)) {
  733. // Loga um aviso se o motivo da finalização for inesperado e não houver texto
  734. console.warn(`Summarize with AI: Gemini response structure missing expected text content or unusual finish reason: ${finishReason}`, candidate)
  735. } else if (!rawSummary && !data?.error) { // Se não houver texto E não for um erro já tratado
  736. // Loga um aviso se a estrutura esperada estiver ausente
  737. console.warn('Summarize with AI: Gemini response structure missing expected text content.', candidate)
  738. }
  739. // Se rawSummary continuar vazio aqui, o erro "did not contain valid summary" será lançado abaixo.
  740. }
  741.  
  742. // Verifica se, após a extração, a variável rawSummary contém algum texto.
  743. // Ignora esta verificação se já houver um erro explícito na resposta (data.error)
  744. if (!rawSummary && !data?.error) {
  745. console.error('Summarize with AI: API Response Data:', data) // Loga a resposta completa para depuração
  746. throw new Error('API response did not contain a valid summary.') // Lança erro se o sumário estiver vazio
  747. }
  748.  
  749. // Limpa quebras de linha (\n) que não fazem parte de tags HTML (substitui por espaço)
  750. // e comprime múltiplos espaços em um único espaço.
  751. // Isso melhora a formatação caso a API retorne quebras de linha desnecessárias.
  752. const cleanedSummary = rawSummary.replace(/\n/g, ' ').replace(/ {2,}/g, ' ').trim()
  753.  
  754. // Atualiza o overlay com o sumário final limpo.
  755. // Passa 'false' para isError, indicando que é um sucesso e não precisa do botão "Try Again".
  756. updateSummaryOverlay(cleanedSummary, false)
  757. }
  758.  
  759. /**
  760. * Constrói o objeto do corpo (payload) da requisição para a API (OpenAI ou Gemini).
  761. * Inclui o prompt do sistema e os parâmetros de geração (como max tokens),
  762. * usando valores específicos do modelo (modelConfig.params) ou os padrões do serviço (MODEL_GROUPS[service].defaultParams).
  763. * @param {string} service - O nome do serviço ('openai' ou 'gemini').
  764. * @param {object} payload - Objeto com { title, content, lang } do artigo.
  765. * @param {object} modelConfig - A configuração completa do modelo ativo { id, service, name?, params? }.
  766. * @returns {object} - O objeto pronto para ser serializado em JSON e enviado como corpo da requisição.
  767. */
  768. function buildRequestBody(service, {title, content, lang}, modelConfig) {
  769. // Gera o prompt completo que será enviado à IA, incluindo instruções e o conteúdo do artigo
  770. const systemPrompt = PROMPT_TEMPLATE(title, content, lang)
  771. // Obtém os parâmetros padrão definidos para o serviço (ex: default max tokens)
  772. const serviceDefaults = MODEL_GROUPS[service]?.defaultParams || {}
  773. // Obtém os parâmetros específicos definidos para este modelo (se houver)
  774. const modelSpecificParams = modelConfig?.params || {}
  775.  
  776. if (service === 'openai') {
  777. // Mescla os parâmetros: os específicos do modelo sobrescrevem os padrões do serviço
  778. const finalParams = {...serviceDefaults, ...modelSpecificParams}
  779.  
  780. // Retorna a estrutura de corpo esperada pela API OpenAI Chat Completions
  781. return {
  782. model: modelConfig.id, // ID do modelo a ser usado
  783. messages: [
  784. {role: 'system', content: systemPrompt}, // O prompt principal como mensagem do sistema
  785. {role: 'user', content: 'Generate the summary as requested.'} // Uma mensagem curta do usuário para iniciar a resposta
  786. ],
  787. // Inclui os parâmetros de geração mesclados (ex: max_completion_tokens)
  788. ...finalParams
  789. // 'temperature', 'top_p', etc., podem ser adicionados aqui ou nos params do modelo/serviço
  790. }
  791. } else { // gemini
  792. // Mescla os parâmetros para a seção 'generationConfig' do Gemini
  793. const finalGenConfigParams = {...serviceDefaults, ...modelSpecificParams}
  794.  
  795. // Retorna a estrutura de corpo esperada pela API Gemini generateContent
  796. return {
  797. contents: [{
  798. parts: [{text: systemPrompt}], // O prompt principal vai dentro de 'contents' -> 'parts'
  799. // role não é necessário aqui para um prompt simples
  800. }],
  801. // Inclui a 'generationConfig' com os parâmetros mesclados (ex: maxOutputTokens)
  802. generationConfig: finalGenConfigParams
  803. // 'temperature', 'topP', 'topK' podem ser adicionados aqui ou nos params do modelo/serviço
  804. }
  805. }
  806. }
  807.  
  808. /**
  809. * Retorna os cabeçalhos HTTP apropriados para a requisição à API.
  810. * @param {string} service - O nome do serviço ('openai' ou 'gemini').
  811. * @param {string} apiKey - A chave da API.
  812. * @returns {object} - Um objeto contendo os cabeçalhos HTTP necessários.
  813. */
  814. function getHeaders(service, apiKey) {
  815. // Cabeçalho comum a todas as requisições
  816. const headers = {'Content-Type': 'application/json'}
  817. // Adiciona o cabeçalho de Autorização específico para OpenAI
  818. if (service === 'openai') {
  819. headers['Authorization'] = `Bearer ${apiKey}` // OpenAI usa autenticação Bearer Token
  820. }
  821. // Gemini inclui a chave de API na URL da requisição (veja sendApiRequest),
  822. // então nenhum cabeçalho de autorização adicional é necessário aqui.
  823. return headers
  824. }
  825.  
  826. /**
  827. * Obtém a chave da API para o serviço especificado a partir do armazenamento seguro do GM (GM.getValue).
  828. * Se a chave não estiver armazenada ou estiver vazia, retorna null.
  829. * A verificação se a chave é necessária e a solicitação ao usuário (se ausente) ocorrem em `processSummarization`.
  830. * @param {string} service - O nome do serviço ('openai' ou 'gemini') para o qual obter a chave.
  831. * @returns {Promise<string|null>} - Uma promessa que resolve com a string da chave da API ou null se não encontrada/vazia.
  832. */
  833. async function getApiKey(service) {
  834. const storageKey = `${service}_api_key` // Chave usada para armazenar no GM storage (ex: 'openai_api_key')
  835. let apiKey = await GM.getValue(storageKey) // Lê o valor do storage
  836. // Retorna a chave se ela existir e não for apenas espaços em branco, caso contrário, retorna null.
  837. return apiKey?.trim() || null
  838. }
  839.  
  840. /**
  841. * Permite ao usuário resetar (redefinir ou limpar) a chave da API para um serviço específico.
  842. * Solicita a nova chave através de um prompt do navegador.
  843. * Ativado pelo link 'Reset Key' no cabeçalho de cada grupo no dropdown de modelos.
  844. * @param {string} service - O serviço ('openai' ou 'gemini') para o qual resetar a chave.
  845. */
  846. async function handleApiKeyReset(service) {
  847. // Validação básica para garantir que um serviço válido foi passado
  848. if (!service || !MODEL_GROUPS[service]) {
  849. console.error("Invalid service provided for API key reset:", service)
  850. alert("Internal error: Invalid service provided.")
  851. return
  852. }
  853.  
  854. const storageKey = `${service}_api_key` // Chave do storage para esta API key
  855. // Pede a nova chave ao usuário via prompt. O usuário pode digitar a chave, deixar em branco ou cancelar.
  856. const newKey = prompt(`Enter the new ${service.toUpperCase()} API key (leave blank to clear):`)
  857.  
  858. // Verifica se o usuário não clicou em "Cancelar" (prompt retorna null se cancelado)
  859. if (newKey !== null) {
  860. // Remove espaços extras da chave digitada (ou string vazia se deixado em branco)
  861. const keyToSave = newKey.trim()
  862. // Salva a nova chave (ou string vazia) no GM storage, sobrescrevendo a anterior
  863. await GM.setValue(storageKey, keyToSave)
  864. // Informa o usuário sobre a ação realizada
  865. if (keyToSave) {
  866. alert(`${service.toUpperCase()} API key updated!`) // Mensagem se a chave foi atualizada
  867. } else {
  868. alert(`${service.toUpperCase()} API key cleared!`) // Mensagem se a chave foi limpa
  869. }
  870. // Opcional: Se o dropdown estiver visível, poderia ser repopulado aqui para refletir
  871. // alguma mudança visual, mas atualmente não há indicação visual da presença da chave.
  872. // const dropdown = document.getElementById(DROPDOWN_ID)
  873. // if (dropdown && dropdown.style.display !== 'none') {
  874. // populateDropdown(dropdown)
  875. // }
  876. }
  877. // Se newKey for null (usuário clicou em Cancelar no prompt), nenhuma ação é tomada.
  878. }
  879.  
  880. /**
  881. * Gerencia o fluxo interativo para adicionar um novo modelo customizado.
  882. * Pede ao usuário, via prompts do navegador, o serviço (OpenAI/Gemini) e o ID exato do modelo.
  883. * Valida as entradas e chama `addCustomModel` para salvar.
  884. */
  885. async function handleAddModel() {
  886. // 1. Pergunta o serviço (OpenAI ou Gemini)
  887. // Converte para minúsculas e remove espaços para validação
  888. const service = prompt('Enter the service for the custom model (openai / gemini):')?.toLowerCase()?.trim()
  889. // Valida se o serviço é 'openai' ou 'gemini' e se não foi cancelado
  890. if (!service || !MODEL_GROUPS[service]) {
  891. // Mostra alerta apenas se o usuário digitou algo inválido (não se cancelou)
  892. if (service !== null) alert('Invalid service. Please enter "openai" or "gemini".')
  893. return // Cancela o fluxo se o serviço for inválido ou o prompt for cancelado
  894. }
  895.  
  896. // 2. Pergunta o nome exato (ID) do modelo
  897. // Remove espaços extras do ID digitado
  898. const modelId = prompt(`Enter the exact ID of the ${service.toUpperCase()} model:`)?.trim()
  899. // Valida se o ID não está vazio e se não foi cancelado
  900. if (!modelId) {
  901. // Mostra alerta apenas se o usuário deixou em branco (não se cancelou)
  902. if (modelId !== null) alert('Model ID cannot be empty.')
  903. return // Cancela o fluxo se o ID for vazio ou o prompt for cancelado
  904. }
  905.  
  906. // 3. Chama a função para adicionar o modelo e salvar no storage
  907. await addCustomModel(service, modelId)
  908. // Nota: Após adicionar, o dropdown não é reaberto automaticamente. O usuário precisará
  909. // fazer long-press novamente para ver o modelo adicionado na lista.
  910. }
  911.  
  912. /**
  913. * Adiciona um novo modelo customizado à lista em memória (`customModels`) e
  914. * salva a lista atualizada no GM storage.
  915. * Verifica se um modelo com o mesmo ID (case-insensitive) já existe (seja padrão ou customizado)
  916. * para evitar duplicatas. Salva no formato { id: string, service: string }.
  917. * @param {string} service - O serviço do modelo ('openai' ou 'gemini').
  918. * @param {string} modelId - O ID exato do modelo a ser adicionado.
  919. */
  920. async function addCustomModel(service, modelId) {
  921. // Verifica se o ID do modelo já existe na lista de customizados para este serviço (ignorando case)
  922. const existsInCustom = customModels.some(m => m.service === service && m.id.toLowerCase() === modelId.toLowerCase())
  923. // Verifica também se o ID já existe nos modelos padrão definidos em MODEL_GROUPS (ignorando case)
  924. const existsInStandard = MODEL_GROUPS[service]?.models.some(m => m.id.toLowerCase() === modelId.toLowerCase())
  925.  
  926. // Se o modelo já existir em qualquer uma das listas
  927. if (existsInCustom || existsInStandard) {
  928. alert(`Model ID "${modelId}" already exists for ${service.toUpperCase()}.`) // Informa o usuário
  929. return // Interrompe a adição
  930. }
  931.  
  932. // Se não existe, adiciona o novo modelo (como objeto {id, service}) à lista em memória
  933. customModels.push({id: modelId, service})
  934. // Salva a lista completa e atualizada de modelos customizados no GM storage como uma string JSON
  935. await GM.setValue(CUSTOM_MODELS_KEY, JSON.stringify(customModels))
  936. // Informa o usuário que o modelo foi adicionado com sucesso
  937. alert(`Custom model "${modelId}" (${service.toUpperCase()}) added!`)
  938. }
  939.  
  940. /**
  941. * Carrega a lista de modelos customizados salvos no GM storage.
  942. * Faz parse da string JSON armazenada e realiza uma validação básica
  943. * para garantir que o formato seja um array de objetos, cada um com `id` e `service`.
  944. * Em caso de formato inválido ou erro de parse, reseta o storage para '[]' e retorna um array vazio.
  945. * @returns {Promise<Array<object>>} - Uma promessa que resolve com o array de objetos de modelos customizados [{ id, service }, ...].
  946. */
  947. async function getCustomModels() {
  948. try {
  949. // Obtém a string JSON do storage, usando '[]' como valor padrão se a chave não existir
  950. const storedModels = await GM.getValue(CUSTOM_MODELS_KEY, '[]')
  951. // Faz o parse da string JSON para um objeto JavaScript
  952. const parsedModels = JSON.parse(storedModels)
  953. // Validação: Verifica se é um array e se cada item é um objeto com as propriedades 'id' e 'service'
  954. if (Array.isArray(parsedModels) && parsedModels.every(m => typeof m === 'object' && m.id && m.service)) {
  955. return parsedModels // Retorna os modelos customizados válidos
  956. } else {
  957. // Se o formato for inválido, loga um aviso, reseta o storage e retorna um array vazio
  958. console.warn("Summarize with AI: Invalid custom model format found in storage. Resetting.", parsedModels)
  959. await GM.setValue(CUSTOM_MODELS_KEY, '[]') // Limpa o valor inválido no storage
  960. return []
  961. }
  962. } catch (error) {
  963. // Se ocorrer um erro durante o getValue ou JSON.parse
  964. console.error('Summarize with AI: Failed to load/parse custom models:', error)
  965. // Tenta resetar o storage para um estado limpo em caso de erro de parse
  966. await GM.setValue(CUSTOM_MODELS_KEY, '[]')
  967. return [] // Retorna um array vazio em caso de erro
  968. }
  969. }
  970.  
  971. // --- Funções de Eventos e Utilidades ---
  972.  
  973. /**
  974. * Manipulador global para eventos de teclado.
  975. * Ouve por Alt+S para iniciar a sumarização e Esc para fechar o overlay ou o dropdown.
  976. * @param {KeyboardEvent} e - O objeto do evento de teclado.
  977. */
  978. function handleKeyPress(e) {
  979. // Atalho Alt+S: Simula um clique simples no botão 'S' para iniciar a sumarização
  980. if (e.altKey && e.code === 'KeyS' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { // Verifica Alt+S sem outros modificadores
  981. e.preventDefault() // Previne qualquer ação padrão do navegador para Alt+S
  982. const button = document.getElementById(BUTTON_ID)
  983. // Verifica se o botão 'S' existe na página (ou seja, se um artigo foi detectado)
  984. if (button) {
  985. // Verifica se um campo de input NÃO está focado antes de disparar
  986. if (!document.activeElement?.closest('input, textarea, select, [contenteditable="true"]')) {
  987. processSummarization() // Chama a função principal de sumarização
  988. }
  989. }
  990. }
  991. // Tecla Esc: Fecha elementos abertos pelo script
  992. if (e.key === 'Escape') {
  993. // Prioridade 1: Fechar o overlay de sumário/erro se estiver aberto
  994. if (document.getElementById(OVERLAY_ID)) {
  995. e.preventDefault() // Previne que o Esc feche outras coisas na página
  996. closeOverlay()
  997. }
  998. // Prioridade 2: Fechar o dropdown de modelos se estiver aberto e o overlay não estiver
  999. else if (document.getElementById(DROPDOWN_ID)?.style.display !== 'none') {
  1000. e.preventDefault() // Previne que o Esc feche outras coisas
  1001. hideElement(DROPDOWN_ID)
  1002. }
  1003. }
  1004. }
  1005.  
  1006. /**
  1007. * Configura listeners de foco para esconder/mostrar o botão 'S' automaticamente.
  1008. * Esconde o botão quando o usuário foca em um campo de input/textarea/select/contenteditable.
  1009. * Mostra o botão novamente quando o foco sai desses campos (e volta para o corpo da página, por exemplo).
  1010. */
  1011. function setupFocusListeners() {
  1012. // Listener 'focusin': Disparado quando um elemento na página recebe foco (incluindo via tabulação).
  1013. document.addEventListener('focusin', (event) => {
  1014. // Verifica se o elemento que recebeu foco (event.target) é ou está dentro de um campo editável.
  1015. if (event.target?.closest('input, textarea, select, [contenteditable="true"]')) {
  1016. hideElement(BUTTON_ID) // Esconde o botão 'S'
  1017. hideElement(DROPDOWN_ID) // Esconde também o dropdown se estiver aberto
  1018. }
  1019. })
  1020.  
  1021. // Listener 'focusout': Disparado quando um elemento perde o foco.
  1022. document.addEventListener('focusout', (event) => {
  1023. // Verifica se o elemento que perdeu foco (event.target) era um campo editável.
  1024. const isLeavingInput = event.target?.closest('input, textarea, select, [contenteditable="true"]')
  1025. // Verifica se o novo elemento que recebeu foco (event.relatedTarget) NÃO é um campo editável.
  1026. // 'relatedTarget' é null se o foco saiu da janela ou foi para o body.
  1027. const isEnteringInput = event.relatedTarget?.closest('input, textarea, select, [contenteditable="true"]')
  1028.  
  1029. // Mostra o botão 'S' somente se as seguintes condições forem verdadeiras:
  1030. // 1. O foco estava em um campo editável (isLeavingInput).
  1031. // 2. O foco NÃO está indo para outro campo editável (isEnteringInput é falso/null).
  1032. // 3. O script detectou um artigo legível (articleData existe).
  1033. if (isLeavingInput && !isEnteringInput && articleData) {
  1034. // Usa um pequeno delay (50ms) antes de mostrar o botão.
  1035. // Isso evita que o botão pisque rapidamente se o usuário tabular entre campos editáveis.
  1036. setTimeout(() => {
  1037. // Reconfirma no momento de mostrar: garante que o foco *atual* não é um input
  1038. // (o foco pode ter mudado novamente durante o delay).
  1039. if (!document.activeElement?.closest('input, textarea, select, [contenteditable="true"]')) {
  1040. showElement(BUTTON_ID) // Mostra o botão 'S'
  1041. }
  1042. }, 50)
  1043. }
  1044. }, true) // Usa 'capture: true' para garantir que o evento 'focusout' seja capturado de forma confiável.
  1045. }
  1046.  
  1047. /**
  1048. * Injeta os estilos CSS necessários para a interface do script (botão, dropdown, overlay, etc.).
  1049. * Inclui estilos para dark mode e responsividade móvel.
  1050. */
  1051. function injectStyles() {
  1052. // Estilos CSS injetados usando GM.addStyle
  1053. GM.addStyle(`
  1054. /* --- Elementos Principais da UI --- */
  1055. #${BUTTON_ID} {
  1056. position: fixed; bottom: 20px; right: 20px;
  1057. width: 50px; height: 50px; /* Tamanho */
  1058. background: linear-gradient(145deg, #3a7bd5, #00d2ff); /* Gradiente azul */
  1059. color: white; font-size: 24px; /* Texto */
  1060. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  1061. border-radius: 50%; cursor: pointer; z-index: 2147483640;
  1062. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
  1063. /* display é controlado por show/hideElement, mas !important garante sobreposição se necessário */
  1064. display: flex !important; align-items: center !important; justify-content: center !important; /* Centraliza 'S' */
  1065. transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
  1066. line-height: 1; user-select: none; /* Previne seleção de texto */
  1067. -webkit-tap-highlight-color: transparent; /* Remove highlight azul no toque (iOS/Android) */
  1068. }
  1069. #${BUTTON_ID}:hover {
  1070. transform: scale(1.1); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25);
  1071. }
  1072. #${DROPDOWN_ID} {
  1073. position: fixed; bottom: 80px; right: 20px; /* Posicionado acima do botão 'S' */
  1074. background: #ffffff; border: 1px solid #e0e0e0; border-radius: 10px;
  1075. box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15); z-index: 2147483641; /* Z-index maior que o botão */
  1076. max-height: 70vh; overflow-y: auto; /* Permite scroll se a lista for longa */
  1077. padding: 8px; width: 300px; /* Dimensões e espaçamento interno */
  1078. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  1079. display: none; /* Começa oculto (controlado por show/hideElement) */
  1080. animation: fadeIn 0.2s ease-out; /* Animação suave ao aparecer */
  1081. }
  1082. #${OVERLAY_ID} {
  1083. position: fixed; top: 0; left: 0; width: 100%; height: 100%;
  1084. background-color: rgba(0, 0, 0, 0.6); /* Fundo semi-transparente (padrão light) */
  1085. z-index: 2147483645; /* Z-index muito alto para ficar sobre tudo */
  1086. display: flex; align-items: center; justify-content: center; /* Centraliza o conteúdo */
  1087. overflow: hidden; /* Impede scroll do body enquanto aberto */
  1088. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  1089. animation: fadeIn 0.3s ease-out; /* Animação suave ao aparecer */
  1090. }
  1091. #${CONTENT_ID} {
  1092. background-color: #fff; /* Fundo branco (padrão light) */
  1093. color: #333; /* Texto escuro (padrão light) */
  1094. padding: 25px 35px; border-radius: 12px; /* Espaçamento interno e bordas arredondadas */
  1095. box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
  1096. max-width: 800px; width: 90%; max-height: 85vh; /* Limites de tamanho */
  1097. overflow-y: auto; /* Scroll interno se o conteúdo for maior que a altura máxima */
  1098. position: relative; /* Para posicionamento absoluto do botão de fechar */
  1099. font-size: 16px; line-height: 1.6; /* Tamanho e espaçamento de linha do texto */
  1100. animation: slideInUp 0.3s ease-out; /* Animação de entrada (desliza de baixo para cima) */
  1101. white-space: normal; /* Permite quebra de linha normal baseada no HTML */
  1102. box-sizing: border-box; /* Garante que padding não aumente o tamanho total além de max-width/width */
  1103. }
  1104. #${CONTENT_ID} p { margin-top: 0; margin-bottom: 1em; } /* Margem padrão para parágrafos dentro do conteúdo */
  1105. #${CONTENT_ID} ul { margin: 1em 0; padding-left: 1.5em; } /* Adiciona padding à esquerda para listas (bullet points com emoji) */
  1106. #${CONTENT_ID} li { list-style-type: none; margin-bottom: 0.5em; } /* Remove marcador padrão da lista (usa emoji) e adiciona espaço abaixo */
  1107. #${CLOSE_BUTTON_ID} {
  1108. position: absolute; top: 10px; right: 15px; /* Canto superior direito do conteúdo */
  1109. font-size: 28px; color: #aaa; /* Cinza claro (padrão light) */
  1110. cursor: pointer;
  1111. transition: color 0.2s; line-height: 1; z-index: 1; /* Garante que fique acima do texto scrollável */
  1112. }
  1113. #${CLOSE_BUTTON_ID}:hover { color: #333; } /* Cor mais escura no hover (light) */
  1114. #${ERROR_ID} {
  1115. position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); /* Centralizado na parte inferior */
  1116. background-color: #e53e3e; color: white; padding: 12px 20px; /* Vermelho para erro */
  1117. border-radius: 6px; z-index: 2147483646; /* Acima de tudo, até do overlay */
  1118. font-size: 14px; box-shadow: 0 2px 8px rgba(0,0,0,0.2);
  1119. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  1120. animation: fadeIn 0.3s, fadeOut 0.3s 3.7s forwards; /* Fade in, espera 3.7s, fade out */
  1121. }
  1122. .retry-button { /* Estilo para o botão "Try Again" em caso de erro */
  1123. display: block; margin: 20px auto 0; padding: 8px 16px; /* Centralizado abaixo do erro */
  1124. background-color: #4a90e2; /* Azul (padrão light) */
  1125. color: white; border: none; border-radius: 5px;
  1126. cursor: pointer; font-size: 14px; transition: background-color 0.2s;
  1127. }
  1128. .retry-button:hover { background-color: #3a7bd5; } /* Azul mais escuro no hover (light) */
  1129.  
  1130. /* --- Estilos do Dropdown --- */
  1131. .model-group { margin-bottom: 8px; } /* Espaço abaixo de cada grupo de modelos */
  1132. .group-header-container { /* Container para Nome do Serviço + Link Reset Key */
  1133. display: flex; align-items: center; justify-content: space-between; /* Alinhamento flex */
  1134. padding: 8px 12px; background: #f7f7f7; /* Fundo cinza claro */
  1135. border-radius: 6px; margin-bottom: 4px; /* Bordas arredondadas e espaço abaixo */
  1136. }
  1137. .group-header-text { /* Texto do nome do serviço (ex: OpenAI) */
  1138. font-weight: 600; color: #333; font-size: 13px;
  1139. text-transform: uppercase; letter-spacing: 0.5px; /* Estilo de título */
  1140. flex-grow: 1; /* Ocupa espaço disponível, empurrando o link para a direita */
  1141. }
  1142. .reset-key-link { /* Link "Reset Key" */
  1143. font-size: 11px; color: #666; text-decoration: none;
  1144. margin-left: 10px; /* Espaço à esquerda */
  1145. white-space: nowrap; /* Impede quebra de linha */
  1146. cursor: pointer;
  1147. transition: color 0.2s;
  1148. }
  1149. .reset-key-link:hover { color: #1a73e8; } /* Azul no hover */
  1150. .model-item { /* Estilo para cada item de modelo clicável */
  1151. padding: 10px 14px; margin: 2px 0; border-radius: 6px; /* Espaçamento e bordas */
  1152. transition: background-color 0.15s ease-out, color 0.15s ease-out; /* Transição suave no hover */
  1153. font-size: 14px; cursor: pointer; color: #444; display: block; /* Estilo de texto e cursor */
  1154. overflow: hidden; text-overflow: ellipsis; white-space: nowrap; /* Evita quebra e adiciona '...' em nomes longos */
  1155. }
  1156. .model-item:hover { background-color: #eef6ff; color: #1a73e8; } /* Efeito hover (fundo azul claro, texto azul) */
  1157. .add-model-item { /* Estilo adicional para o item "+ Add Custom Model" */
  1158. color: #666; /* Cor mais apagada */
  1159. font-style: italic; /* Itálico */
  1160. }
  1161. .add-model-item:hover { background-color: #f0f0f0; color: #333; } /* Hover diferente para o item de adicionar */
  1162.  
  1163. /* --- Estilos de Conteúdo (Glow, Qualidade do Artigo) --- */
  1164. .glow { /* Efeito de brilho pulsante para a mensagem "Summarizing..." */
  1165. font-size: 1.4em; text-align: center; padding: 40px 0;
  1166. /* Aplica a animação 'glow' definida abaixo */
  1167. animation: glow 2.5s ease-in-out infinite;
  1168. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  1169. font-weight: 400;
  1170. }
  1171. /* Cores para as classes de qualidade do artigo (usadas no span dentro do sumário) */
  1172. span.article-excellent { color: #2ecc71; font-weight: bold; } /* Verde brilhante */
  1173. span.article-good { color: #3498db; font-weight: bold; } /* Azul */
  1174. span.article-average { color: #f39c12; font-weight: bold; } /* Laranja */
  1175. span.article-bad { color: #e74c3c; font-weight: bold; } /* Vermelho */
  1176. span.article-very-bad { color: #c0392b; font-weight: bold; } /* Vermelho escuro */
  1177.  
  1178. /* --- Animações --- */
  1179. /* Keyframes para a animação 'glow': cicla entre azul, roxo e vermelho com sombra de texto */
  1180. @keyframes glow {
  1181. 0%, 100% { color: #4a90e2; text-shadow: 0 0 10px rgba(74, 144, 226, 0.6), 0 0 20px rgba(74, 144, 226, 0.4); } /* Azul */
  1182. 33% { color: #9b59b6; text-shadow: 0 0 12px rgba(155, 89, 182, 0.7), 0 0 25px rgba(155, 89, 182, 0.5); } /* Roxo */
  1183. 66% { color: #e74c3c; text-shadow: 0 0 12px rgba(231, 76, 60, 0.7), 0 0 25px rgba(231, 76, 60, 0.5); } /* Vermelho */
  1184. }
  1185. /* Animação simples de Fade In (usada no overlay, dropdown, notificação de erro) */
  1186. @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
  1187. /* Animação simples de Fade Out (usada na notificação de erro) */
  1188. @keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
  1189. /* Animação de Slide In de baixo para cima (usada no conteúdo do overlay) */
  1190. @keyframes slideInUp {
  1191. from { transform: translateY(30px); opacity: 0; }
  1192. to { transform: translateY(0); opacity: 1; }
  1193. }
  1194.  
  1195. /* --- Dark Mode Override (Adaptação automática ao tema escuro do sistema) --- */
  1196. @media (prefers-color-scheme: dark) {
  1197. /* Fundo do overlay mais escuro */
  1198. #${OVERLAY_ID} {
  1199. background-color: rgba(20, 20, 20, 0.7); /* Fundo mais opaco e escuro */
  1200. }
  1201. /* Conteúdo do sumário com fundo escuro e texto claro */
  1202. #${CONTENT_ID} {
  1203. background-color: #2c2c2c; /* Cinza bem escuro */
  1204. color: #e0e0e0; /* Texto cinza claro */
  1205. box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4); /* Sombra um pouco mais visível */
  1206. }
  1207. /* Botão de fechar ('×') com cores invertidas */
  1208. #${CLOSE_BUTTON_ID} {
  1209. color: #888; /* Cinza médio */
  1210. }
  1211. #${CLOSE_BUTTON_ID}:hover {
  1212. color: #eee; /* Quase branco no hover */
  1213. }
  1214. /* Botão "Try Again" com estilo adaptado para dark mode */
  1215. .retry-button {
  1216. background-color: #555; /* Cinza médio */
  1217. color: #eee; /* Texto claro */
  1218. }
  1219. .retry-button:hover {
  1220. background-color: #666; /* Cinza um pouco mais claro no hover */
  1221. }
  1222. /* Dropdown com fundo escuro e texto claro */
  1223. #${DROPDOWN_ID} {
  1224. background: #333; /* Fundo escuro para dropdown */
  1225. border-color: #555; /* Borda mais escura */
  1226. }
  1227. .model-item {
  1228. color: #ccc; /* Texto do item mais claro */
  1229. }
  1230. .model-item:hover {
  1231. background-color: #444; /* Fundo de hover mais escuro */
  1232. color: #fff; /* Texto branco no hover */
  1233. }
  1234. /* Itens do cabeçalho do grupo no dropdown */
  1235. .group-header-container {
  1236. background: #444; /* Fundo do cabeçalho do grupo */
  1237. }
  1238. .group-header-text {
  1239. color: #eee; /* Texto do cabeçalho claro */
  1240. }
  1241. .reset-key-link {
  1242. color: #aaa; /* Link de reset mais claro */
  1243. }
  1244. .reset-key-link:hover {
  1245. color: #fff; /* Link de reset branco no hover */
  1246. }
  1247. /* Item "+ Add Custom Model" no dropdown */
  1248. .add-model-item {
  1249. color: #999; /* Item de adicionar mais claro */
  1250. }
  1251. .add-model-item:hover {
  1252. background-color: #4a4a4a; /* Fundo de hover */
  1253. color: #eee; /* Texto claro no hover */
  1254. }
  1255. /* Separador (linha hr) no dropdown */
  1256. hr {
  1257. /* !important pode ser necessário para sobrescrever estilos inline */
  1258. border-top-color: #555 !important;
  1259. }
  1260. /* Cores de qualidade (opcionalmente ajustar para melhor contraste em dark mode) */
  1261. /* span.article-excellent { color: #36d880; } */
  1262. /* span.article-good { color: #4aa9f2; } */
  1263. /* As cores atuais parecem ter contraste razoável, mantendo por enquanto */
  1264.  
  1265. /* Ajuste de cor para o brilho 'glow' no modo escuro (opcional) */
  1266. /* As cores atuais do glow parecem funcionar bem, mas poderiam ser ajustadas aqui */
  1267. /* @keyframes glow-dark { ... } */
  1268. /* .glow { animation-name: glow-dark; } */
  1269. }
  1270.  
  1271. /* --- Mobile Responsiveness --- */
  1272. /* Ajustes para telas pequenas (ex: smartphones com largura máxima de 600px) */
  1273. @media (max-width: 600px) {
  1274. /* Faz o conteúdo do overlay ocupar a tela inteira para melhor uso do espaço */
  1275. #${CONTENT_ID} {
  1276. width: 100%; /* Largura total */
  1277. height: 100%; /* Altura total */
  1278. max-width: none; /* Remove limite de largura máxima */
  1279. max-height: none; /* Remove limite de altura máxima */
  1280. border-radius: 0; /* Remove cantos arredondados (visual edge-to-edge) */
  1281. padding: 20px 15px; /* Ajusta padding interno para telas menores */
  1282. box-shadow: none; /* Remove sombra (opcional, visual mais limpo) */
  1283. animation: none; /* Desabilita animação slideInUp em mobile (opcional) */
  1284. font-size: 15px; /* Pode reduzir um pouco a fonte se necessário */
  1285. }
  1286. /* Ajusta posição do botão de fechar para o novo padding e tamanho */
  1287. #${CLOSE_BUTTON_ID} {
  1288. top: 8px;
  1289. right: 8px;
  1290. font-size: 32px; /* Aumenta um pouco o tamanho do '×' para facilitar o toque */
  1291. }
  1292. /* Esconde explicitamente o botão flutuante 'S' e o dropdown quando o overlay estiver aberto */
  1293. /* Embora o overlay já tenha z-index maior, isso garante que não haja interações acidentais */
  1294. #${OVERLAY_ID} ~ #${BUTTON_ID},
  1295. #${OVERLAY_ID} ~ #${DROPDOWN_ID} {
  1296. display: none !important; /* Garante que fiquem escondidos */
  1297. }
  1298. /* Opcional: Posicionar o botão 'S' um pouco mais para dentro em telas pequenas */
  1299. /* #${BUTTON_ID} { bottom: 15px; right: 15px; } */
  1300. /* Opcional: Aumentar um pouco o tamanho do botão 'S' em mobile */
  1301. /* #${BUTTON_ID} { width: 55px; height: 55px; font-size: 26px; } */
  1302. }
  1303. `)
  1304. }
  1305.  
  1306. // --- Inicialização ---
  1307. // noinspection JSIgnoredPromiseFromCall
  1308. initialize() // Chama a função principal para iniciar o script assim que ele for carregado
  1309.  
  1310. })()