Summarize with AI

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

目前為 2025-05-03 提交的版本,檢視 最新版本

  1. // ==UserScript==
  2. // @name Summarize with AI
  3. // @namespace https://github.com/insign/userscripts
  4. // @version 2025.05.03.2014
  5. // @description Single-button AI summarization (OpenAI/Gemini) with model selection dropdown for articles/news. Uses Alt+S shortcut. Long press 'S' 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. // Nova 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}}, // Nome pode precisar de ajuste
  52. {id: 'gpt-4.1', name: 'GPT-4.1'}, // Usa params padrão
  53. {id: 'gpt-4.1-mini', name: 'GPT-4.1 mini'},
  54. {id: 'gpt-4.1-nano', name: 'GPT-4.1 nano (faster)'},
  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 no botão 'S'
  101. let isLongPress = false // Flag para indicar se ocorreu long press
  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
  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
  133. if (!isProbablyReaderable(docClone)) {
  134. console.log('Summarize with AI: Page not detected as readerable.')
  135. return null
  136. }
  137. const reader = new Readability(docClone)
  138. const article = reader.parse()
  139. // Retorna dados se o conteúdo foi extraído e não está vazio
  140. return (article?.content && article.textContent?.trim())
  141. ? {title: article.title, content: article.textContent.trim()}
  142. : null
  143. } catch (error) {
  144. console.error('Summarize with AI: Article parsing failed:', error)
  145. return null // Retorna null em caso de erro
  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) e injeta estilos.
  152. */
  153. function addSummarizeButton() {
  154. // Evita adicionar o botão múltiplas vezes
  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
  161. button.title = 'Summarize (Alt+S) / Long Press to Select Model' // Tooltip atualizado (sem dblclick)
  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 modelos
  168.  
  169. // Listener para clique simples: Inicia a sumarização com o modelo ativo
  170. button.addEventListener('click', () => {
  171. // Só executa se não foi um long press
  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
  176. isLongPress = false
  177. })
  178.  
  179. // Listener para Long Press: Mostra/esconde o dropdown
  180. button.addEventListener('mousedown', (e) => {
  181. // Inicia o timer para detectar long press
  182. isLongPress = false // Reseta a flag
  183. clearTimeout(longPressTimer) // Limpa timer anterior se houver
  184. longPressTimer = setTimeout(() => {
  185. isLongPress = true // Marca que ocorreu long press
  186. toggleDropdown(e) // Abre/fecha o dropdown
  187. }, LONG_PRESS_DURATION)
  188. })
  189.  
  190. // Listener para soltar o botão (cancela o timer se antes do tempo)
  191. button.addEventListener('mouseup', () => {
  192. clearTimeout(longPressTimer)
  193. })
  194.  
  195. // Listener se o mouse sair do botão (cancela o timer)
  196. button.addEventListener('mouseleave', () => {
  197. clearTimeout(longPressTimer)
  198. })
  199.  
  200. // Listener para clique fora do dropdown para fechá-lo
  201. document.addEventListener('click', handleOutsideClick)
  202.  
  203. // Injeta os estilos CSS necessários para a interface
  204. injectStyles()
  205. }
  206.  
  207.  
  208. // --- Funções de UI (Dropdown, Overlay, Notificações) ---
  209.  
  210. /**
  211. * Cria o elemento base (container) do dropdown.
  212. * @returns {HTMLElement} - O elemento div do dropdown, inicialmente vazio e oculto.
  213. */
  214. function createDropdownElement() {
  215. const dropdown = document.createElement('div')
  216. dropdown.id = DROPDOWN_ID
  217. dropdown.style.display = 'none' // Começa oculto
  218. return dropdown
  219. }
  220.  
  221. /**
  222. * Preenche o elemento dropdown com os grupos de modelos (padrão e customizados)
  223. * e a opção para adicionar novos modelos. Adiciona links de reset de API Key.
  224. * @param {HTMLElement} dropdownElement - O elemento do dropdown a ser preenchido.
  225. */
  226. function populateDropdown(dropdownElement) {
  227. dropdownElement.innerHTML = '' // Limpa conteúdo anterior
  228.  
  229. Object.entries(MODEL_GROUPS).forEach(([service, group]) => {
  230. // Combina modelos padrão e customizados para este serviço
  231. const standardModels = group.models || [] // Array de objetos {id, name?, params?}
  232. const serviceCustomModels = customModels
  233. .filter(m => m.service === service) // Filtra customizados por serviço {id, service}
  234. .map(m => ({id: m.id})) // Mapeia para o formato {id}, sem name ou params definidos aqui
  235.  
  236. const allModelObjects = [...standardModels, ...serviceCustomModels]
  237. // Remove duplicatas baseadas no ID (case-insensitive)
  238. .reduce((acc, model) => {
  239. if (!acc.some(existing => existing.id.toLowerCase() === model.id.toLowerCase())) {
  240. acc.push(model)
  241. }
  242. return acc
  243. }, [])
  244. .sort((a, b) => a.id.localeCompare(b.id)) // Ordena alfabeticamente pelo ID
  245.  
  246. if (allModelObjects.length > 0) {
  247. const groupDiv = document.createElement('div')
  248. groupDiv.className = 'model-group'
  249. // Cria o cabeçalho com link de reset
  250. groupDiv.appendChild(createHeader(group.name, service))
  251. // Adiciona cada item de modelo
  252. allModelObjects.forEach(modelObj => groupDiv.appendChild(createModelItem(modelObj)))
  253. dropdownElement.appendChild(groupDiv)
  254. }
  255. })
  256.  
  257. // Adiciona separador e item "+ Adicionar"
  258. const separator = document.createElement('hr')
  259. separator.style.margin = '8px 0'
  260. separator.style.border = 'none'
  261. separator.style.borderTop = '1px solid #eee'
  262. dropdownElement.appendChild(separator)
  263. dropdownElement.appendChild(createAddModelItem())
  264. }
  265.  
  266. /**
  267. * Cria um elemento de cabeçalho para um grupo de modelos no dropdown,
  268. * incluindo um link para resetar a API Key do serviço.
  269. * @param {string} text - O texto do cabeçalho (nome do serviço).
  270. * @param {string} service - A chave do serviço ('openai' ou 'gemini').
  271. * @returns {HTMLElement} - O elemento div do cabeçalho.
  272. */
  273. function createHeader(text, service) {
  274. const headerContainer = document.createElement('div')
  275. headerContainer.className = 'group-header-container' // Container para flex layout
  276.  
  277. const headerText = document.createElement('span') // Span para o texto
  278. headerText.className = 'group-header-text'
  279. headerText.textContent = text
  280.  
  281. const resetLink = document.createElement('a') // Link para resetar
  282. resetLink.href = '#'
  283. resetLink.textContent = 'Reset Key'
  284. resetLink.className = 'reset-key-link'
  285. resetLink.title = `Reset ${text} API Key`
  286. resetLink.addEventListener('click', (e) => {
  287. e.preventDefault() // Previne navegação
  288. e.stopPropagation() // Impede que feche o dropdown
  289. handleApiKeyReset(service) // Chama o reset para o serviço específico
  290. })
  291.  
  292. headerContainer.appendChild(headerText)
  293. headerContainer.appendChild(resetLink)
  294. return headerContainer
  295. }
  296.  
  297. /**
  298. * Cria um item clicável para um modelo específico no dropdown.
  299. * Usa a nova estrutura de objeto do modelo.
  300. * @param {object} modelObj - O objeto do modelo { id, name?, params? }.
  301. * @returns {HTMLElement} - O elemento div do item do modelo.
  302. */
  303. function createModelItem(modelObj) {
  304. const item = document.createElement('div')
  305. item.className = 'model-item'
  306. // Usa o nome amigável se disponível, senão o ID
  307. item.textContent = modelObj.name || modelObj.id
  308. // Adiciona um marcador visual se for o modelo ativo atualmente
  309. if (modelObj.id === activeModel) {
  310. item.style.fontWeight = 'bold'
  311. item.style.color = '#1A73E8' // Azul para destacar
  312. }
  313. // Listener de clique: seleciona o ID do modelo, esconde dropdown e inicia sumarização
  314. item.addEventListener('click', async () => {
  315. activeModel = modelObj.id // Define o ID do modelo ativo
  316. await GM.setValue('last_used_model', activeModel) // Salva a última seleção
  317. hideElement(DROPDOWN_ID) // Esconde o dropdown
  318. processSummarization() // Inicia o processo de sumarização
  319. })
  320. return item
  321. }
  322.  
  323. /**
  324. * Cria o item clicável "+ Adicionar Modelo Customizado" no dropdown.
  325. * @returns {HTMLElement} - O elemento div do item.
  326. */
  327. function createAddModelItem() {
  328. const item = document.createElement('div')
  329. item.id = ADD_MODEL_ITEM_ID
  330. item.className = 'model-item add-model-item' // Classe adicional para estilização
  331. item.textContent = '+ Add Custom Model'
  332. // Listener de clique: inicia o fluxo para adicionar um novo modelo
  333. item.addEventListener('click', async (e) => {
  334. e.stopPropagation() // Impede que feche o dropdown
  335. hideElement(DROPDOWN_ID) // Esconde o dropdown antes de mostrar os prompts
  336. await handleAddModel()
  337. })
  338. return item
  339. }
  340.  
  341. /**
  342. * Mostra ou esconde o dropdown de seleção de modelo.
  343. * @param {Event} [e] - O objeto do evento de clique/mousedown (opcional, para stopPropagation).
  344. */
  345. function toggleDropdown(e) {
  346. if (e) e.stopPropagation() // Impede que o clique feche imediatamente o dropdown
  347. const dropdown = document.getElementById(DROPDOWN_ID)
  348. if (dropdown) {
  349. const isHidden = dropdown.style.display === 'none'
  350. if (isHidden) {
  351. // Repopula o dropdown caso modelos tenham sido adicionados/removidos ou para atualizar link de reset
  352. populateDropdown(dropdown)
  353. showElement(DROPDOWN_ID)
  354. } else {
  355. hideElement(DROPDOWN_ID)
  356. }
  357. }
  358. }
  359.  
  360. /**
  361. * Fecha o dropdown se o clique ocorrer fora dele ou do botão 'S'.
  362. * @param {Event} event - O objeto do evento de clique.
  363. */
  364. function handleOutsideClick(event) {
  365. const dropdown = document.getElementById(DROPDOWN_ID)
  366. const button = document.getElementById(BUTTON_ID)
  367. // Verifica se o dropdown está visível e se o clique foi fora dele e fora do botão
  368. if (dropdown && dropdown.style.display !== 'none' &&
  369. !dropdown.contains(event.target) &&
  370. !button?.contains(event.target)) { // Verifica se o botão existe
  371. hideElement(DROPDOWN_ID)
  372. }
  373. }
  374.  
  375. /**
  376. * Exibe o overlay de sumarização com o conteúdo fornecido.
  377. * Cria o overlay se ele não existir.
  378. * Simplificado: O botão retry apenas chama processSummarization.
  379. * @param {string} contentHTML - O conteúdo HTML a ser exibido (pode ser loading, sumário ou erro com retry).
  380. * @param {boolean} [isError=false] - Indica se o conteúdo é uma mensagem de erro para adicionar botão de retry.
  381. */
  382. function showSummaryOverlay(contentHTML, isError = false) {
  383. // Se o overlay já existe, apenas atualiza o conteúdo
  384. if (document.getElementById(OVERLAY_ID)) {
  385. updateSummaryOverlay(contentHTML, isError)
  386. return
  387. }
  388.  
  389. // Cria o elemento do overlay
  390. const overlay = document.createElement('div')
  391. overlay.id = OVERLAY_ID
  392. // Define o HTML interno com container, botão de fechar e conteúdo inicial
  393. let finalContent = `<div id="${CLOSE_BUTTON_ID}" title="Close (Esc)">×</div>${contentHTML}`
  394. // Adiciona botão de Tentar Novamente se for um erro
  395. if (isError) {
  396. finalContent += `<button id="${RETRY_BUTTON_ID}" class="retry-button">Try Again</button>`
  397. }
  398. overlay.innerHTML = `<div id="${CONTENT_ID}">${finalContent}</div>`
  399.  
  400. document.body.appendChild(overlay)
  401. document.body.style.overflow = 'hidden' // Trava o scroll do body
  402.  
  403. // Adiciona listeners para fechar o overlay
  404. document.getElementById(CLOSE_BUTTON_ID).addEventListener('click', closeOverlay)
  405. overlay.addEventListener('click', (e) => { // Fecha clicando no fundo (fora do content)
  406. if (e.target === overlay) closeOverlay()
  407. })
  408. // Adiciona listener para o botão de Tentar Novamente, se existir
  409. // Apenas chama processSummarization() novamente
  410. document.getElementById(RETRY_BUTTON_ID)?.addEventListener('click', processSummarization)
  411. }
  412.  
  413. /**
  414. * Fecha e remove o overlay de sumarização do DOM. Restaura o scroll do body.
  415. */
  416. function closeOverlay() {
  417. const overlay = document.getElementById(OVERLAY_ID)
  418. if (overlay) {
  419. overlay.remove()
  420. document.body.style.overflow = '' // Libera o scroll do body
  421. }
  422. }
  423.  
  424. /**
  425. * Atualiza o conteúdo dentro do overlay de sumarização já existente.
  426. * Simplificado: O botão retry apenas chama processSummarization.
  427. * @param {string} contentHTML - O novo conteúdo HTML.
  428. * @param {boolean} [isError=false] - Indica se o conteúdo é uma mensagem de erro para adicionar botão de retry.
  429. */
  430. function updateSummaryOverlay(contentHTML, isError = false) {
  431. const contentDiv = document.getElementById(CONTENT_ID)
  432. if (contentDiv) {
  433. // Recria o conteúdo interno, garantindo que o botão de fechar permaneça
  434. let finalContent = `<div id="${CLOSE_BUTTON_ID}" title="Close (Esc)">×</div>${contentHTML}`
  435. // Adiciona botão de Tentar Novamente se for um erro
  436. if (isError) {
  437. finalContent += `<button id="${RETRY_BUTTON_ID}" class="retry-button">Try Again</button>`
  438. }
  439. contentDiv.innerHTML = finalContent
  440. // Reatribui o listener ao novo botão de fechar
  441. document.getElementById(CLOSE_BUTTON_ID)?.addEventListener('click', closeOverlay)
  442. // Reatribui listener ao botão de Tentar Novamente, se existir
  443. // Apenas chama processSummarization() novamente
  444. document.getElementById(RETRY_BUTTON_ID)?.addEventListener('click', processSummarization)
  445. }
  446. }
  447.  
  448. /**
  449. * Exibe uma notificação de erro temporária na parte inferior central da tela.
  450. * @param {string} message - A mensagem de erro.
  451. */
  452. function showErrorNotification(message) {
  453. document.getElementById(ERROR_ID)?.remove() // Remove notificação anterior
  454.  
  455. const errorDiv = document.createElement('div')
  456. errorDiv.id = ERROR_ID
  457. errorDiv.innerText = message
  458. document.body.appendChild(errorDiv)
  459.  
  460. // Remove a notificação após 4 segundos
  461. setTimeout(() => errorDiv.remove(), 4000)
  462. }
  463.  
  464. /**
  465. * Esconde um elemento pelo seu ID, definindo display como 'none'.
  466. * @param {string} id - O ID do elemento.
  467. */
  468. function hideElement(id) {
  469. const el = document.getElementById(id)
  470. if (el) el.style.display = 'none'
  471. }
  472.  
  473. /**
  474. * Mostra um elemento pelo seu ID.
  475. * @param {string} id - O ID do elemento.
  476. */
  477. function showElement(id) {
  478. const el = document.getElementById(id)
  479. if (el) {
  480. // Usa 'flex' para o botão e 'block' para os outros por padrão
  481. el.style.display = (id === BUTTON_ID) ? 'flex' : 'block'
  482. }
  483. }
  484.  
  485. // --- Funções de Lógica (Sumarização, API, Modelos) ---
  486.  
  487. /**
  488. * Encontra o objeto de configuração completo para o modelo ativo (padrão ou customizado).
  489. * @returns {object|null} Um objeto contendo { id, service, name?, params? } ou null se não encontrado.
  490. */
  491. function getActiveModelConfig() {
  492. for (const service in MODEL_GROUPS) {
  493. const group = MODEL_GROUPS[service]
  494. const modelConfig = group.models.find(m => m.id === activeModel)
  495. if (modelConfig) {
  496. // Retorna uma cópia do objeto, adicionando a chave do serviço
  497. return {...modelConfig, service: service}
  498. }
  499. }
  500. // Verifica modelos customizados
  501. const customConfig = customModels.find(m => m.id === activeModel)
  502. if (customConfig) {
  503. // Custom models não tem 'name' ou 'params' definidos por padrão aqui
  504. // Retorna uma cópia do objeto customizado { id, service }
  505. return {...customConfig}
  506. }
  507. console.error(`Summarize with AI: Active model configuration not found for ID: ${activeModel}`)
  508. return null // Modelo não encontrado
  509. }
  510.  
  511. /**
  512. * Orquestra o processo de sumarização: obtém API key, mostra overlay de loading com nome do modelo,
  513. * envia requisição à API e trata a resposta.
  514. */
  515. async function processSummarization() {
  516. try {
  517. // Garante que temos dados do artigo antes de prosseguir
  518. if (!articleData) {
  519. showErrorNotification('Article content not found or not readable.')
  520. return
  521. }
  522.  
  523. const modelConfig = getActiveModelConfig() // Obtém a configuração completa do modelo ativo
  524. if (!modelConfig) {
  525. // Mensagem de erro mais informativa se o modelo não for encontrado
  526. showErrorNotification(`Configuration for model "${activeModel}" not found. Please select another model.`)
  527. return // Interrompe a execução se a configuração não for encontrada
  528. }
  529.  
  530. // Determina o nome a ser exibido (usa 'name' se disponível, senão 'id')
  531. const modelDisplayName = modelConfig.name || modelConfig.id
  532. const service = modelConfig.service // Determina 'openai' ou 'gemini' a partir da config
  533.  
  534. const apiKey = await getApiKey(service) // Obtém a API key (pede ao usuário se não tiver)
  535. if (!apiKey) { // Aborta se não houver API key
  536. // Mostra erro no overlay se estiver aberto, senão como notificação
  537. const errorMsg = `API key for ${service.toUpperCase()} is required. Click the 'Reset Key' link in the model selection menu (long-press 'S' button).`
  538. if (document.getElementById(OVERLAY_ID)) {
  539. // Mostra o erro no overlay existente, sem botão de retry para este caso
  540. updateSummaryOverlay(`<p style="color: red;">${errorMsg}</p>`, false)
  541. } else {
  542. // Se o overlay não estava aberto, mostra como notificação
  543. showErrorNotification(errorMsg)
  544. }
  545. return // Interrompe se não houver chave
  546. }
  547.  
  548. // Mostra feedback de loading com o nome do modelo
  549. // Verifica se o overlay já existe (caso seja um retry)
  550. const loadingMessage = `<p class="glow">Summarizing with ${modelDisplayName}... </p>`
  551. if (document.getElementById(OVERLAY_ID)) {
  552. updateSummaryOverlay(loadingMessage) // Atualiza overlay existente
  553. } else {
  554. showSummaryOverlay(loadingMessage) // Cria novo overlay
  555. }
  556.  
  557. // Prepara os dados para a API
  558. const payload = {title: articleData.title, content: articleData.content, lang: navigator.language || 'en-US'}
  559.  
  560. // Passa a configuração do modelo para sendApiRequest
  561. const response = await sendApiRequest(service, apiKey, payload, modelConfig)
  562.  
  563. handleApiResponse(response, service) // Processa a resposta
  564.  
  565. } catch (error) {
  566. // Exibe erros no overlay com botão de Tentar Novamente
  567. const errorMsg = `Error: ${error.message}`
  568. console.error('Summarize with AI:', errorMsg, error) // Loga o erro completo
  569. // Mostra erro no overlay (ou cria um novo se não existir), com botão de retry
  570. showSummaryOverlay(`<p style="color: red;">${errorMsg}</p>`, true)
  571. hideElement(DROPDOWN_ID) // Garante que o dropdown esteja oculto em caso de erro
  572. }
  573. }
  574.  
  575. /**
  576. * Envia a requisição HTTP para a API de IA (OpenAI ou Gemini).
  577. * @param {string} service - 'openai' ou 'gemini'.
  578. * @param {string} apiKey - A chave da API para o serviço.
  579. * @param {object} payload - Objeto com { title, content, lang }.
  580. * @param {object} modelConfig - Configuração do modelo ativo { id, service, name?, params? }.
  581. * @returns {Promise<object>} - A promessa resolve com o objeto de resposta da requisição.
  582. */
  583. async function sendApiRequest(service, apiKey, payload, modelConfig) {
  584. const group = MODEL_GROUPS[service]
  585. const url = service === 'openai'
  586. ? group.baseUrl // URL base da OpenAI
  587. : `${group.baseUrl}${modelConfig.id}:generateContent?key=${apiKey}` // URL Gemini (inclui ID do modelo e key)
  588.  
  589. return new Promise((resolve, reject) => {
  590. GM.xmlHttpRequest({
  591. method: 'POST',
  592. url: url,
  593. headers: getHeaders(service, apiKey), // Cabeçalhos específicos do serviço
  594. // Passa modelConfig para construir o corpo corretamente
  595. data: JSON.stringify(buildRequestBody(service, payload, modelConfig)),
  596. responseType: 'json', // Espera uma resposta JSON
  597. timeout: 60000, // Timeout de 60 segundos
  598. onload: response => {
  599. // GM.xmlHttpRequest pode retornar response.response em vez de responseText para JSON
  600. const responseData = response.response || response.responseText
  601. // Resolve com um objeto contendo status e dados parseados (ou texto original)
  602. resolve({
  603. status: response.status,
  604. // Tenta parsear mesmo que responseType seja json, pois pode falhar
  605. data: typeof responseData === 'object' ? responseData : JSON.parse(responseData || '{}'),
  606. statusText: response.statusText,
  607. })
  608. },
  609. onerror: error => reject(new Error(`Network error: ${error.statusText || 'Failed to connect'}`)),
  610. onabort: () => reject(new Error('Request aborted')),
  611. ontimeout: () => reject(new Error('Request timed out')),
  612. })
  613. })
  614. }
  615.  
  616. /**
  617. * Processa a resposta da API, extrai o sumário, limpa quebras de linha extras e atualiza o overlay.
  618. * @param {object} response - O objeto de resposta resolvido da Promise de `sendApiRequest` (contém status, data).
  619. * @param {string} service - 'openai' ou 'gemini'.
  620. */
  621. function handleApiResponse(response, service) {
  622. const {status, data, statusText} = response
  623.  
  624. // Verifica se o status HTTP indica sucesso (2xx)
  625. if (status < 200 || status >= 300) {
  626. // Tenta extrair uma mensagem de erro mais detalhada do corpo da resposta
  627. const errorDetails = data?.error?.message || data?.message || statusText || 'Unknown API error' // Gemini pode usar 'message' no erro
  628. throw new Error(`API Error (${status}): ${errorDetails}`)
  629. }
  630.  
  631. // Extrai o conteúdo do sumário dependendo do serviço
  632. let rawSummary = ''
  633. if (service === 'openai') {
  634. const choice = data?.choices?.[0]
  635. rawSummary = choice?.message?.content
  636.  
  637. // Loga o motivo pelo qual a geração parou
  638. const finishReason = choice?.finish_reason
  639. console.log(`Summarize with AI: OpenAI Finish Reason: ${finishReason} (Model: ${activeModel})`)
  640. if (finishReason === 'length') {
  641. console.warn('Summarize with AI: Summary may be incomplete because the max token limit was reached.')
  642. }
  643.  
  644. } else if (service === 'gemini') {
  645. const candidate = data?.candidates?.[0]
  646. const finishReason = candidate?.finishReason
  647. console.log(`Summarize with AI: Gemini Finish Reason: ${finishReason} (Model: ${activeModel})`)
  648.  
  649. if (finishReason === 'SAFETY') {
  650. const safetyRatings = candidate.safetyRatings?.map(r => `${r.category}: ${r.probability}`).join(', ')
  651. throw new Error(`Content blocked due to safety concerns (${safetyRatings || 'No details'}).`)
  652. }
  653. if (finishReason === 'MAX_TOKENS') {
  654. console.warn('Summarize with AI: Summary may be incomplete because the max token limit was reached.')
  655. }
  656.  
  657. // Verificação robusta: garante que parts existe e tem conteúdo
  658. if (candidate?.content?.parts?.length > 0 && candidate.content.parts[0].text) {
  659. rawSummary = candidate.content.parts[0].text
  660. } else if (finishReason && !['STOP', 'SAFETY', 'MAX_TOKENS'].includes(finishReason)) {
  661. // Loga aviso se motivo de finalização não for comum e não houver texto
  662. console.warn(`Summarize with AI: Gemini response structure missing expected text content or unusual finish reason: ${finishReason}`, candidate)
  663. } else if (!rawSummary && !data?.error) {
  664. console.warn('Summarize with AI: Gemini response structure missing expected text content.', candidate)
  665. }
  666. // Se rawSummary ainda estiver vazio aqui, o erro "did not contain valid summary" será lançado abaixo
  667. }
  668.  
  669. // Verifica se o sumário foi realmente obtido
  670. if (!rawSummary && !data?.error) { // Adicionada verificação !data?.error para não sobrescrever erros de API
  671. console.error('Summarize with AI: API Response Data:', data) // Loga a resposta para depuração
  672. throw new Error('API response did not contain a valid summary.')
  673. }
  674.  
  675. // Limpa quebras de linha (\n) que não fazem parte de tags HTML (substitui por espaço)
  676. // e comprime múltiplos espaços em um único espaço.
  677. // Isso ajuda a evitar espaçamento duplo estranho se a API retornar \n desnecessários.
  678. const cleanedSummary = rawSummary.replace(/\n/g, ' ').replace(/ {2,}/g, ' ').trim()
  679.  
  680. // Atualiza o overlay com o sumário limpo, sem botão de retry
  681. updateSummaryOverlay(cleanedSummary, false)
  682. }
  683.  
  684. /**
  685. * Constrói o corpo (payload) da requisição para a API (OpenAI ou Gemini).
  686. * Usa parâmetros definidos no modelConfig ou os padrões do serviço.
  687. * @param {string} service - 'openai' ou 'gemini'.
  688. * @param {object} payload - Objeto com { title, content, lang }.
  689. * @param {object} modelConfig - Configuração do modelo ativo { id, service, name?, params? }.
  690. * @returns {object} - O objeto do corpo da requisição.
  691. */
  692. function buildRequestBody(service, {title, content, lang}, modelConfig) {
  693. const systemPrompt = PROMPT_TEMPLATE(title, content, lang) // Gera o prompt principal
  694. const serviceDefaults = MODEL_GROUPS[service]?.defaultParams || {}
  695. const modelSpecificParams = modelConfig?.params || {}
  696.  
  697. if (service === 'openai') {
  698. // Mescla parâmetros padrão e específicos do modelo
  699. const finalParams = {...serviceDefaults, ...modelSpecificParams}
  700.  
  701. return {
  702. model: modelConfig.id, // Usa o ID do modelo da config
  703. messages: [
  704. {role: 'system', content: systemPrompt},
  705. {role: 'user', content: 'Generate the summary as requested.'},
  706. ],
  707. // Inclui parâmetros mesclados (ex: max_completion_tokens)
  708. ...finalParams
  709. // 'temperature' não está definido, usará o padrão da API ou o definido em params
  710. }
  711. } else { // gemini
  712. // Mescla parâmetros padrão e específicos do modelo para generationConfig
  713. const finalGenConfigParams = {...serviceDefaults, ...modelSpecificParams}
  714.  
  715. return {
  716. contents: [{
  717. parts: [{text: systemPrompt}], // Estrutura do Gemini
  718. }],
  719. // Inclui generationConfig com parâmetros mesclados
  720. generationConfig: finalGenConfigParams
  721. // 'temperature' não está definido, usará o padrão da API ou o definido em params
  722. }
  723. }
  724. }
  725.  
  726. /**
  727. * Retorna os cabeçalhos HTTP apropriados para a API.
  728. * @param {string} service - 'openai' ou 'gemini'.
  729. * @param {string} apiKey - A chave da API.
  730. * @returns {object} - O objeto de cabeçalhos.
  731. */
  732. function getHeaders(service, apiKey) {
  733. const headers = {'Content-Type': 'application/json'}
  734. if (service === 'openai') {
  735. headers['Authorization'] = `Bearer ${apiKey}` // OpenAI usa Bearer token
  736. }
  737. // Gemini usa a chave na URL, não no cabeçalho
  738. return headers
  739. }
  740.  
  741. /**
  742. * Obtém a chave da API para o serviço especificado a partir do armazenamento (GM.getValue).
  743. * Se não existir, retorna null (a verificação e mensagem de erro ocorrem em processSummarization).
  744. * @param {string} service - 'openai' ou 'gemini'.
  745. * @returns {Promise<string|null>} - A chave da API ou null se não for encontrada.
  746. */
  747. async function getApiKey(service) {
  748. const storageKey = `${service}_api_key`
  749. let apiKey = await GM.getValue(storageKey)
  750. // Retorna a chave encontrada ou null se não existir/vazia
  751. return apiKey?.trim() || null
  752. }
  753.  
  754. /**
  755. * Permite ao usuário resetar (redefinir) a chave da API para um serviço específico via prompt.
  756. * Ativado pelo link 'Reset Key' no dropdown.
  757. * @param {string} service - O serviço ('openai' ou 'gemini') para o qual resetar a chave.
  758. */
  759. async function handleApiKeyReset(service) {
  760. if (!service || !MODEL_GROUPS[service]) {
  761. console.error("Invalid service provided for API key reset:", service)
  762. alert("Internal error: Invalid service provided.")
  763. return
  764. }
  765.  
  766. const storageKey = `${service}_api_key`
  767. const newKey = prompt(`Enter the new ${service.toUpperCase()} API key (leave blank to clear):`)
  768.  
  769. if (newKey !== null) { // Verifica se o usuário não cancelou (clicou em OK ou deixou em branco)
  770. const keyToSave = newKey.trim()
  771. await GM.setValue(storageKey, keyToSave)
  772. if (keyToSave) {
  773. alert(`${service.toUpperCase()} API key updated!`)
  774. } else {
  775. alert(`${service.toUpperCase()} API key cleared!`)
  776. }
  777. // Opcional: Repopular dropdown para refletir alguma mudança visual se necessário
  778. // const dropdown = document.getElementById(DROPDOWN_ID)
  779. // if (dropdown && dropdown.style.display !== 'none') {
  780. // populateDropdown(dropdown)
  781. // }
  782. }
  783. // Se newKey for null (usuário clicou Cancelar), não faz nada.
  784. }
  785.  
  786. /**
  787. * Gerencia o fluxo para adicionar um novo modelo customizado.
  788. * Pede ao usuário o serviço e o ID do modelo via prompts.
  789. * Salva no formato { id, service }.
  790. */
  791. async function handleAddModel() {
  792. // 1. Pergunta o serviço (OpenAI ou Gemini)
  793. const service = prompt('Enter the service for the custom model (openai / gemini):')?.toLowerCase()?.trim()
  794. if (!service || !MODEL_GROUPS[service]) {
  795. if (service !== null) alert('Invalid service. Please enter "openai" or "gemini".')
  796. return // Cancela se inválido ou se o usuário cancelar
  797. }
  798.  
  799. // 2. Pergunta o nome exato (ID) do modelo
  800. const modelId = prompt(`Enter the exact ID of the ${service.toUpperCase()} model:`)?.trim()
  801. if (!modelId) {
  802. if (modelId !== null) alert('Model ID cannot be empty.')
  803. return // Cancela se vazio ou se o usuário cancelar
  804. }
  805.  
  806. // 3. Adiciona o modelo e salva
  807. await addCustomModel(service, modelId)
  808. // Opcional: reabrir dropdown após adicionar? Por ora, não.
  809. }
  810.  
  811. /**
  812. * Adiciona um novo modelo customizado à lista e salva no GM storage.
  813. * Atualiza a variável global `customModels`. Salva como { id, service }.
  814. * @param {string} service - 'openai' ou 'gemini'.
  815. * @param {string} modelId - O ID exato do modelo.
  816. */
  817. async function addCustomModel(service, modelId) {
  818. // Verifica se o ID do modelo já existe para este serviço (case-insensitive)
  819. const exists = customModels.some(m => m.service === service && m.id.toLowerCase() === modelId.toLowerCase()) ||
  820. MODEL_GROUPS[service]?.models.some(m => m.id.toLowerCase() === modelId.toLowerCase()) // Verifica também nos padrões
  821.  
  822. if (exists) {
  823. alert(`Model ID "${modelId}" already exists for ${service.toUpperCase()}.`)
  824. return
  825. }
  826.  
  827. // Adiciona o novo modelo à lista na memória
  828. customModels.push({id: modelId, service}) // Salva no formato { id, service }
  829. // Salva a lista atualizada no storage
  830. await GM.setValue(CUSTOM_MODELS_KEY, JSON.stringify(customModels))
  831. alert(`Custom model "${modelId}" (${service.toUpperCase()}) added!`)
  832. }
  833.  
  834. /**
  835. * Carrega a lista de modelos customizados salvos no GM storage.
  836. * Espera o formato [{ id, service }, ...].
  837. * @returns {Promise<Array<object>>} - Uma promessa que resolve com o array de modelos customizados.
  838. */
  839. async function getCustomModels() {
  840. try {
  841. const storedModels = await GM.getValue(CUSTOM_MODELS_KEY, '[]') // Obtém a string JSON, default '[]'
  842. const parsedModels = JSON.parse(storedModels)
  843. // Validação simples para garantir que é um array de objetos com id e service
  844. if (Array.isArray(parsedModels) && parsedModels.every(m => typeof m === 'object' && m.id && m.service)) {
  845. return parsedModels
  846. } else {
  847. console.warn("Summarize with AI: Invalid custom model format found in storage. Resetting.", parsedModels)
  848. await GM.setValue(CUSTOM_MODELS_KEY, '[]') // Reseta se formato inválido
  849. return []
  850. }
  851. } catch (error) {
  852. console.error('Summarize with AI: Failed to load/parse custom models:', error)
  853. // Em caso de erro de parse, retorna um array vazio e tenta limpar o storage
  854. await GM.setValue(CUSTOM_MODELS_KEY, '[]') // Reseta para um array vazio
  855. return []
  856. }
  857. }
  858.  
  859. // --- Funções de Eventos e Utilidades ---
  860.  
  861. /**
  862. * Manipulador para o atalho de teclado (Alt+S) e tecla Esc.
  863. * Alt+S: Simula clique no botão 'S' (inicia sumarização).
  864. * Esc: Fecha o overlay ou o dropdown.
  865. * @param {KeyboardEvent} e - O objeto do evento de teclado.
  866. */
  867. function handleKeyPress(e) {
  868. // Atalho Alt+S para iniciar sumarização (simula clique simples)
  869. if (e.altKey && e.code === 'KeyS') {
  870. e.preventDefault()
  871. const button = document.getElementById(BUTTON_ID)
  872. if (button) {
  873. // Chama a função principal de sumarização
  874. processSummarization()
  875. }
  876. }
  877. // Tecla Esc para fechar overlay ou dropdown
  878. if (e.key === 'Escape') {
  879. if (document.getElementById(OVERLAY_ID)) { // Prioriza fechar o overlay
  880. closeOverlay()
  881. } else if (document.getElementById(DROPDOWN_ID)?.style.display !== 'none') { // Fecha o dropdown se aberto
  882. hideElement(DROPDOWN_ID)
  883. }
  884. }
  885. }
  886.  
  887. /**
  888. * Configura listeners para esconder/mostrar o botão 'S' com base no foco em inputs.
  889. */
  890. function setupFocusListeners() {
  891. // Esconde o botão quando um campo editável ganha foco
  892. document.addEventListener('focusin', (event) => {
  893. if (event.target?.closest('input, textarea, select, [contenteditable="true"]')) {
  894. hideElement(BUTTON_ID)
  895. hideElement(DROPDOWN_ID) // Esconde dropdown também
  896. }
  897. })
  898.  
  899. // Mostra o botão quando o foco sai de um campo editável (clicando fora)
  900. document.addEventListener('focusout', (event) => {
  901. // Verifica se o elemento que perdeu o foco é um campo editável
  902. // e se o novo elemento focado (relatedTarget) NÃO é um campo editável
  903. const isLeavingInput = event.target?.closest('input, textarea, select, [contenteditable="true"]')
  904. const isEnteringInput = event.relatedTarget?.closest('input, textarea, select, [contenteditable="true"]')
  905.  
  906. // Só mostra o botão se estiver saindo de um input e não entrando em outro,
  907. // e se o artigo foi detectado.
  908. if (isLeavingInput && !isEnteringInput && articleData) {
  909. // Pequeno delay para evitar piscar se o foco mudar rapidamente entre inputs
  910. setTimeout(() => {
  911. // Reconfirma se o foco atual não é um input antes de mostrar
  912. if (!document.activeElement?.closest('input, textarea, select, [contenteditable="true"]')) {
  913. showElement(BUTTON_ID)
  914. }
  915. }, 50) // Delay de 50ms
  916. }
  917. }, true) // Usa captura para garantir que o evento seja pego
  918. }
  919.  
  920. /**
  921. * Injeta os estilos CSS necessários para a interface do script.
  922. */
  923. function injectStyles() {
  924. // Estilos CSS com adições para cores de qualidade, dark mode e responsividade móvel
  925. GM.addStyle(`
  926. /* --- Elementos Principais da UI --- */
  927. #${BUTTON_ID} {
  928. position: fixed; bottom: 20px; right: 20px;
  929. width: 50px; height: 50px; /* Tamanho */
  930. background: linear-gradient(145deg, #3a7bd5, #00d2ff); /* Gradiente azul */
  931. color: white; font-size: 24px; /* Texto */
  932. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  933. border-radius: 50%; cursor: pointer; z-index: 2147483640;
  934. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
  935. display: flex !important; align-items: center !important; justify-content: center !important; /* Centraliza 'S' */
  936. transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
  937. line-height: 1; user-select: none; /* Previne seleção */
  938. }
  939. #${BUTTON_ID}:hover {
  940. transform: scale(1.1); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25);
  941. }
  942. #${DROPDOWN_ID} {
  943. position: fixed; bottom: 80px; right: 20px; /* Acima do botão */
  944. background: #ffffff; border: 1px solid #e0e0e0; border-radius: 10px;
  945. box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15); z-index: 2147483641; /* Acima do botão */
  946. max-height: 70vh; overflow-y: auto; /* Scroll */
  947. padding: 8px; width: 300px; /* Dimensões */
  948. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  949. display: none; /* Começa oculto */
  950. animation: fadeIn 0.2s ease-out; /* Animação */
  951. }
  952. #${OVERLAY_ID} {
  953. position: fixed; top: 0; left: 0; width: 100%; height: 100%;
  954. background-color: rgba(0, 0, 0, 0.6); /* Fundo semi-transparente (padrão light) */
  955. z-index: 2147483645; /* Muito alto */
  956. display: flex; align-items: center; justify-content: center;
  957. overflow: hidden; /* Impede scroll do body */
  958. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  959. animation: fadeIn 0.3s ease-out;
  960. }
  961. #${CONTENT_ID} {
  962. background-color: #fff; /* Fundo branco (padrão light) */
  963. color: #333; /* Texto escuro (padrão light) */
  964. padding: 25px 35px; border-radius: 12px;
  965. box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
  966. max-width: 800px; width: 90%; max-height: 85vh; /* Dimensões */
  967. overflow-y: auto; /* Scroll interno */
  968. position: relative; font-size: 16px; line-height: 1.6;
  969. animation: slideInUp 0.3s ease-out; /* Animação */
  970. white-space: normal; /* Permite quebra de linha HTML */
  971. box-sizing: border-box; /* Garante que padding não aumente o tamanho total */
  972. }
  973. #${CONTENT_ID} p { margin-top: 0; margin-bottom: 1em; } /* Margem padrão para parágrafos */
  974. #${CONTENT_ID} ul { margin: 1em 0; padding-left: 1.5em; } /* Adiciona padding para bullet points */
  975. #${CONTENT_ID} li { list-style-type: none; margin-bottom: 0.5em; } /* Remove marcador padrão (usa emoji) */
  976. #${CLOSE_BUTTON_ID} {
  977. position: absolute; top: 10px; right: 15px;
  978. font-size: 28px; color: #aaa; /* Cinza claro (padrão light) */
  979. cursor: pointer;
  980. transition: color 0.2s; line-height: 1; z-index: 1; /* Garante que fique acima do conteúdo */
  981. }
  982. #${CLOSE_BUTTON_ID}:hover { color: #333; } /* Mais escuro no hover (light) */
  983. #${ERROR_ID} {
  984. position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); /* Centralizado */
  985. background-color: #e53e3e; color: white; padding: 12px 20px;
  986. border-radius: 6px; z-index: 2147483646; /* Acima de tudo */
  987. font-size: 14px; box-shadow: 0 2px 8px rgba(0,0,0,0.2);
  988. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  989. animation: fadeIn 0.3s, fadeOut 0.3s 3.7s forwards; /* Fade in e out */
  990. }
  991. .retry-button { /* Estilo para o botão Tentar Novamente */
  992. display: block; margin: 20px auto 0; padding: 8px 16px;
  993. background-color: #4a90e2; /* Azul (padrão light) */
  994. color: white; border: none; border-radius: 5px;
  995. cursor: pointer; font-size: 14px; transition: background-color 0.2s;
  996. }
  997. .retry-button:hover { background-color: #3a7bd5; } /* Azul mais escuro no hover (light) */
  998.  
  999. /* --- Estilos do Dropdown --- */
  1000. .model-group { margin-bottom: 8px; }
  1001. .group-header-container { /* Container para header e link reset */
  1002. display: flex; align-items: center; justify-content: space-between;
  1003. padding: 8px 12px; background: #f7f7f7;
  1004. border-radius: 6px; margin-bottom: 4px;
  1005. }
  1006. .group-header-text { /* Texto do header */
  1007. font-weight: 600; color: #333; font-size: 13px;
  1008. text-transform: uppercase; letter-spacing: 0.5px;
  1009. flex-grow: 1; /* Ocupa espaço disponível */
  1010. }
  1011. .reset-key-link { /* Link de reset */
  1012. font-size: 11px; color: #666; text-decoration: none;
  1013. margin-left: 10px; /* Espaçamento */
  1014. white-space: nowrap; /* Não quebrar linha */
  1015. cursor: pointer;
  1016. transition: color 0.2s;
  1017. }
  1018. .reset-key-link:hover { color: #1a73e8; }
  1019. .model-item {
  1020. padding: 10px 14px; margin: 2px 0; border-radius: 6px;
  1021. transition: background-color 0.15s ease-out, color 0.15s ease-out;
  1022. font-size: 14px; cursor: pointer; color: #444; display: block;
  1023. overflow: hidden; text-overflow: ellipsis; white-space: nowrap; /* Evita quebra de linha em nomes longos */
  1024. }
  1025. .model-item:hover { background-color: #eef6ff; color: #1a73e8; }
  1026. .add-model-item { /* Estilo específico para o item de adicionar modelo */
  1027. color: #666;
  1028. font-style: italic;
  1029. }
  1030. .add-model-item:hover { background-color: #f0f0f0; color: #333; }
  1031.  
  1032. /* --- Estilos de Conteúdo (Glow, Qualidade) --- */
  1033. .glow { /* Estilo para "Summarizing with [Model]..." / "Retrying with [Model]..." */
  1034. font-size: 1.4em; text-align: center; padding: 40px 0;
  1035. /* Aplica a animação 'glow' com ciclo infinito e duração de 2.5s */
  1036. animation: glow 2.5s ease-in-out infinite;
  1037. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  1038. font-weight: 400;
  1039. }
  1040. /* Cores para as classes de qualidade do artigo */
  1041. span.article-excellent { color: #2ecc71; font-weight: bold; } /* Verde brilhante */
  1042. span.article-good { color: #3498db; font-weight: bold; } /* Azul */
  1043. span.article-average { color: #f39c12; font-weight: bold; } /* Laranja */
  1044. span.article-bad { color: #e74c3c; font-weight: bold; } /* Vermelho */
  1045. span.article-very-bad { color: #c0392b; font-weight: bold; } /* Vermelho escuro */
  1046.  
  1047. /* --- Animações --- */
  1048. /* Define os keyframes para a animação 'glow' ciclando entre azul, roxo e vermelho */
  1049. @keyframes glow {
  1050. 0%, 100% { /* Início e Fim: Azul */
  1051. color: #4a90e2;
  1052. text-shadow: 0 0 10px rgba(74, 144, 226, 0.6),
  1053. 0 0 20px rgba(74, 144, 226, 0.4);
  1054. }
  1055. 33% { /* Ponto intermediário 1: Roxo */
  1056. color: #9b59b6; /* Tom de roxo */
  1057. text-shadow: 0 0 12px rgba(155, 89, 182, 0.7), /* Sombra roxa */
  1058. 0 0 25px rgba(155, 89, 182, 0.5);
  1059. }
  1060. 66% { /* Ponto intermediário 2: Vermelho */
  1061. color: #e74c3c; /* Tom de vermelho */
  1062. text-shadow: 0 0 12px rgba(231, 76, 60, 0.7), /* Sombra vermelha */
  1063. 0 0 25px rgba(231, 76, 60, 0.5);
  1064. }
  1065. }
  1066. @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
  1067. @keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
  1068. @keyframes slideInUp {
  1069. from { transform: translateY(30px); opacity: 0; }
  1070. to { transform: translateY(0); opacity: 1; }
  1071. }
  1072.  
  1073. /* --- Dark Mode Override (Adaptação automática ao tema escuro do sistema) --- */
  1074. @media (prefers-color-scheme: dark) {
  1075. /* Fundo do overlay mais escuro */
  1076. #${OVERLAY_ID} {
  1077. background-color: rgba(20, 20, 20, 0.7); /* Fundo mais opaco e escuro */
  1078. }
  1079. /* Conteúdo do sumário com fundo escuro e texto claro */
  1080. #${CONTENT_ID} {
  1081. background-color: #2c2c2c; /* Cinza bem escuro */
  1082. color: #e0e0e0; /* Texto cinza claro */
  1083. box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4); /* Sombra um pouco mais visível */
  1084. }
  1085. /* Botão de fechar com cores invertidas */
  1086. #${CLOSE_BUTTON_ID} {
  1087. color: #888; /* Cinza médio */
  1088. }
  1089. #${CLOSE_BUTTON_ID}:hover {
  1090. color: #eee; /* Quase branco no hover */
  1091. }
  1092. /* Botão Tentar Novamente com estilo adaptado */
  1093. .retry-button {
  1094. background-color: #555; /* Cinza médio */
  1095. color: #eee; /* Texto claro */
  1096. }
  1097. .retry-button:hover {
  1098. background-color: #666; /* Cinza um pouco mais claro no hover */
  1099. }
  1100. /* Dropdown também pode ter fundo escuro (opcional, mantendo legibilidade) */
  1101. #${DROPDOWN_ID} {
  1102. background: #333; /* Fundo escuro para dropdown */
  1103. border-color: #555; /* Borda mais escura */
  1104. }
  1105. .model-item {
  1106. color: #ccc; /* Texto do item mais claro */
  1107. }
  1108. .model-item:hover {
  1109. background-color: #444; /* Fundo de hover mais escuro */
  1110. color: #fff; /* Texto branco no hover */
  1111. }
  1112. .group-header-container {
  1113. background: #444; /* Fundo do cabeçalho do grupo */
  1114. }
  1115. .group-header-text {
  1116. color: #eee; /* Texto do cabeçalho claro */
  1117. }
  1118. .reset-key-link {
  1119. color: #aaa; /* Link de reset mais claro */
  1120. }
  1121. .reset-key-link:hover {
  1122. color: #fff; /* Link de reset branco no hover */
  1123. }
  1124. .add-model-item {
  1125. color: #999; /* Item de adicionar mais claro */
  1126. }
  1127. .add-model-item:hover {
  1128. background-color: #4a4a4a; /* Fundo de hover */
  1129. color: #eee; /* Texto claro no hover */
  1130. }
  1131. hr {
  1132. border-top-color: #555 !important; /* Separador mais escuro */
  1133. }
  1134. /* Ajuste de cor para o brilho no modo escuro se necessário (opcional) */
  1135. /* As cores atuais do glow parecem funcionar bem, mas podem ser ajustadas aqui */
  1136. /* @keyframes glow-dark { ... } */
  1137. /* .glow { animation-name: glow-dark; } */
  1138. }
  1139.  
  1140. /* --- Mobile Responsiveness --- */
  1141. /* Ajustes para telas pequenas (e.g., smartphones) */
  1142. @media (max-width: 600px) {
  1143. /* Faz o conteúdo do overlay ocupar a tela inteira */
  1144. #${CONTENT_ID} {
  1145. width: 100%; /* Largura total */
  1146. height: 100%; /* Altura total */
  1147. max-width: none; /* Remove limite de largura máxima */
  1148. max-height: none; /* Remove limite de altura máxima */
  1149. border-radius: 0; /* Remove cantos arredondados (edge-to-edge) */
  1150. padding: 15px; /* Reduz padding interno */
  1151. box-shadow: none; /* Remove sombra (opcional) */
  1152. /* A animação pode ser desabilitada em mobile se preferir */
  1153. /* animation: none; */
  1154. }
  1155. /* Ajusta posição do botão de fechar para o novo padding */
  1156. #${CLOSE_BUTTON_ID} {
  1157. top: 10px;
  1158. right: 10px;
  1159. }
  1160. /* Esconde o botão flutuante 'S' e o dropdown quando o overlay estiver aberto */
  1161. /* (Embora o overlay já esteja acima, garante que não apareçam por baixo) */
  1162. #${OVERLAY_ID} ~ #${BUTTON_ID},
  1163. #${OVERLAY_ID} ~ #${DROPDOWN_ID} {
  1164. display: none !important;
  1165. }
  1166. }
  1167. `)
  1168. }
  1169.  
  1170. // --- Inicialização ---
  1171. initialize() // Chama a função principal para iniciar o script
  1172.  
  1173. })()