AI Prompt Manager (DeepSeek)

Easily manage (save, edit, insert) reusable prompts on DeepSeek Chat. Adds a floating button.

// ==UserScript==
// @name         AI Prompt Manager (DeepSeek)
// @namespace    https://github.com/insign/userscripts
// @version      2025.02.18.1758
// @description  Easily manage (save, edit, insert) reusable prompts on DeepSeek Chat. Adds a floating button.
// @author       Hélio <[email protected]>
// @license      WTFPL
// @match        https://chat.deepseek.com/*
// @grant        GM.setValue
// @grant        GM.getValue
// @grant        GM.addStyle
// ==/UserScript==

(function() {
	'use strict'

	// --- Constantes ---
	const MANAGER_ID = 'ds-prompt-manager-v2' // ID para o container principal do gerenciador
	const BUTTON_ID = 'ds-prompt-button-v2'   // ID para o botão flutuante
	const STORAGE_KEY = 'ds_prompts_v2'      // Chave para armazenar os prompts no GM storage
	const CSS_THEME = '#4D6BFE'              // Cor tema para a interface

	// --- Estado ---
	let prompts = [] // Array para guardar os prompts carregados/salvos

	/**
	 * Inicializa o script: carrega prompts, cria a interface e adiciona listeners.
	 */
	async function initialize() {
		try {
			// Carrega os prompts salvos ou inicializa com array vazio
			const storedPrompts = await GM.getValue(STORAGE_KEY, '[]') // Padrão como string JSON
			try {
				prompts = JSON.parse(storedPrompts)
				// Garante que seja um array, mesmo que o storage esteja corrompido
				if (!Array.isArray(prompts)) {
					console.warn('AI Prompt Manager: Invalid data found in storage, resetting.')
					prompts = []
					await GM.setValue(STORAGE_KEY, JSON.stringify([]))
				}
			} catch (parseError) {
				console.error('AI Prompt Manager: Failed to parse stored prompts, resetting.', parseError)
				prompts = []
				await GM.setValue(STORAGE_KEY, JSON.stringify([])) // Reseta se não conseguir parsear
			}

			// Cria os elementos da interface
			createManagerButton()
			createPromptManager()

			// Configura os listeners de eventos
			setupEventListeners()

			// Preenche a lista de prompts na interface
			refreshPromptList()

			console.log('AI Prompt Manager initialized successfully.')

		} catch (error) {
			console.error('AI Prompt Manager: Initialization failed:', error)
		}
	}

	/**
	 * Cria o botão flutuante (📋) para abrir o gerenciador.
	 */
	function createManagerButton() {
		// Evita criar múltiplos botões
		if (document.getElementById(BUTTON_ID)) return

		// Cria o elemento do botão
		const btn = document.createElement('div')
		btn.id = BUTTON_ID
		btn.innerHTML = '📋' // Ícone de prancheta
		btn.title = 'Open Prompt Manager' // Tooltip

		// Aplica estilos ao botão
		Object.assign(btn.style, {
			position: 'fixed',
			bottom: '85px', // Posição vertical ajustada
			right: '20px',
			width: '45px',
			height: '45px',
			background: CSS_THEME,
			color: 'white',
			borderRadius: '50%',
			cursor: 'pointer',
			display: 'flex',
			alignItems: 'center',
			justifyContent: 'center',
			zIndex: '2147483646', // Z-index alto, mas abaixo do gerenciador
			fontSize: '24px',
			boxShadow: '0 3px 10px rgba(0,0,0,0.25)', // Sombra mais pronunciada
			transition: 'transform 0.2s ease-out, background-color 0.2s ease-out', // Transições suaves
			userSelect: 'none',
		})

		// Efeito hover
		btn.onmouseover = () => { btn.style.transform = 'scale(1.1)'; btn.style.backgroundColor = '#3b5ae0'; }
		btn.onmouseout = () => { btn.style.transform = 'scale(1)'; btn.style.backgroundColor = CSS_THEME; }

		document.body.appendChild(btn)
	}

	/**
	 * Cria o container do gerenciador de prompts (inicialmente oculto).
	 */
	function createPromptManager() {
		// Evita criar múltiplos gerenciadores
		if (document.getElementById(MANAGER_ID)) return

		// Cria o container principal
		const mgr = document.createElement('div')
		mgr.id = MANAGER_ID
		mgr.innerHTML = `
      <div class="ds-pm-header">
        <span>Saved Prompts</span>
        <button class="ds-pm-close-btn" title="Close Manager">×</button>
      </div>
      <div class="ds-pm-prompt-list"></div>
      <button class="ds-pm-add-prompt">+ New Prompt</button>
    `

		// Aplica estilos ao container
		Object.assign(mgr.style, {
			position: 'fixed',
			bottom: '140px', // Acima do botão flutuante
			right: '20px',
			background: 'white',
			borderRadius: '12px',
			boxShadow: '0 8px 24px rgba(0,0,0,0.15)',
			padding: '0', // Padding será interno nos elementos filhos
			width: '320px', // Largura aumentada ligeiramente
			display: 'none', // Começa oculto
			zIndex: '2147483647', // Z-index máximo
			fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
			fontSize: '14px',
			overflow: 'hidden', // Para conter os elementos internos e bordas arredondadas
			border: '1px solid #e0e0e0',
		})

		document.body.appendChild(mgr)

		// Adiciona estilos CSS específicos via GM.addStyle
		addManagerStyles()
	}

	/**
	 * Atualiza a lista de prompts exibida na interface do gerenciador.
	 */
	function refreshPromptList() {
		const list = document.querySelector(`#${MANAGER_ID} .ds-pm-prompt-list`)
		if (!list) return // Sai se a lista não for encontrada

		list.innerHTML = '' // Limpa a lista atual

		if (prompts.length === 0) {
			list.innerHTML = '<div class="ds-pm-no-prompts">No prompts saved yet. Click "+ New Prompt" to add one.</div>'
			return
		}

		// Cria e adiciona um item para cada prompt
		prompts.forEach((prompt, index) => {
			const item = document.createElement('div')
			item.className = 'ds-pm-prompt-item'
			item.title = `Click to insert prompt:\n"${prompt.content.substring(0, 100)}${prompt.content.length > 100 ? '...' : ''}"` // Tooltip com preview
			item.innerHTML = `
              <span class="ds-pm-prompt-title">${prompt.title}</span>
              <div class="ds-pm-prompt-actions">
                  <button class="ds-pm-edit-btn" title="Edit Prompt">✏️</button>
                  <button class="ds-pm-delete-btn" title="Delete Prompt">🗑️</button>
              </div>
            `

			// Listener para deletar
			item.querySelector('.ds-pm-delete-btn').addEventListener('click', (e) => {
				e.stopPropagation() // Impede que o clique no botão acione o clique no item
				deletePrompt(index)
			})

			// Listener para editar
			item.querySelector('.ds-pm-edit-btn').addEventListener('click', (e) => {
				e.stopPropagation()
				editPrompt(index)
			})

			// Listener para inserir o prompt ao clicar no item
			item.addEventListener('click', () => insertPrompt(prompt.content))

			list.appendChild(item)
		})
	}

	/**
	 * Salva o array de prompts atual no armazenamento do GM.
	 */
	async function savePrompts() {
		try {
			await GM.setValue(STORAGE_KEY, JSON.stringify(prompts))
		} catch (error) {
			console.error('AI Prompt Manager: Failed to save prompts:', error)
			alert('Error: Could not save prompts.') // Informa o usuário
		}
	}

	/**
	 * Deleta um prompt do array e atualiza a interface e o armazenamento.
	 * @param {number} index - O índice do prompt a ser deletado.
	 */
	async function deletePrompt(index) {
		// Confirmação antes de deletar
		if (!confirm(`Are you sure you want to delete the prompt "${prompts[index]?.title}"?`)) {
			return
		}
		prompts.splice(index, 1) // Remove o prompt do array
		await savePrompts()      // Salva as alterações
		refreshPromptList()      // Atualiza a lista na interface
	}

	/**
	 * Permite ao usuário editar o título e o conteúdo de um prompt existente.
	 * @param {number} index - O índice do prompt a ser editado.
	 */
	async function editPrompt(index) {
		const promptData = prompts[index]
		if (!promptData) return // Sai se o índice for inválido

		// Pede novo título, mantendo o atual como padrão
		const newTitle = prompt('Edit prompt title:', promptData.title)
		if (newTitle === null) return // Sai se o usuário cancelar

		// Pede novo conteúdo, mantendo o atual como padrão
		const newContent = prompt('Edit prompt content:', promptData.content)
		if (newContent === null) return // Sai se o usuário cancelar

		// Atualiza o prompt no array
		prompts[index] = { title: newTitle.trim() || 'Untitled', content: newContent.trim() }
		await savePrompts()   // Salva as alterações
		refreshPromptList()   // Atualiza a interface
	}

	/**
	 * Insere o conteúdo de um prompt na caixa de texto do chat do DeepSeek.
	 * Tenta manipular o estado do React e o DOM para garantir a inserção correta.
	 * @param {string} content - O conteúdo do prompt a ser inserido.
	 */
	function insertPrompt(content) {
		// Seletores específicos do DeepSeek (podem precisar de atualização se o site mudar)
		const textarea = document.getElementById('chat-input') // O textarea real (pode estar oculto)
		const visibleEditor = document.querySelector('.ds-editor-input-wrapper .ds-md-editor-tiptap') // O editor visível (TipTap/ProseMirror)

		if (!textarea || !visibleEditor) {
			console.error('AI Prompt Manager: Could not find DeepSeek chat input elements.')
			alert('Error: Could not find the chat input field.')
			return
		}

		try {
			// --- Método 1: Simular input no editor visível (mais robusto para editores ricos) ---
			// Foca o editor
			visibleEditor.focus()

			// Cria um evento de input para simular digitação (pode ser necessário para o React detectar)
			// Adiciona o conteúdo + duas quebras de linha no início do valor atual
			const newValue = content + '\n\n' + (textarea.value || '')

			// Tenta usar document.execCommand (pode funcionar em alguns casos)
			// Move o cursor para o início antes de inserir
			const selection = window.getSelection()
			const range = document.createRange()
			range.selectNodeContents(visibleEditor)
			range.collapse(true) // Colapsa para o início
			selection.removeAllRanges()
			selection.addRange(range)
			// Insere o texto (pode não funcionar perfeitamente com React/TipTap)
			// document.execCommand('insertText', false, content + '\n\n') // Comentado - menos confiável

			// --- Método 2: Manipulação direta e disparo de evento (Fallback/Alternativa) ---
			// Define o valor no textarea oculto (React pode ouvir isso)
			textarea.value = newValue

			// Dispara eventos de input e change no textarea para notificar o React
			textarea.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }))
			textarea.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }))

			// Atualiza o conteúdo do editor visível (força a sincronização visual)
			// Encontra o parágrafo inicial ou cria um se não existir
			let firstParagraph = visibleEditor.querySelector('p')
			if (!firstParagraph) {
				firstParagraph = document.createElement('p')
				visibleEditor.appendChild(firstParagraph)
			}
			// Define o conteúdo do primeiro parágrafo
			// Adiciona quebras de linha <br> para simular o parágrafo
			firstParagraph.innerHTML = content.replace(/\n/g, '<br>') + '<br><br>' + firstParagraph.innerHTML


			// --- Método 3: Interagir com a instância do editor TipTap (Avançado, se possível) ---
			// Se houvesse uma forma de acessar a API do TipTap (ex: window.editorInstance),
			// seria o método ideal:
			// if (window.editorInstance) {
			//    window.editorInstance.chain().focus().insertContentAt(0, content + '\n\n').run()
			// }

			console.log('AI Prompt Manager: Prompt inserted.')
			// Fecha o gerenciador após a inserção
			hideManager()

		} catch (error) {
			console.error('AI Prompt Manager: Failed to insert prompt:', error)
			alert('Error: Could not insert the prompt into the chat input.')
		}
	}

	/**
	 * Esconde o painel do gerenciador.
	 */
	function hideManager() {
		const mgr = document.getElementById(MANAGER_ID)
		if (mgr) mgr.style.display = 'none'
	}

	/**
	 * Configura os listeners de eventos para o botão e o gerenciador.
	 */
	function setupEventListeners() {
		// Listener para o botão flutuante: mostra/esconde o gerenciador
		document.getElementById(BUTTON_ID)?.addEventListener('click', (e) => {
			e.stopPropagation() // Impede que o clique feche o gerenciador imediatamente
			const mgr = document.getElementById(MANAGER_ID)
			if (mgr) {
				mgr.style.display = mgr.style.display === 'none' ? 'block' : 'none'
			}
		})

		// Listener para fechar o gerenciador clicando fora dele ou no botão 'x'
		document.addEventListener('click', (e) => {
			const mgr = document.getElementById(MANAGER_ID)
			const btn = document.getElementById(BUTTON_ID)
			// Fecha se o clique foi fora do gerenciador E fora do botão de abrir
			// Ou se foi no botão de fechar dentro do header
			if (mgr && mgr.style.display === 'block') {
				if (e.target.classList.contains('ds-pm-close-btn')) {
					hideManager()
				} else if (!mgr.contains(e.target) && e.target !== btn && !btn.contains(e.target)) {
					hideManager()
				}
			}
		}, true) // Usa captura para pegar o evento antes que outros listeners o parem

		// Listener para o botão "+ New Prompt"
		document.querySelector(`#${MANAGER_ID} .ds-pm-add-prompt`)?.addEventListener('click', async (e) => {
			e.stopPropagation() // Previne fechar o painel
			const title = prompt('Enter prompt title:')
			if (title === null) return // Cancelado
			const content = prompt('Enter prompt content:')
			if (content === null) return // Cancelado

			// Adiciona o novo prompt ao array
			prompts.push({ title: title.trim() || 'Untitled', content: content.trim() })
			await savePrompts() // Salva
			refreshPromptList() // Atualiza a interface
		})
	}

	/**
	 * Adiciona os estilos CSS para o gerenciador usando GM.addStyle.
	 */
	function addManagerStyles() {
		GM.addStyle(`
          #${MANAGER_ID} * { /* Reseta box-sizing para consistência */
             box-sizing: border-box;
          }
          #${MANAGER_ID} .ds-pm-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 10px 15px;
            background: #f7f7f7;
            border-bottom: 1px solid #e0e0e0;
            font-weight: 600;
            color: #333;
            font-size: 15px;
          }
          #${MANAGER_ID} .ds-pm-close-btn {
            background: none;
            border: none;
            font-size: 20px;
            cursor: pointer;
            color: #888;
            padding: 0 5px;
            line-height: 1;
          }
           #${MANAGER_ID} .ds-pm-close-btn:hover {
            color: #000;
           }
          #${MANAGER_ID} .ds-pm-prompt-list {
            max-height: 40vh; /* Limita altura da lista */
            overflow-y: auto; /* Adiciona scroll se necessário */
            padding: 8px;
          }
          #${MANAGER_ID} .ds-pm-no-prompts {
             text-align: center;
             color: #777;
             padding: 20px;
             font-style: italic;
          }
          #${MANAGER_ID} .ds-pm-prompt-item {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 10px 12px;
            margin-bottom: 6px;
            border-radius: 6px;
            cursor: pointer;
            transition: background-color 0.15s ease-out;
            border: 1px solid transparent; /* Para manter o layout no hover */
          }
          #${MANAGER_ID} .ds-pm-prompt-item:hover {
            background-color: #f0f4ff; /* Cor de fundo suave no hover */
            border-color: #d0dfff;
          }
          #${MANAGER_ID} .ds-pm-prompt-title {
            flex-grow: 1;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis; /* Adiciona '...' se o título for longo */
            margin-right: 10px;
            color: #222;
          }
          #${MANAGER_ID} .ds-pm-prompt-actions button {
            background: none;
            border: none;
            padding: 2px 4px; /* Padding ajustado */
            cursor: pointer;
            margin-left: 5px; /* Espaço entre botões */
            opacity: 0.6;
            transition: opacity 0.15s ease-out;
            font-size: 14px; /* Tamanho dos ícones (emojis) */
          }
          #${MANAGER_ID} .ds-pm-prompt-actions button:hover {
            opacity: 1;
          }
          #${MANAGER_ID} .ds-pm-add-prompt {
            display: block; /* Ocupa toda a largura */
            width: calc(100% - 20px); /* Largura ajustada para padding */
            margin: 10px; /* Margem em volta */
            padding: 10px;
            background: ${CSS_THEME};
            color: white;
            border: none;
            border-radius: 6px;
            cursor: pointer;
            font-weight: 500;
            text-align: center;
            font-size: 14px;
            transition: background-color 0.15s ease-out;
          }
          #${MANAGER_ID} .ds-pm-add-prompt:hover {
            background-color: #3b5ae0; /* Cor mais escura no hover */
          }

          /* Estilo da barra de scroll */
           #${MANAGER_ID} .ds-pm-prompt-list::-webkit-scrollbar {
              width: 6px;
            }
            #${MANAGER_ID} .ds-pm-prompt-list::-webkit-scrollbar-track {
              background: #f1f1f1;
              border-radius: 3px;
            }
            #${MANAGER_ID} .ds-pm-prompt-list::-webkit-scrollbar-thumb {
              background: #ccc;
              border-radius: 3px;
            }
            #${MANAGER_ID} .ds-pm-prompt-list::-webkit-scrollbar-thumb:hover {
              background: #aaa;
            }
        `)
	}


	// --- Inicialização ---
	// Espera um pouco para garantir que o DOM do DeepSeek esteja mais estável
	// antes de tentar adicionar elementos e listeners.
	if (document.readyState === 'complete') {
		setTimeout(initialize, 1000)
	} else {
		window.addEventListener('load', () => setTimeout(initialize, 1000))
	}

})();