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.

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Summarize with AI
// @namespace    https://github.com/insign/userscripts
// @version      2025.05.03.1751
// @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.
// @author       Hélio <[email protected]>
// @license      WTFPL
// @match        *://*/*
// @grant        GM.addStyle
// @grant        GM.xmlHttpRequest
// @grant        GM.setValue
// @grant        GM.getValue
// @connect      api.openai.com
// @connect      generativelanguage.googleapis.com
// @require      https://cdnjs.cloudflare.com/ajax/libs/readability/0.5.0/Readability.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/readability/0.5.0/Readability-readerable.min.js
// ==/UserScript==

(function () {
	'use strict'

	// --- Constantes ---
	// IDs dos elementos da interface do script
	const BUTTON_ID         = 'summarize-button'       // Botão principal flutuante 'S'
	const DROPDOWN_ID       = 'model-dropdown'         // Dropdown de seleção de modelo
	const OVERLAY_ID        = 'summarize-overlay'      // Overlay de fundo para o sumário
	const CLOSE_BUTTON_ID   = 'summarize-close'        // Botão de fechar no overlay
	const CONTENT_ID        = 'summarize-content'      // Div que contém o texto do sumário
	const ERROR_ID          = 'summarize-error'        // Div para exibir notificações de erro
	const ADD_MODEL_ITEM_ID = 'add-custom-model'       // ID para o item "Adicionar Modelo" no dropdown
	const RETRY_BUTTON_ID = 'summarize-retry-button' // ID para o botão "Tentar Novamente" no overlay de erro

	// Chave para armazenar modelos customizados no GM storage
	const CUSTOM_MODELS_KEY = 'custom_ai_models'

	// Limite de tokens padrão
	const DEFAULT_MAX_TOKENS = 1000
	// Limite de tokens alto (para modelos específicos)
	const HIGH_MAX_TOKENS    = 1500
	// Tempo para considerar long press (em milissegundos)
	const LONG_PRESS_DURATION = 500

	// Configuração dos serviços e modelos de IA *padrão* suportados
	// Nova estrutura: models é um array de objetos com id, name (opcional), params (opcional)
	const MODEL_GROUPS = {
		openai: {
			name:    'OpenAI',
			baseUrl: 'https://api.openai.com/v1/chat/completions',
			models:  [
				{id: 'o4-mini', name: 'o4 mini (better)', params: {max_completion_tokens: HIGH_MAX_TOKENS}},
				{id: 'o3-mini', name: 'o3 mini', params: {max_completion_tokens: HIGH_MAX_TOKENS}}, // Nome pode precisar de ajuste
				{id: 'gpt-4.1', name: 'GPT-4.1'}, // Usa params padrão
				{id: 'gpt-4.1-mini', name: 'GPT-4.1 mini'},
				{id: 'gpt-4.1-nano', name: 'GPT-4.1 nano (faster)'},
			],
			// Parâmetros padrão específicos para OpenAI (se não definidos no modelo)
			defaultParams: {max_completion_tokens: DEFAULT_MAX_TOKENS}
		},
		gemini: {
			name:    'Gemini',
			baseUrl: 'https://generativelanguage.googleapis.com/v1beta/models/',
			models:  [
				{id:      'gemini-2.5-flash-preview-04-17',
					name:   'Gemini 2.5 Flash (faster)',
					params: {maxOutputTokens: HIGH_MAX_TOKENS}
				},
				{id: 'gemini-2.5-pro-exp-03-25', name: 'Gemini 2.5 Pro (better)', params: {maxOutputTokens: HIGH_MAX_TOKENS}},
			],
			// Parâmetros padrão específicos para Gemini (se não definidos no modelo)
			defaultParams: {maxOutputTokens: DEFAULT_MAX_TOKENS} // Mantemos o padrão original aqui
		},
	}

	// Template do prompt enviado para a IA
	// Instruções atualizadas para usar as classes CSS específicas de qualidade
	const PROMPT_TEMPLATE = (title, content, lang) => `You are a summarizer bot that provides clear and affirmative explanations of content.
		Generate a concise summary that includes:
		- 2-sentence introduction
- Relevant emojis as bullet points
- No section headers
- Use HTML formatting, never use \`\`\` code blocks, never use markdown.
- 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.
- User language to be used in the entire summary: ${lang}
- 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.
- For the quality class use:
	<span class=article-excellent>9/10</span> (or 10)
	<span class=article-good>8/10</span>
	<span class=article-average>7/10</span>
	<span class=article-bad>6/10</span>
	<span class=article-very-bad>5/10</span> (or less)
- "Opinion:", "Article Quality:" should be in user language, e.g. "Opinião:", "Qualidade do artigo:" for Português.

Article Title: ${title}
Article Content: ${content}`

// --- Variáveis de Estado ---
	let activeModel    = 'gemini-2.5-flash-preview-04-17' // ID do modelo ativo selecionado por padrão ou pelo usuário
	let articleData    = null                             // Armazena o título e conteúdo extraído do artigo { title, content }
	let customModels   = []                              // Array para armazenar modelos customizados carregados do storage { id, service }
	let longPressTimer = null                             // Timer para detectar long press no botão 'S'
	let isLongPress    = false                            // Flag para indicar se ocorreu long press

// --- Funções Principais ---

	/**
	 * Função principal de inicialização do script.
	 * Carrega modelos customizados, adiciona listener de teclado,
	 * tenta extrair dados do artigo, e se bem-sucedido, adiciona o botão e listeners de foco.
	 */
	async function initialize() {
		customModels = await getCustomModels() // Carrega modelos customizados do storage
		document.addEventListener('keydown', handleKeyPress) // Listener para atalhos (Alt+S, Esc)
		articleData = getArticleData()         // Tenta extrair o conteúdo do artigo
		if (articleData) {                     // Se encontrou conteúdo legível:
			addSummarizeButton()                 // Adiciona o botão flutuante e o dropdown
			showElement(BUTTON_ID)               // Torna o botão visível
			setupFocusListeners()                // Configura para esconder/mostrar botão em campos de input
			// Define o último modelo usado (ou padrão) como ativo
			activeModel = await GM.getValue('last_used_model', activeModel)
		}
	}

	/**
	 * Tenta extrair o conteúdo principal da página usando a biblioteca Readability.js.
	 * @returns {object|null} - Um objeto { title, content } se bem-sucedido, ou null se não for legível ou ocorrer erro.
	 */
	function getArticleData() {
		try {
			const docClone = document.cloneNode(true) // Clona o documento para não modificar o original
			// Remove elementos que podem interferir com a extração
			docClone.querySelectorAll('script, style, noscript, iframe, figure, img, svg, header, footer, nav').forEach(el => el.remove())
			// Verifica se a página é provavelmente legível
			if (!isProbablyReaderable(docClone)) {
				console.log('Summarize with AI: Page not detected as readerable.')
				return null
			}
			const reader  = new Readability(docClone)
			const article = reader.parse()
			// Retorna dados se o conteúdo foi extraído e não está vazio
			return (article?.content && article.textContent?.trim())
					? {title: article.title, content: article.textContent.trim()}
					: null
		} catch (error) {
			console.error('Summarize with AI: Article parsing failed:', error)
			return null // Retorna null em caso de erro
		}
	}

	/**
	 * Adiciona o botão flutuante 'S' e o dropdown de seleção de modelo ao DOM.
	 * Configura os event listeners do botão (click, long press) e injeta estilos.
	 */
	function addSummarizeButton() {
		// Evita adicionar o botão múltiplas vezes
		if (document.getElementById(BUTTON_ID)) return

		// Cria o botão 'S'
		const button       = document.createElement('div')
		button.id          = BUTTON_ID
		button.textContent = 'S' // Texto simples e pequeno
		button.title = 'Summarize (Alt+S) / Long Press to Select Model' // Tooltip atualizado (sem dblclick)
		document.body.appendChild(button)

		// Cria o dropdown (inicialmente oculto)
		const dropdown = createDropdownElement() // Cria o elemento base do dropdown
		document.body.appendChild(dropdown)
		populateDropdown(dropdown) // Preenche o dropdown com modelos

		// Listener para clique simples: Inicia a sumarização com o modelo ativo
		button.addEventListener('click', () => {
			// Só executa se não foi um long press
			if (!isLongPress) {
				processSummarization() // Chama a função principal de sumarização
			}
			// Reseta a flag de long press para o próximo clique
			isLongPress = false
		})

		// Listener para Long Press: Mostra/esconde o dropdown
		button.addEventListener('mousedown', (e) => {
			// Inicia o timer para detectar long press
			isLongPress = false // Reseta a flag
			clearTimeout(longPressTimer) // Limpa timer anterior se houver
			longPressTimer = setTimeout(() => {
				isLongPress = true // Marca que ocorreu long press
				toggleDropdown(e) // Abre/fecha o dropdown
			}, LONG_PRESS_DURATION)
		})

		// Listener para soltar o botão (cancela o timer se antes do tempo)
		button.addEventListener('mouseup', () => {
			clearTimeout(longPressTimer)
		})

		// Listener se o mouse sair do botão (cancela o timer)
		button.addEventListener('mouseleave', () => {
			clearTimeout(longPressTimer)
		})

		// Listener para clique fora do dropdown para fechá-lo
		document.addEventListener('click', handleOutsideClick)

		// Injeta os estilos CSS necessários para a interface
		injectStyles()
	}


// --- Funções de UI (Dropdown, Overlay, Notificações) ---

	/**
	 * Cria o elemento base (container) do dropdown.
	 * @returns {HTMLElement} - O elemento div do dropdown, inicialmente vazio e oculto.
	 */
	function createDropdownElement() {
		const dropdown         = document.createElement('div')
		dropdown.id            = DROPDOWN_ID
		dropdown.style.display = 'none' // Começa oculto
		return dropdown
	}

	/**
	 * Preenche o elemento dropdown com os grupos de modelos (padrão e customizados)
	 * e a opção para adicionar novos modelos. Adiciona links de reset de API Key.
	 * @param {HTMLElement} dropdownElement - O elemento do dropdown a ser preenchido.
	 */
	function populateDropdown(dropdownElement) {
		dropdownElement.innerHTML = '' // Limpa conteúdo anterior

		Object.entries(MODEL_GROUPS).forEach(([service, group]) => {
			// Combina modelos padrão e customizados para este serviço
			const standardModels      = group.models || [] // Array de objetos {id, name?, params?}
			const serviceCustomModels = customModels
					.filter(m => m.service === service) // Filtra customizados por serviço {id, service}
					.map(m => ({id: m.id})) // Mapeia para o formato {id}, sem name ou params definidos aqui

			const allModelObjects = [...standardModels, ...serviceCustomModels]
					// Remove duplicatas baseadas no ID (case-insensitive)
					.reduce((acc, model) => {
						if (!acc.some(existing => existing.id.toLowerCase() === model.id.toLowerCase())) {
							acc.push(model)
						}
						return acc
					}, [])
					.sort((a, b) => a.id.localeCompare(b.id)) // Ordena alfabeticamente pelo ID

			if (allModelObjects.length > 0) {
				const groupDiv     = document.createElement('div')
				groupDiv.className = 'model-group'
				// Cria o cabeçalho com link de reset
				groupDiv.appendChild(createHeader(group.name, service))
				// Adiciona cada item de modelo
				allModelObjects.forEach(modelObj => groupDiv.appendChild(createModelItem(modelObj)))
				dropdownElement.appendChild(groupDiv)
			}
		})

		// Adiciona separador e item "+ Adicionar"
		const separator           = document.createElement('hr')
		separator.style.margin    = '8px 0'
		separator.style.border    = 'none'
		separator.style.borderTop = '1px solid #eee'
		dropdownElement.appendChild(separator)
		dropdownElement.appendChild(createAddModelItem())
	}

	/**
	 * Cria um elemento de cabeçalho para um grupo de modelos no dropdown,
	 * incluindo um link para resetar a API Key do serviço.
	 * @param {string} text - O texto do cabeçalho (nome do serviço).
	 * @param {string} service - A chave do serviço ('openai' ou 'gemini').
	 * @returns {HTMLElement} - O elemento div do cabeçalho.
	 */
	function createHeader(text, service) {
		const headerContainer     = document.createElement('div')
		headerContainer.className = 'group-header-container' // Container para flex layout

		const headerText       = document.createElement('span') // Span para o texto
		headerText.className   = 'group-header-text'
		headerText.textContent = text

		const resetLink       = document.createElement('a') // Link para resetar
		resetLink.href        = '#'
		resetLink.textContent = 'Reset Key'
		resetLink.className   = 'reset-key-link'
		resetLink.title       = `Reset ${text} API Key`
		resetLink.addEventListener('click', (e) => {
			e.preventDefault() // Previne navegação
			e.stopPropagation() // Impede que feche o dropdown
			handleApiKeyReset(service) // Chama o reset para o serviço específico
		})

		headerContainer.appendChild(headerText)
		headerContainer.appendChild(resetLink)
		return headerContainer
	}

	/**
	 * Cria um item clicável para um modelo específico no dropdown.
	 * Usa a nova estrutura de objeto do modelo.
	 * @param {object} modelObj - O objeto do modelo { id, name?, params? }.
	 * @returns {HTMLElement} - O elemento div do item do modelo.
	 */
	function createModelItem(modelObj) {
		const item       = document.createElement('div')
		item.className   = 'model-item'
		// Usa o nome amigável se disponível, senão o ID
		item.textContent = modelObj.name || modelObj.id
		// Adiciona um marcador visual se for o modelo ativo atualmente
		if (modelObj.id === activeModel) {
			item.style.fontWeight = 'bold'
			item.style.color      = '#1A73E8' // Azul para destacar
		}
		// Listener de clique: seleciona o ID do modelo, esconde dropdown e inicia sumarização
		item.addEventListener('click', async () => {
			activeModel = modelObj.id // Define o ID do modelo ativo
			await GM.setValue('last_used_model', activeModel) // Salva a última seleção
			hideElement(DROPDOWN_ID) // Esconde o dropdown
			processSummarization() // Inicia o processo de sumarização
		})
		return item
	}

	/**
	 * Cria o item clicável "+ Adicionar Modelo Customizado" no dropdown.
	 * @returns {HTMLElement} - O elemento div do item.
	 */
	function createAddModelItem() {
		const item       = document.createElement('div')
		item.id          = ADD_MODEL_ITEM_ID
		item.className   = 'model-item add-model-item' // Classe adicional para estilização
		item.textContent = '+ Add Custom Model'
		// Listener de clique: inicia o fluxo para adicionar um novo modelo
		item.addEventListener('click', async (e) => {
			e.stopPropagation() // Impede que feche o dropdown
			hideElement(DROPDOWN_ID) // Esconde o dropdown antes de mostrar os prompts
			await handleAddModel()
		})
		return item
	}

	/**
	 * Mostra ou esconde o dropdown de seleção de modelo.
	 * @param {Event} [e] - O objeto do evento de clique/mousedown (opcional, para stopPropagation).
	 */
	function toggleDropdown(e) {
		if (e) e.stopPropagation() // Impede que o clique feche imediatamente o dropdown
		const dropdown = document.getElementById(DROPDOWN_ID)
		if (dropdown) {
			const isHidden = dropdown.style.display === 'none'
			if (isHidden) {
				// Repopula o dropdown caso modelos tenham sido adicionados/removidos ou para atualizar link de reset
				populateDropdown(dropdown)
				showElement(DROPDOWN_ID)
			} else {
				hideElement(DROPDOWN_ID)
			}
		}
	}

	/**
	 * Fecha o dropdown se o clique ocorrer fora dele ou do botão 'S'.
	 * @param {Event} event - O objeto do evento de clique.
	 */
	function handleOutsideClick(event) {
		const dropdown = document.getElementById(DROPDOWN_ID)
		const button   = document.getElementById(BUTTON_ID)
		// Verifica se o dropdown está visível e se o clique foi fora dele e fora do botão
		if (dropdown && dropdown.style.display !== 'none' &&
				!dropdown.contains(event.target) &&
				!button?.contains(event.target)) { // Verifica se o botão existe
			hideElement(DROPDOWN_ID)
		}
	}

	/**
	 * Exibe o overlay de sumarização com o conteúdo fornecido.
	 * Cria o overlay se ele não existir.
	 * Simplificado: O botão retry apenas chama processSummarization.
	 * @param {string} contentHTML - O conteúdo HTML a ser exibido (pode ser loading, sumário ou erro com retry).
	 * @param {boolean} [isError=false] - Indica se o conteúdo é uma mensagem de erro para adicionar botão de retry.
	 */
	function showSummaryOverlay(contentHTML, isError = false) {
		// Se o overlay já existe, apenas atualiza o conteúdo
		if (document.getElementById(OVERLAY_ID)) {
			updateSummaryOverlay(contentHTML, isError)
			return
		}

		// Cria o elemento do overlay
		const overlay    = document.createElement('div')
		overlay.id       = OVERLAY_ID
		// Define o HTML interno com container, botão de fechar e conteúdo inicial
		let finalContent = `<div id="${CLOSE_BUTTON_ID}" title="Close (Esc)">×</div>${contentHTML}`
		// Adiciona botão de Tentar Novamente se for um erro
		if (isError) {
			finalContent += `<button id="${RETRY_BUTTON_ID}" class="retry-button">Try Again</button>`
		}
		overlay.innerHTML = `<div id="${CONTENT_ID}">${finalContent}</div>`

		document.body.appendChild(overlay)
		document.body.style.overflow = 'hidden' // Trava o scroll do body

		// Adiciona listeners para fechar o overlay
		document.getElementById(CLOSE_BUTTON_ID).addEventListener('click', closeOverlay)
		overlay.addEventListener('click', (e) => { // Fecha clicando no fundo (fora do content)
			if (e.target === overlay) closeOverlay()
		})
		// Adiciona listener para o botão de Tentar Novamente, se existir
		// Apenas chama processSummarization() novamente
		document.getElementById(RETRY_BUTTON_ID)?.addEventListener('click', processSummarization)
	}

	/**
	 * Fecha e remove o overlay de sumarização do DOM. Restaura o scroll do body.
	 */
	function closeOverlay() {
		const overlay = document.getElementById(OVERLAY_ID)
		if (overlay) {
			overlay.remove()
			document.body.style.overflow = '' // Libera o scroll do body
		}
	}

	/**
	 * Atualiza o conteúdo dentro do overlay de sumarização já existente.
	 * Simplificado: O botão retry apenas chama processSummarization.
	 * @param {string} contentHTML - O novo conteúdo HTML.
	 * @param {boolean} [isError=false] - Indica se o conteúdo é uma mensagem de erro para adicionar botão de retry.
	 */
	function updateSummaryOverlay(contentHTML, isError = false) {
		const contentDiv = document.getElementById(CONTENT_ID)
		if (contentDiv) {
			// Recria o conteúdo interno, garantindo que o botão de fechar permaneça
			let finalContent = `<div id="${CLOSE_BUTTON_ID}" title="Close (Esc)">×</div>${contentHTML}`
			// Adiciona botão de Tentar Novamente se for um erro
			if (isError) {
				finalContent += `<button id="${RETRY_BUTTON_ID}" class="retry-button">Try Again</button>`
			}
			contentDiv.innerHTML = finalContent
			// Reatribui o listener ao novo botão de fechar
			document.getElementById(CLOSE_BUTTON_ID)?.addEventListener('click', closeOverlay)
			// Reatribui listener ao botão de Tentar Novamente, se existir
			// Apenas chama processSummarization() novamente
			document.getElementById(RETRY_BUTTON_ID)?.addEventListener('click', processSummarization)
		}
	}

	/**
	 * Exibe uma notificação de erro temporária na parte inferior central da tela.
	 * @param {string} message - A mensagem de erro.
	 */
	function showErrorNotification(message) {
		document.getElementById(ERROR_ID)?.remove() // Remove notificação anterior

		const errorDiv     = document.createElement('div')
		errorDiv.id        = ERROR_ID
		errorDiv.innerText = message
		document.body.appendChild(errorDiv)

		// Remove a notificação após 4 segundos
		setTimeout(() => errorDiv.remove(), 4000)
	}

	/**
	 * Esconde um elemento pelo seu ID, definindo display como 'none'.
	 * @param {string} id - O ID do elemento.
	 */
	function hideElement(id) {
		const el = document.getElementById(id)
		if (el) el.style.display = 'none'
	}

	/**
	 * Mostra um elemento pelo seu ID.
	 * @param {string} id - O ID do elemento.
	 */
	function showElement(id) {
		const el = document.getElementById(id)
		if (el) {
			// Usa 'flex' para o botão e 'block' para os outros por padrão
			el.style.display = (id === BUTTON_ID) ? 'flex' : 'block'
		}
	}

// --- Funções de Lógica (Sumarização, API, Modelos) ---

	/**
	 * Encontra o objeto de configuração completo para o modelo ativo (padrão ou customizado).
	 * @returns {object|null} Um objeto contendo { id, service, name?, params? } ou null se não encontrado.
	 */
	function getActiveModelConfig() {
		for (const service in MODEL_GROUPS) {
			const group       = MODEL_GROUPS[service]
			const modelConfig = group.models.find(m => m.id === activeModel)
			if (modelConfig) {
				// Retorna uma cópia do objeto, adicionando a chave do serviço
				return {...modelConfig, service: service}
			}
		}
		// Verifica modelos customizados
		const customConfig = customModels.find(m => m.id === activeModel)
		if (customConfig) {
			// Custom models não tem 'name' ou 'params' definidos por padrão aqui
			// Retorna uma cópia do objeto customizado { id, service }
			return {...customConfig}
		}
		console.error(`Summarize with AI: Active model configuration not found for ID: ${activeModel}`)
		return null // Modelo não encontrado
	}

	/**
	 * Orquestra o processo de sumarização: obtém API key, mostra overlay de loading com nome do modelo,
	 * envia requisição à API e trata a resposta.
	 */
	async function processSummarization() {
		try {
			// Garante que temos dados do artigo antes de prosseguir
			if (!articleData) {
				showErrorNotification('Article content not found or not readable.')
				return
			}

			const modelConfig = getActiveModelConfig() // Obtém a configuração completa do modelo ativo
			if (!modelConfig) {
				// Mensagem de erro mais informativa se o modelo não for encontrado
				showErrorNotification(`Configuration for model "${activeModel}" not found. Please select another model.`)
				return // Interrompe a execução se a configuração não for encontrada
			}

			// Determina o nome a ser exibido (usa 'name' se disponível, senão 'id')
			const modelDisplayName = modelConfig.name || modelConfig.id
			const service          = modelConfig.service // Determina 'openai' ou 'gemini' a partir da config

			const apiKey = await getApiKey(service) // Obtém a API key (pede ao usuário se não tiver)
			if (!apiKey) { // Aborta se não houver API key
				// Mostra erro no overlay se estiver aberto, senão como notificação
				const errorMsg = `API key for ${service.toUpperCase()} is required. Click the 'Reset Key' link in the model selection menu (long-press 'S' button).`
				if (document.getElementById(OVERLAY_ID)) {
					// Mostra o erro no overlay existente, sem botão de retry para este caso
					updateSummaryOverlay(`<p style="color: red;">${errorMsg}</p>`, false)
				} else {
					// Se o overlay não estava aberto, mostra como notificação
					showErrorNotification(errorMsg)
				}
				return // Interrompe se não houver chave
			}

			// Mostra feedback de loading com o nome do modelo
			// Verifica se o overlay já existe (caso seja um retry)
			const loadingMessage = `<p class="glow">Summarizing with ${modelDisplayName}... </p>`
			if (document.getElementById(OVERLAY_ID)) {
				updateSummaryOverlay(loadingMessage) // Atualiza overlay existente
			} else {
				showSummaryOverlay(loadingMessage) // Cria novo overlay
			}

			// Prepara os dados para a API
			const payload = {title: articleData.title, content: articleData.content, lang: navigator.language || 'en-US'}

			// Passa a configuração do modelo para sendApiRequest
			const response = await sendApiRequest(service, apiKey, payload, modelConfig)

			handleApiResponse(response, service) // Processa a resposta

		} catch (error) {
			// Exibe erros no overlay com botão de Tentar Novamente
			const errorMsg = `Error: ${error.message}`
			console.error('Summarize with AI:', errorMsg, error) // Loga o erro completo
			// Mostra erro no overlay (ou cria um novo se não existir), com botão de retry
			showSummaryOverlay(`<p style="color: red;">${errorMsg}</p>`, true)
			hideElement(DROPDOWN_ID) // Garante que o dropdown esteja oculto em caso de erro
		}
	}

	/**
	 * Envia a requisição HTTP para a API de IA (OpenAI ou Gemini).
	 * @param {string} service - 'openai' ou 'gemini'.
	 * @param {string} apiKey - A chave da API para o serviço.
	 * @param {object} payload - Objeto com { title, content, lang }.
	 * @param {object} modelConfig - Configuração do modelo ativo { id, service, name?, params? }.
	 * @returns {Promise<object>} - A promessa resolve com o objeto de resposta da requisição.
	 */
	async function sendApiRequest(service, apiKey, payload, modelConfig) {
		const group = MODEL_GROUPS[service]
		const url   = service === 'openai'
				? group.baseUrl // URL base da OpenAI
				: `${group.baseUrl}${modelConfig.id}:generateContent?key=${apiKey}` // URL Gemini (inclui ID do modelo e key)

		return new Promise((resolve, reject) => {
			GM.xmlHttpRequest({
				method:  'POST',
				url:     url,
				headers: getHeaders(service, apiKey), // Cabeçalhos específicos do serviço
				// Passa modelConfig para construir o corpo corretamente
				data:         JSON.stringify(buildRequestBody(service, payload, modelConfig)),
				responseType: 'json', // Espera uma resposta JSON
				timeout:      60000, // Timeout de 60 segundos
				onload:       response => {
					// GM.xmlHttpRequest pode retornar response.response em vez de responseText para JSON
					const responseData = response.response || response.responseText
					// Resolve com um objeto contendo status e dados parseados (ou texto original)
					resolve({
						status: response.status,
						// Tenta parsear mesmo que responseType seja json, pois pode falhar
						data:       typeof responseData === 'object' ? responseData : JSON.parse(responseData || '{}'),
						statusText: response.statusText,
					})
				},
				onerror:      error => reject(new Error(`Network error: ${error.statusText || 'Failed to connect'}`)),
				onabort:      () => reject(new Error('Request aborted')),
				ontimeout:    () => reject(new Error('Request timed out')),
			})
		})
	}

	/**
	 * Processa a resposta da API, extrai o sumário, limpa quebras de linha extras e atualiza o overlay.
	 * @param {object} response - O objeto de resposta resolvido da Promise de `sendApiRequest` (contém status, data).
	 * @param {string} service - 'openai' ou 'gemini'.
	 */
	function handleApiResponse(response, service) {
		const {status, data, statusText} = response

		// Verifica se o status HTTP indica sucesso (2xx)
		if (status < 200 || status >= 300) {
			// Tenta extrair uma mensagem de erro mais detalhada do corpo da resposta
			const errorDetails = data?.error?.message || data?.message || statusText || 'Unknown API error' // Gemini pode usar 'message' no erro
			throw new Error(`API Error (${status}): ${errorDetails}`)
		}

		// Extrai o conteúdo do sumário dependendo do serviço
		let rawSummary = ''
		if (service === 'openai') {
			const choice = data?.choices?.[0]
			rawSummary   = choice?.message?.content

			// Loga o motivo pelo qual a geração parou
			const finishReason = choice?.finish_reason
			console.log(`Summarize with AI: OpenAI Finish Reason: ${finishReason} (Model: ${activeModel})`)
			if (finishReason === 'length') {
				console.warn('Summarize with AI: Summary may be incomplete because the max token limit was reached.')
			}

		} else if (service === 'gemini') {
			const candidate    = data?.candidates?.[0]
			const finishReason = candidate?.finishReason
			console.log(`Summarize with AI: Gemini Finish Reason: ${finishReason} (Model: ${activeModel})`)

			if (finishReason === 'SAFETY') {
				const safetyRatings = candidate.safetyRatings?.map(r => `${r.category}: ${r.probability}`).join(', ')
				throw new Error(`Content blocked due to safety concerns (${safetyRatings || 'No details'}).`)
			}
			if (finishReason === 'MAX_TOKENS') {
				console.warn('Summarize with AI: Summary may be incomplete because the max token limit was reached.')
			}

			// Verificação robusta: garante que parts existe e tem conteúdo
			if (candidate?.content?.parts?.length > 0 && candidate.content.parts[0].text) {
				rawSummary = candidate.content.parts[0].text
			} else if (finishReason && !['STOP', 'SAFETY', 'MAX_TOKENS'].includes(finishReason)) {
				// Loga aviso se motivo de finalização não for comum e não houver texto
				console.warn(`Summarize with AI: Gemini response structure missing expected text content or unusual finish reason: ${finishReason}`, candidate)
			} else if (!rawSummary && !data?.error) {
				console.warn('Summarize with AI: Gemini response structure missing expected text content.', candidate)
			}
			// Se rawSummary ainda estiver vazio aqui, o erro "did not contain valid summary" será lançado abaixo
		}

		// Verifica se o sumário foi realmente obtido
		if (!rawSummary && !data?.error) { // Adicionada verificação !data?.error para não sobrescrever erros de API
			console.error('Summarize with AI: API Response Data:', data) // Loga a resposta para depuração
			throw new Error('API response did not contain a valid summary.')
		}

		// Limpa quebras de linha (\n) que não fazem parte de tags HTML (substitui por espaço)
		// e comprime múltiplos espaços em um único espaço.
		// Isso ajuda a evitar espaçamento duplo estranho se a API retornar \n desnecessários.
		const cleanedSummary = rawSummary.replace(/\n/g, ' ').replace(/ {2,}/g, ' ').trim()

		// Atualiza o overlay com o sumário limpo, sem botão de retry
		updateSummaryOverlay(cleanedSummary, false)
	}

	/**
	 * Constrói o corpo (payload) da requisição para a API (OpenAI ou Gemini).
	 * Usa parâmetros definidos no modelConfig ou os padrões do serviço.
	 * @param {string} service - 'openai' ou 'gemini'.
	 * @param {object} payload - Objeto com { title, content, lang }.
	 * @param {object} modelConfig - Configuração do modelo ativo { id, service, name?, params? }.
	 * @returns {object} - O objeto do corpo da requisição.
	 */
	function buildRequestBody(service, {title, content, lang}, modelConfig) {
		const systemPrompt        = PROMPT_TEMPLATE(title, content, lang) // Gera o prompt principal
		const serviceDefaults     = MODEL_GROUPS[service]?.defaultParams || {}
		const modelSpecificParams = modelConfig?.params || {}

		if (service === 'openai') {
			// Mescla parâmetros padrão e específicos do modelo
			const finalParams = {...serviceDefaults, ...modelSpecificParams}

			return {
				model:    modelConfig.id, // Usa o ID do modelo da config
				messages: [
					{role: 'system', content: systemPrompt},
					{role: 'user', content: 'Generate the summary as requested.'},
				],
				// Inclui parâmetros mesclados (ex: max_completion_tokens)
				...finalParams
				// 'temperature' não está definido, usará o padrão da API ou o definido em params
			}
		} else { // gemini
			// Mescla parâmetros padrão e específicos do modelo para generationConfig
			const finalGenConfigParams = {...serviceDefaults, ...modelSpecificParams}

			return {
				contents: [{
					parts: [{text: systemPrompt}], // Estrutura do Gemini
				}],
				// Inclui generationConfig com parâmetros mesclados
				generationConfig: finalGenConfigParams
				// 'temperature' não está definido, usará o padrão da API ou o definido em params
			}
		}
	}

	/**
	 * Retorna os cabeçalhos HTTP apropriados para a API.
	 * @param {string} service - 'openai' ou 'gemini'.
	 * @param {string} apiKey - A chave da API.
	 * @returns {object} - O objeto de cabeçalhos.
	 */
	function getHeaders(service, apiKey) {
		const headers = {'Content-Type': 'application/json'}
		if (service === 'openai') {
			headers['Authorization'] = `Bearer ${apiKey}` // OpenAI usa Bearer token
		}
		// Gemini usa a chave na URL, não no cabeçalho
		return headers
	}

	/**
	 * Obtém a chave da API para o serviço especificado a partir do armazenamento (GM.getValue).
	 * Se não existir, retorna null (a verificação e mensagem de erro ocorrem em processSummarization).
	 * @param {string} service - 'openai' ou 'gemini'.
	 * @returns {Promise<string|null>} - A chave da API ou null se não for encontrada.
	 */
	async function getApiKey(service) {
		const storageKey = `${service}_api_key`
		let apiKey       = await GM.getValue(storageKey)
		// Retorna a chave encontrada ou null se não existir/vazia
		return apiKey?.trim() || null
	}

	/**
	 * Permite ao usuário resetar (redefinir) a chave da API para um serviço específico via prompt.
	 * Ativado pelo link 'Reset Key' no dropdown.
	 * @param {string} service - O serviço ('openai' ou 'gemini') para o qual resetar a chave.
	 */
	async function handleApiKeyReset(service) {
		if (!service || !MODEL_GROUPS[service]) {
			console.error("Invalid service provided for API key reset:", service)
			alert("Internal error: Invalid service provided.")
			return
		}

		const storageKey = `${service}_api_key`
		const newKey     = prompt(`Enter the new ${service.toUpperCase()} API key (leave blank to clear):`)

		if (newKey !== null) { // Verifica se o usuário não cancelou (clicou em OK ou deixou em branco)
			const keyToSave = newKey.trim()
			await GM.setValue(storageKey, keyToSave)
			if (keyToSave) {
				alert(`${service.toUpperCase()} API key updated!`)
			} else {
				alert(`${service.toUpperCase()} API key cleared!`)
			}
			// Opcional: Repopular dropdown para refletir alguma mudança visual se necessário
			// const dropdown = document.getElementById(DROPDOWN_ID)
			// if (dropdown && dropdown.style.display !== 'none') {
			//     populateDropdown(dropdown)
			// }
		}
		// Se newKey for null (usuário clicou Cancelar), não faz nada.
	}

	/**
	 * Gerencia o fluxo para adicionar um novo modelo customizado.
	 * Pede ao usuário o serviço e o ID do modelo via prompts.
	 * Salva no formato { id, service }.
	 */
	async function handleAddModel() {
		// 1. Pergunta o serviço (OpenAI ou Gemini)
		const service = prompt('Enter the service for the custom model (openai / gemini):')?.toLowerCase()?.trim()
		if (!service || !MODEL_GROUPS[service]) {
			if (service !== null) alert('Invalid service. Please enter "openai" or "gemini".')
			return // Cancela se inválido ou se o usuário cancelar
		}

		// 2. Pergunta o nome exato (ID) do modelo
		const modelId = prompt(`Enter the exact ID of the ${service.toUpperCase()} model:`)?.trim()
		if (!modelId) {
			if (modelId !== null) alert('Model ID cannot be empty.')
			return // Cancela se vazio ou se o usuário cancelar
		}

		// 3. Adiciona o modelo e salva
		await addCustomModel(service, modelId)
		// Opcional: reabrir dropdown após adicionar? Por ora, não.
	}

	/**
	 * Adiciona um novo modelo customizado à lista e salva no GM storage.
	 * Atualiza a variável global `customModels`. Salva como { id, service }.
	 * @param {string} service - 'openai' ou 'gemini'.
	 * @param {string} modelId - O ID exato do modelo.
	 */
	async function addCustomModel(service, modelId) {
		// Verifica se o ID do modelo já existe para este serviço (case-insensitive)
		const exists = customModels.some(m => m.service === service && m.id.toLowerCase() === modelId.toLowerCase()) ||
				MODEL_GROUPS[service]?.models.some(m => m.id.toLowerCase() === modelId.toLowerCase()) // Verifica também nos padrões

		if (exists) {
			alert(`Model ID "${modelId}" already exists for ${service.toUpperCase()}.`)
			return
		}

		// Adiciona o novo modelo à lista na memória
		customModels.push({id: modelId, service}) // Salva no formato { id, service }
		// Salva a lista atualizada no storage
		await GM.setValue(CUSTOM_MODELS_KEY, JSON.stringify(customModels))
		alert(`Custom model "${modelId}" (${service.toUpperCase()}) added!`)
	}

	/**
	 * Carrega a lista de modelos customizados salvos no GM storage.
	 * Espera o formato [{ id, service }, ...].
	 * @returns {Promise<Array<object>>} - Uma promessa que resolve com o array de modelos customizados.
	 */
	async function getCustomModels() {
		try {
			const storedModels = await GM.getValue(CUSTOM_MODELS_KEY, '[]') // Obtém a string JSON, default '[]'
			const parsedModels = JSON.parse(storedModels)
			// Validação simples para garantir que é um array de objetos com id e service
			if (Array.isArray(parsedModels) && parsedModels.every(m => typeof m === 'object' && m.id && m.service)) {
				return parsedModels
			} else {
				console.warn("Summarize with AI: Invalid custom model format found in storage. Resetting.", parsedModels)
				await GM.setValue(CUSTOM_MODELS_KEY, '[]') // Reseta se formato inválido
				return []
			}
		} catch (error) {
			console.error('Summarize with AI: Failed to load/parse custom models:', error)
			// Em caso de erro de parse, retorna um array vazio e tenta limpar o storage
			await GM.setValue(CUSTOM_MODELS_KEY, '[]') // Reseta para um array vazio
			return []
		}
	}

// --- Funções de Eventos e Utilidades ---

	/**
	 * Manipulador para o atalho de teclado (Alt+S) e tecla Esc.
	 * Alt+S: Simula clique no botão 'S' (inicia sumarização).
	 * Esc: Fecha o overlay ou o dropdown.
	 * @param {KeyboardEvent} e - O objeto do evento de teclado.
	 */
	function handleKeyPress(e) {
		// Atalho Alt+S para iniciar sumarização (simula clique simples)
		if (e.altKey && e.code === 'KeyS') {
			e.preventDefault()
			const button = document.getElementById(BUTTON_ID)
			if (button) {
				// Chama a função principal de sumarização
				processSummarization()
			}
		}
		// Tecla Esc para fechar overlay ou dropdown
		if (e.key === 'Escape') {
			if (document.getElementById(OVERLAY_ID)) { // Prioriza fechar o overlay
				closeOverlay()
			} else if (document.getElementById(DROPDOWN_ID)?.style.display !== 'none') { // Fecha o dropdown se aberto
				hideElement(DROPDOWN_ID)
			}
		}
	}

	/**
	 * Configura listeners para esconder/mostrar o botão 'S' com base no foco em inputs.
	 */
	function setupFocusListeners() {
		// Esconde o botão quando um campo editável ganha foco
		document.addEventListener('focusin', (event) => {
			if (event.target?.closest('input, textarea, select, [contenteditable="true"]')) {
				hideElement(BUTTON_ID)
				hideElement(DROPDOWN_ID) // Esconde dropdown também
			}
		})

		// Mostra o botão quando o foco sai de um campo editável (clicando fora)
		document.addEventListener('focusout', (event) => {
			// Verifica se o elemento que perdeu o foco é um campo editável
			// e se o novo elemento focado (relatedTarget) NÃO é um campo editável
			const isLeavingInput  = event.target?.closest('input, textarea, select, [contenteditable="true"]')
			const isEnteringInput = event.relatedTarget?.closest('input, textarea, select, [contenteditable="true"]')

			// Só mostra o botão se estiver saindo de um input e não entrando em outro,
			// e se o artigo foi detectado.
			if (isLeavingInput && !isEnteringInput && articleData) {
				// Pequeno delay para evitar piscar se o foco mudar rapidamente entre inputs
				setTimeout(() => {
					// Reconfirma se o foco atual não é um input antes de mostrar
					if (!document.activeElement?.closest('input, textarea, select, [contenteditable="true"]')) {
						showElement(BUTTON_ID)
					}
				}, 50) // Delay de 50ms
			}
		}, true) // Usa captura para garantir que o evento seja pego
	}

	/**
	 * Injeta os estilos CSS necessários para a interface do script.
	 */
	function injectStyles() {
		// Estilos CSS com adições para cores de qualidade e dark mode
		GM.addStyle(`
      /* --- Elementos Principais da UI --- */
      #${BUTTON_ID} {
        position: fixed; bottom: 20px; right: 20px;
        width: 50px; height: 50px; /* Tamanho */
        background: linear-gradient(145deg, #3a7bd5, #00d2ff); /* Gradiente azul */
        color: white; font-size: 24px; /* Texto */
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
        border-radius: 50%; cursor: pointer; z-index: 2147483640;
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
        display: flex !important; align-items: center !important; justify-content: center !important; /* Centraliza 'S' */
        transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
        line-height: 1; user-select: none; /* Previne seleção */
      }
      #${BUTTON_ID}:hover {
        transform: scale(1.1); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25);
      }
      #${DROPDOWN_ID} {
        position: fixed; bottom: 80px; right: 20px; /* Acima do botão */
        background: #ffffff; border: 1px solid #e0e0e0; border-radius: 10px;
        box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15); z-index: 2147483641; /* Acima do botão */
        max-height: 70vh; overflow-y: auto; /* Scroll */
        padding: 8px; width: 300px; /* Dimensões */
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
        display: none; /* Começa oculto */
        animation: fadeIn 0.2s ease-out; /* Animação */
      }
      #${OVERLAY_ID} {
        position: fixed; top: 0; left: 0; width: 100%; height: 100%;
        background-color: rgba(0, 0, 0, 0.6); /* Fundo semi-transparente (padrão light) */
        z-index: 2147483645; /* Muito alto */
        display: flex; align-items: center; justify-content: center;
        overflow: hidden; /* Impede scroll do body */
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
        animation: fadeIn 0.3s ease-out;
      }
      #${CONTENT_ID} {
        background-color: #fff; /* Fundo branco (padrão light) */
        color: #333; /* Texto escuro (padrão light) */
        padding: 25px 35px; border-radius: 12px;
        box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
        max-width: 800px; width: 90%; max-height: 85vh; /* Dimensões */
        overflow-y: auto; /* Scroll interno */
        position: relative; font-size: 16px; line-height: 1.6;
        animation: slideInUp 0.3s ease-out; /* Animação */
        white-space: normal; /* Permite quebra de linha HTML */
      }
      #${CONTENT_ID} p { margin-top: 0; margin-bottom: 1em; } /* Margem padrão para parágrafos */
      #${CONTENT_ID} ul { margin: 1em 0; padding-left: 1.5em; } /* Adiciona padding para bullet points */
      #${CONTENT_ID} li { list-style-type: none; margin-bottom: 0.5em; } /* Remove marcador padrão (usa emoji) */
      #${CLOSE_BUTTON_ID} {
        position: absolute; top: 10px; right: 15px;
        font-size: 28px; color: #aaa; /* Cinza claro (padrão light) */
        cursor: pointer;
        transition: color 0.2s; line-height: 1; z-index: 1; /* Garante que fique acima do conteúdo */
      }
      #${CLOSE_BUTTON_ID}:hover { color: #333; } /* Mais escuro no hover (light) */
      #${ERROR_ID} {
        position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); /* Centralizado */
        background-color: #e53e3e; color: white; padding: 12px 20px;
        border-radius: 6px; z-index: 2147483646; /* Acima de tudo */
        font-size: 14px; box-shadow: 0 2px 8px rgba(0,0,0,0.2);
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
        animation: fadeIn 0.3s, fadeOut 0.3s 3.7s forwards; /* Fade in e out */
      }
      .retry-button { /* Estilo para o botão Tentar Novamente */
        display: block; margin: 20px auto 0; padding: 8px 16px;
        background-color: #4a90e2; /* Azul (padrão light) */
        color: white; border: none; border-radius: 5px;
        cursor: pointer; font-size: 14px; transition: background-color 0.2s;
      }
      .retry-button:hover { background-color: #3a7bd5; } /* Azul mais escuro no hover (light) */

      /* --- Estilos do Dropdown --- */
      .model-group { margin-bottom: 8px; }
      .group-header-container { /* Container para header e link reset */
        display: flex; align-items: center; justify-content: space-between;
        padding: 8px 12px; background: #f7f7f7;
        border-radius: 6px; margin-bottom: 4px;
      }
      .group-header-text { /* Texto do header */
        font-weight: 600; color: #333; font-size: 13px;
        text-transform: uppercase; letter-spacing: 0.5px;
        flex-grow: 1; /* Ocupa espaço disponível */
      }
      .reset-key-link { /* Link de reset */
        font-size: 11px; color: #666; text-decoration: none;
        margin-left: 10px; /* Espaçamento */
        white-space: nowrap; /* Não quebrar linha */
        cursor: pointer;
        transition: color 0.2s;
      }
      .reset-key-link:hover { color: #1a73e8; }
      .model-item {
        padding: 10px 14px; margin: 2px 0; border-radius: 6px;
        transition: background-color 0.15s ease-out, color 0.15s ease-out;
        font-size: 14px; cursor: pointer; color: #444; display: block;
        overflow: hidden; text-overflow: ellipsis; white-space: nowrap; /* Evita quebra de linha em nomes longos */
      }
      .model-item:hover { background-color: #eef6ff; color: #1a73e8; }
      .add-model-item { /* Estilo específico para o item de adicionar modelo */
         color: #666;
         font-style: italic;
      }
      .add-model-item:hover { background-color: #f0f0f0; color: #333; }

      /* --- Estilos de Conteúdo (Glow, Qualidade) --- */
      .glow { /* Estilo para "Summarizing with [Model]..." / "Retrying with [Model]..." */
        font-size: 1.4em; text-align: center; padding: 40px 0;
        /* Aplica a animação 'glow' com ciclo infinito e duração de 2.5s */
        animation: glow 2.5s ease-in-out infinite;
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
        font-weight: 400;
      }
      /* Cores para as classes de qualidade do artigo */
      span.article-excellent { color: #2ecc71; font-weight: bold; } /* Verde brilhante */
      span.article-good      { color: #3498db; font-weight: bold; } /* Azul */
      span.article-average   { color: #f39c12; font-weight: bold; } /* Laranja */
      span.article-bad       { color: #e74c3c; font-weight: bold; } /* Vermelho */
      span.article-very-bad  { color: #c0392b; font-weight: bold; } /* Vermelho escuro */

      /* --- Animações --- */
      /* Define os keyframes para a animação 'glow' ciclando entre azul, roxo e vermelho */
      @keyframes glow {
        0%, 100% { /* Início e Fim: Azul */
          color: #4a90e2;
          text-shadow: 0 0 10px rgba(74, 144, 226, 0.6),
                       0 0 20px rgba(74, 144, 226, 0.4);
        }
        33% { /* Ponto intermediário 1: Roxo */
          color: #9b59b6; /* Tom de roxo */
          text-shadow: 0 0 12px rgba(155, 89, 182, 0.7), /* Sombra roxa */
                       0 0 25px rgba(155, 89, 182, 0.5);
        }
        66% { /* Ponto intermediário 2: Vermelho */
          color: #e74c3c; /* Tom de vermelho */
          text-shadow: 0 0 12px rgba(231, 76, 60, 0.7), /* Sombra vermelha */
                       0 0 25px rgba(231, 76, 60, 0.5);
        }
      }
      @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
      @keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
      @keyframes slideInUp {
         from { transform: translateY(30px); opacity: 0; }
         to { transform: translateY(0); opacity: 1; }
      }

      /* --- Dark Mode Override (Adaptação automática ao tema escuro do sistema) --- */
      @media (prefers-color-scheme: dark) {
        /* Fundo do overlay mais escuro */
        #${OVERLAY_ID} {
          background-color: rgba(20, 20, 20, 0.7); /* Fundo mais opaco e escuro */
        }
        /* Conteúdo do sumário com fundo escuro e texto claro */
        #${CONTENT_ID} {
          background-color: #2c2c2c; /* Cinza bem escuro */
          color: #e0e0e0; /* Texto cinza claro */
          box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4); /* Sombra um pouco mais visível */
        }
        /* Botão de fechar com cores invertidas */
        #${CLOSE_BUTTON_ID} {
          color: #888; /* Cinza médio */
        }
        #${CLOSE_BUTTON_ID}:hover {
          color: #eee; /* Quase branco no hover */
        }
        /* Botão Tentar Novamente com estilo adaptado */
        .retry-button {
          background-color: #555; /* Cinza médio */
          color: #eee; /* Texto claro */
        }
        .retry-button:hover {
          background-color: #666; /* Cinza um pouco mais claro no hover */
        }
        /* Dropdown também pode ter fundo escuro (opcional, mantendo legibilidade) */
        #${DROPDOWN_ID} {
           background: #333; /* Fundo escuro para dropdown */
           border-color: #555; /* Borda mais escura */
        }
        .model-item {
           color: #ccc; /* Texto do item mais claro */
        }
        .model-item:hover {
           background-color: #444; /* Fundo de hover mais escuro */
           color: #fff; /* Texto branco no hover */
        }
        .group-header-container {
           background: #444; /* Fundo do cabeçalho do grupo */
        }
        .group-header-text {
           color: #eee; /* Texto do cabeçalho claro */
        }
        .reset-key-link {
           color: #aaa; /* Link de reset mais claro */
        }
        .reset-key-link:hover {
           color: #fff; /* Link de reset branco no hover */
        }
        .add-model-item {
           color: #999; /* Item de adicionar mais claro */
        }
        .add-model-item:hover {
           background-color: #4a4a4a; /* Fundo de hover */
           color: #eee; /* Texto claro no hover */
        }
        hr {
           border-top-color: #555 !important; /* Separador mais escuro */
        }
        /* Ajuste de cor para o brilho no modo escuro se necessário (opcional) */
        /* As cores atuais do glow parecem funcionar bem, mas podem ser ajustadas aqui */
        /* @keyframes glow-dark { ... } */
        /* .glow { animation-name: glow-dark; } */
      }
    `)
	}

// --- Inicialização ---
	initialize() // Chama a função principal para iniciar o script

})()