Icon next to the "Share" button (DeepSeek/ChatGPT) or in the send bar (Claude) to mark which system prompt(s) to add to the first message of each new conversation
// ==UserScript== // @name SyssPrompt // @namespace http://tampermonkey.net/ // @version 0.4 // @description Icon next to the "Share" button (DeepSeek/ChatGPT) or in the send bar (Claude) to mark which system prompt(s) to add to the first message of each new conversation // @author Francisco Vico // @license GPL-3.0 // @homepageURL https://fjvico.github.io // @supportURL mailto:[email protected] // @match https://chat.deepseek.com/* // @match https://chatgpt.com/* // @match https://chat.openai.com/* // @match https://claude.ai/* // @grant GM_setValue // @grant GM_getValue // @run-at document-idle // ==/UserScript== (function () { 'use strict'; // ========================================================= // 0) DETECCIÓN DE SITIO // ========================================================= // De esto dependen: qué botón usamos como "ancla" para colocar el // icono, y en qué dirección se despliega el panel (arriba/abajo). const HOST = location.hostname; let SITE = 'deepseek'; if (HOST.includes('chatgpt.com') || HOST.includes('chat.openai.com')) { SITE = 'chatgpt'; } else if (HOST.includes('claude.ai')) { SITE = 'claude'; } // direction: 'down' -> el panel se despliega hacia abajo (icono arriba, como en DeepSeek/ChatGPT) // direction: 'up' -> el panel se despliega hacia arriba (icono abajo, como en Claude) // fallbackPos: posición de emergencia mientras no se localiza el botón ancla // attachMode: 'inline' -> se intenta enganchar el icono junto a un botón real de la página // 'fixed' -> el icono se queda SIEMPRE en la posición fija (fallbackPos); no se // inserta dentro del DOM de la página. Se usa en Claude porque su barra // del composer centra sus hijos y el icono acababa apareciendo en medio // del ancho en vez de pegado al margen derecho. // inlineOffsetY: desplazamiento vertical extra (px) aplicado SOLO cuando attachMode es 'inline' // y el enganche tuvo éxito (para ChatGPT, 1.5 veces la altura del icono). const ICON_HEIGHT = 28; const SITE_CONFIG = { deepseek: { direction: 'down', fallbackPos: { top: '12px', right: '64px' }, attachMode: 'inline', inlineOffsetY: 0 }, chatgpt: { direction: 'down', fallbackPos: { top: '50px', right: '20px' }, attachMode: 'inline', inlineOffsetY: 0 }, claude: { direction: 'up', fallbackPos: { bottom: '12px', right: '20px' }, attachMode: 'fixed', inlineOffsetY: 0 }, }; const siteConfig = SITE_CONFIG[SITE]; // ========================================================= // 1) IDIOMAS Y PROMPTS (traducidos) — añade tantos como quieras // ========================================================= // Los ids de cada prompt deben ser IGUALES en todos los idiomas (son la // clave que se guarda al marcar/desmarcar); solo cambian "name" y "text". const LANGUAGES = ['ES', 'CA', 'EU', 'GL', 'EN', 'FR', 'DE', 'IT', 'PT', 'RU', 'ZH', 'JA', 'KA']; const PROMPTS_BY_LANG = { ES: [ { id: 'concision', name: 'Sé conciso', text: 'Responde de forma extremadamente concisa, sin rodeos ni explicaciones innecesarias.' }, { id: 'paso_a_paso', name: 'Explica paso a paso', text: 'Explica tu razonamiento paso a paso, de forma clara y detallada, como si se lo explicaras a alguien sin conocimientos previos del tema.' }, { id: 'lenguaje_sencillo', name: 'Lenguaje sencillo', text: 'Usa un lenguaje sencillo y cercano, evitando tecnicismos y jerga innecesaria.' }, { id: 'idioma_respuesta', name: 'Responder en español', text: 'Contesta en español.' }, ], CA: [ { id: 'concision', name: 'Sigues concís', text: 'Respon de manera extremadament concisa, sense rodejos ni explicacions innecessàries.' }, { id: 'paso_a_paso', name: 'Explica pas a pas', text: 'Explica el teu raonament pas a pas, de manera clara i detallada, com si ho expliquessis a algú sense coneixements previs del tema.' }, { id: 'lenguaje_sencillo', name: 'Llenguatge senzill', text: 'Fes servir un llenguatge senzill i proper, evitant tecnicismes i argot innecessari.' }, { id: 'idioma_respuesta', name: 'Respondre en català', text: 'Contesta en català.' }, ], EU: [ { id: 'concision', name: 'Izan zaitez zehatza', text: 'Erantzun oso modu zehatzean, itzulinguruak eta beharrezkoak ez diren azalpenak saihestuz.' }, { id: 'paso_a_paso', name: 'Azaldu urratsez urrats', text: 'Azaldu zure arrazoiketa urratsez urrats, argi eta zehatz, gaiaren aurretiko ezagutzarik ez duen norbaiti azalduko bazenio bezala.' }, { id: 'lenguaje_sencillo', name: 'Hizkuntza erraza', text: 'Erabili hizkuntza erraz eta hurbila, teknizismoak eta beharrezkoa ez den jargoia saihestuz.' }, { id: 'idioma_respuesta', name: 'Euskaraz erantzun', text: 'Erantzun euskaraz.' }, ], GL: [ { id: 'concision', name: 'Sé conciso', text: 'Responde de forma extremadamente concisa, sen rodeos nin explicacións innecesarias.' }, { id: 'paso_a_paso', name: 'Explica paso a paso', text: 'Explica o teu razoamento paso a paso, de forma clara e detallada, coma se llo estiveses a explicar a alguén sen coñecementos previos sobre o tema.' }, { id: 'lenguaje_sencillo', name: 'Linguaxe sinxela', text: 'Usa unha linguaxe sinxela e próxima, evitando tecnicismos e xerga innecesaria.' }, { id: 'idioma_respuesta', name: 'Responder en galego', text: 'Contesta en galego.' }, ], EN: [ { id: 'concision', name: 'Be concise', text: 'Answer extremely concisely, without detours or unnecessary explanations.' }, { id: 'paso_a_paso', name: 'Explain step by step', text: 'Explain your reasoning step by step, clearly and in detail, as if explaining it to someone with no prior knowledge of the topic.' }, { id: 'lenguaje_sencillo', name: 'Plain language', text: 'Use simple, approachable language, avoiding jargon and unnecessary technical terms.' }, { id: 'idioma_respuesta', name: 'Reply in English', text: 'Answer in English.' }, ], FR: [ { id: 'concision', name: 'Sois concis', text: 'Réponds de manière extrêmement concise, sans détours ni explications inutiles.' }, { id: 'paso_a_paso', name: 'Explique étape par étape', text: "Explique ton raisonnement étape par étape, de façon claire et détaillée, comme si tu l'expliquais à quelqu'un sans connaissances préalables du sujet." }, { id: 'lenguaje_sencillo', name: 'Langage simple', text: 'Utilise un langage simple et accessible, en évitant le jargon et les termes techniques inutiles.' }, { id: 'idioma_respuesta', name: 'Répondre en français', text: 'Réponds en français.' }, ], DE: [ { id: 'concision', name: 'Sei prägnant', text: 'Antworte extrem knapp, ohne Umwege oder unnötige Erklärungen.' }, { id: 'paso_a_paso', name: 'Erkläre Schritt für Schritt', text: 'Erkläre deine Überlegungen Schritt für Schritt, klar und detailliert, so als würdest du es jemandem ohne Vorkenntnisse zum Thema erklären.' }, { id: 'lenguaje_sencillo', name: 'Einfache Sprache', text: 'Verwende eine einfache, zugängliche Sprache und vermeide unnötigen Fachjargon.' }, { id: 'idioma_respuesta', name: 'Auf Deutsch antworten', text: 'Antworte auf Deutsch.' }, ], IT: [ { id: 'concision', name: 'Sii conciso', text: 'Rispondi in modo estremamente conciso, senza giri di parole o spiegazioni inutili.' }, { id: 'paso_a_paso', name: 'Spiega passo dopo passo', text: "Spiega il tuo ragionamento passo dopo passo, in modo chiaro e dettagliato, come se lo stessi spiegando a qualcuno senza conoscenze pregresse sull'argomento." }, { id: 'lenguaje_sencillo', name: 'Linguaggio semplice', text: 'Usa un linguaggio semplice e accessibile, evitando tecnicismi e gergo inutile.' }, { id: 'idioma_respuesta', name: 'Rispondere in italiano', text: 'Rispondi in italiano.' }, ], PT: [ { id: 'concision', name: 'Sê conciso', text: 'Responde de forma extremamente concisa, sem rodeios nem explicações desnecessárias.' }, { id: 'paso_a_paso', name: 'Explica passo a passo', text: 'Explica o teu raciocínio passo a passo, de forma clara e detalhada, como se estivesses a explicar a alguém sem conhecimentos prévios sobre o tema.' }, { id: 'lenguaje_sencillo', name: 'Linguagem simples', text: 'Usa uma linguagem simples e próxima, evitando tecnicismos e jargão desnecessário.' }, { id: 'idioma_respuesta', name: 'Responder em português', text: 'Responde em português.' }, ], RU: [ { id: 'concision', name: 'Будь краток', text: 'Отвечай предельно кратко, без лишних отступлений и ненужных объяснений.' }, { id: 'paso_a_paso', name: 'Объясняй пошагово', text: 'Объясняй свои рассуждения шаг за шагом, ясно и подробно, как будто объясняешь человеку, который впервые слышит об этой теме.' }, { id: 'lenguaje_sencillo', name: 'Простой язык', text: 'Используй простой и доступный язык, избегая терминов и ненужного жаргона.' }, { id: 'idioma_respuesta', name: 'Отвечать на русском', text: 'Отвечай на русском языке.' }, ], ZH: [ { id: 'concision', name: '简洁回答', text: '请极其简洁地回答,不要绕圈子,也不要给出不必要的解释。' }, { id: 'paso_a_paso', name: '逐步解释', text: '请一步步清晰详细地解释你的推理过程,就像在向一个完全不了解该主题的人讲解一样。' }, { id: 'lenguaje_sencillo', name: '简单语言', text: '使用简单易懂、贴近日常的语言,避免使用行话和不必要的专业术语。' }, { id: 'idioma_respuesta', name: '用中文回答', text: '请用中文回答。' }, ], JA: [ { id: 'concision', name: '簡潔に答える', text: '回り道や不要な説明をせず、極めて簡潔に答えてください。' }, { id: 'paso_a_paso', name: 'ステップごとに説明', text: 'そのトピックについて予備知識のない人に説明するかのように、あなたの推論をステップごとに明確かつ詳細に説明してください。' }, { id: 'lenguaje_sencillo', name: '平易な言葉', text: '専門用語や不要な業界用語を避け、シンプルで親しみやすい言葉を使ってください。' }, { id: 'idioma_respuesta', name: '日本語で回答', text: '日本語で答えてください。' }, ], KA: [ { id: 'concision', name: 'იყავი ლაკონური', text: 'უპასუხე უკიდურესად მოკლედ, ზედმეტი გადახვევებისა და საჭირო არარსებული განმარტებების გარეშე.' }, { id: 'paso_a_paso', name: 'ახსენი ნაბიჯ-ნაბიჯ', text: 'ახსენი შენი მსჯელობა ნაბიჯ-ნაბიჯ, ნათლად და დეტალურად, თითქოს განუმარტავდი ვინმეს, ვისაც ამ თემის შესახებ წინასწარი ცოდნა არ აქვს.' }, { id: 'lenguaje_sencillo', name: 'მარტივი ენა', text: 'გამოიყენე მარტივი და ხელმისაწვდომი ენა, მოერიდე ჟარგონსა და საჭიროების გარეშე ტექნიკურ ტერმინებს.' }, { id: 'idioma_respuesta', name: 'უპასუხე ქართულად', text: 'უპასუხე ქართულად.' }, ], // -> Añade más objetos aquí (mismo id en cada idioma): { id: 'xxx', name: '...', text: '...' } }; // Textos de la interfaz (nota y plantilla de revocación) por idioma. // activateTemplate(name, text): frase para CADA prompt recién activado. // deactivateTemplate(namesList): frase única para TODOS los prompts recién // desactivados en esta pasada (namesList ya viene formateado como // "Nombre 1", "Nombre 2"). const UI_STRINGS = { ES: { note: 'Si no marcas ninguno, no se añade nada.', title: 'System prompts', namePlaceholder: 'Nombre corto', textPlaceholder: 'Texto de la instrucción...', addButton: '+ Añadir prompt', updateButton: '✎ Actualizar prompt', cancelLabel: 'Cancelar', shareLabel: 'Compartir', shareText: 'SyssPrompt: añade instrucciones a DeepSeek, ChatGPT y Claude con un clic.', copiedLabel: '¡Enlace copiado!', activateTemplate: (name, text) => `A partir de ahora, sigue la regla "${name}": ${text}`, deactivateTemplate: (names) => `Cancela las reglas ${names}. El resto de instrucciones que te he dado siguen vigentes.` }, CA: { note: "Si no en marques cap, no s'afegeix res.", title: 'System prompts', namePlaceholder: 'Nom curt', textPlaceholder: 'Text de la instrucció...', addButton: '+ Afegir prompt', updateButton: '✎ Actualitzar prompt', cancelLabel: "Cancel·lar", shareLabel: 'Compartir', shareText: 'SyssPrompt: afegeix instruccions a DeepSeek, ChatGPT i Claude amb un clic.', copiedLabel: 'Enllaç copiat!', activateTemplate: (name, text) => `A partir d'ara, segueix la regla "${name}": ${text}`, deactivateTemplate: (names) => `Cancel·la les regles ${names}. La resta d'instruccions que t'he donat continuen vigents.` }, EU: { note: 'Bat ere markatzen ez baduzu, ez da ezer gehitzen.', title: 'System prompts', namePlaceholder: 'Izen laburra', textPlaceholder: 'Jarraibidearen testua...', addButton: '+ Prompt bat gehitu', updateButton: '✎ Prompta eguneratu', cancelLabel: 'Utzi', shareLabel: 'Partekatu', shareText: 'SyssPrompt: gehitu jarraibideak DeepSeek, ChatGPT eta Claude-ri klik batean.', copiedLabel: 'Esteka kopiatuta!', activateTemplate: (name, text) => `Hemendik aurrera, jarraitu "${name}" araua: ${text}`, deactivateTemplate: (names) => `Ezeztatu ${names} arauak. Eman dizkizudan gainerako jarraibideek indarrean jarraitzen dute.` }, GL: { note: 'Se non marcas ningún, non se engade nada.', title: 'System prompts', namePlaceholder: 'Nome curto', textPlaceholder: 'Texto da instrución...', addButton: '+ Engadir prompt', updateButton: '✎ Actualizar prompt', cancelLabel: 'Cancelar', shareLabel: 'Compartir', shareText: 'SyssPrompt: engade instrucións a DeepSeek, ChatGPT e Claude cun clic.', copiedLabel: 'Ligazón copiada!', activateTemplate: (name, text) => `A partir de agora, segue a regra "${name}": ${text}`, deactivateTemplate: (names) => `Cancela as regras ${names}. O resto das instrucións que che dei seguen vixentes.` }, EN: { note: "If you don't check any, nothing is added.", title: 'System prompts', namePlaceholder: 'Short name', textPlaceholder: 'Instruction text...', addButton: '+ Add prompt', updateButton: '✎ Update prompt', cancelLabel: 'Cancel', shareLabel: 'Share', shareText: 'SyssPrompt: add instructions to DeepSeek, ChatGPT and Claude with one click.', copiedLabel: 'Link copied!', activateTemplate: (name, text) => `From now on, follow the rule "${name}": ${text}`, deactivateTemplate: (names) => `Cancel the rules ${names}. The rest of the instructions I gave you remain in effect.` }, FR: { note: "Si vous n'en cochez aucun, rien n'est ajouté.", title: 'System prompts', namePlaceholder: 'Nom court', textPlaceholder: "Texte de l'instruction...", addButton: '+ Ajouter un prompt', updateButton: '✎ Mettre à jour le prompt', cancelLabel: 'Annuler', shareLabel: 'Partager', shareText: "SyssPrompt : ajoute des instructions à DeepSeek, ChatGPT et Claude en un clic.", copiedLabel: 'Lien copié !', activateTemplate: (name, text) => `À partir de maintenant, suis la règle "${name}" : ${text}`, deactivateTemplate: (names) => `Annule les règles ${names}. Le reste des instructions que je t'ai données reste en vigueur.` }, DE: { note: 'Wenn du keinen auswählst, wird nichts hinzugefügt.', title: 'System prompts', namePlaceholder: 'Kurzer Name', textPlaceholder: 'Text der Anweisung...', addButton: '+ Prompt hinzufügen', updateButton: '✎ Prompt aktualisieren', cancelLabel: 'Abbrechen', shareLabel: 'Teilen', shareText: 'SyssPrompt: fügt DeepSeek, ChatGPT und Claude mit einem Klick Anweisungen hinzu.', copiedLabel: 'Link kopiert!', activateTemplate: (name, text) => `Ab jetzt befolge die Regel "${name}": ${text}`, deactivateTemplate: (names) => `Widerrufe die Regeln ${names}. Die übrigen Anweisungen, die ich dir gegeben habe, bleiben weiterhin gültig.` }, IT: { note: 'Se non ne selezioni nessuno, non viene aggiunto nulla.', title: 'System prompts', namePlaceholder: 'Nome breve', textPlaceholder: "Testo dell'istruzione...", addButton: '+ Aggiungi prompt', updateButton: '✎ Aggiorna prompt', cancelLabel: 'Annulla', shareLabel: 'Condividi', shareText: 'SyssPrompt: aggiunge istruzioni a DeepSeek, ChatGPT e Claude con un clic.', copiedLabel: 'Link copiato!', activateTemplate: (name, text) => `Da ora in poi, segui la regola "${name}": ${text}`, deactivateTemplate: (names) => `Annulla le regole ${names}. Il resto delle istruzioni che ti ho dato resta valido.` }, PT: { note: 'Se não marcares nenhum, nada é adicionado.', title: 'System prompts', namePlaceholder: 'Nome curto', textPlaceholder: 'Texto da instrução...', addButton: '+ Adicionar prompt', updateButton: '✎ Atualizar prompt', cancelLabel: 'Cancelar', shareLabel: 'Partilhar', shareText: 'SyssPrompt: adiciona instruções ao DeepSeek, ChatGPT e Claude com um clique.', copiedLabel: 'Link copiado!', activateTemplate: (name, text) => `A partir de agora, segue a regra "${name}": ${text}`, deactivateTemplate: (names) => `Cancela as regras ${names}. O resto das instruções que te dei continuam válidas.` }, RU: { note: 'Если ничего не отмечено, ничего не добавляется.', title: 'System prompts', namePlaceholder: 'Короткое название', textPlaceholder: 'Текст инструкции...', addButton: '+ Добавить промпт', updateButton: '✎ Обновить промпт', cancelLabel: 'Отмена', shareLabel: 'Поделиться', shareText: 'SyssPrompt: добавляет инструкции в DeepSeek, ChatGPT и Claude в один клик.', copiedLabel: 'Ссылка скопирована!', activateTemplate: (name, text) => `С этого момента следуй правилу "${name}": ${text}`, deactivateTemplate: (names) => `Отмени правила ${names}. Остальные данные тебе инструкции остаются в силе.` }, ZH: { note: '如果不勾选任何一项,则不会添加任何内容。', title: 'System prompts', namePlaceholder: '简短名称', textPlaceholder: '指令内容...', addButton: '+ 添加提示词', updateButton: '✎ 更新提示词', cancelLabel: '取消', shareLabel: '分享', shareText: 'SyssPrompt:一键为 DeepSeek、ChatGPT 和 Claude 添加指令。', copiedLabel: '链接已复制!', activateTemplate: (name, text) => `从现在起,请遵循规则"${name}":${text}`, deactivateTemplate: (names) => `取消规则${names}。我给你的其他指令仍然有效。` }, JA: { note: '何もチェックしない場合、何も追加されません。', title: 'System prompts', namePlaceholder: '短い名前', textPlaceholder: '指示の内容...', addButton: '+ プロンプトを追加', updateButton: '✎ プロンプトを更新', cancelLabel: 'キャンセル', shareLabel: '共有', shareText: 'SyssPrompt:ワンクリックでDeepSeek、ChatGPT、Claudeに指示を追加。', copiedLabel: 'リンクをコピーしました!', activateTemplate: (name, text) => `これからは「${name}」というルールに従ってください:${text}`, deactivateTemplate: (names) => `ルール${names}を取り消してください。これまでに伝えた他の指示は引き続き有効です。` }, KA: { note: 'თუ არცერთს არ მონიშნავ, არაფერი დაემატება.', title: 'System prompts', namePlaceholder: 'მოკლე სახელი', textPlaceholder: 'ინსტრუქციის ტექსტი...', addButton: '+ პრომპტის დამატება', updateButton: '✎ პრომპტის განახლება', cancelLabel: 'გაუქმება', shareLabel: 'გაზიარება', shareText: 'SyssPrompt: დაამატე ინსტრუქციები DeepSeek-ს, ChatGPT-ს და Claude-ს ერთი დაწკაპუნებით.', copiedLabel: 'ბმული დაკოპირდა!', activateTemplate: (name, text) => `ამიერიდან მიჰყევი წესს "${name}": ${text}`, deactivateTemplate: (names) => `გააუქმე წესები ${names}. დანარჩენი მითითებები, რომლებიც მოგეცი, ძალაში რჩება.` }, }; // Convierte una lista de nombres en 'name1", "name2' formateado para // insertar dentro de deactivateTemplate (que ya pone las comillas del extremo). function quotedNames(names) { return names.map(n => `"${n}"`).join(', '); } // Si este script te resulta útil y quieres apoyar el trabajo detrás de él, // abajo del todo del panel se muestra un pequeño enlace de apoyo (no // intrusivo, no hace ninguna llamada de red ni recoge datos). Si prefieres // no verlo, puedes borrar sin problema el bloque "APOYO" más abajo (busca // "APOYO" en este archivo); el resto del script funciona exactamente igual. const SUPPORT_URL = 'https://www.amazon.es/-/en/Cartas-Alias-pr%C3%B3ximo-inicio-sesi%C3%B3n-ebook/dp/B0GQJMRJ48'; // Página del script en Greasy Fork: la forma más sencilla de instalarlo // (botón de instalación de un clic, detecta si falta el gestor de userscripts). const SITE_URL = 'https://greasyfork.org/en/scripts/593175-syssprompt'; // Iconos de la fila "Compartir": SVG en línea, estilo alambre (solo trazo, // sin relleno), coloreados con currentColor para heredar el color del // texto/hover vía CSS en vez de tener un color fijo dentro del SVG. const SVG_ATTRS = 'viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'; const SHARE_ICONS_SVG = { whatsapp: `<svg ${SVG_ATTRS}><path d="M21 11.5a8.5 8.5 0 0 1-8.5 8.5 8.4 8.4 0 0 1-4.3-1.2L3 20l1.2-5.2A8.5 8.5 0 1 1 21 11.5Z"/></svg>`, telegram: `<svg ${SVG_ATTRS}><path d="M22 2 11 13"/><path d="M22 2 15 22l-4-9-9-4Z"/></svg>`, email: `<svg ${SVG_ATTRS}><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m2 7 10 6 10-6"/></svg>`, link: `<svg ${SVG_ATTRS}><path d="M9 17H7a5 5 0 0 1 0-10h2"/><path d="M15 7h2a5 5 0 0 1 0 10h-2"/><line x1="8" y1="12" x2="16" y2="12"/></svg>`, check: `<svg ${SVG_ATTRS}><path d="M20 6 9 17l-5-5"/></svg>`, nativeShare: `<svg ${SVG_ATTRS}><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.6" y1="10.5" x2="15.4" y2="6.5"/><line x1="8.6" y1="13.5" x2="15.4" y2="17.5"/></svg>`, }; const SUPPORT_TEXT_PRE = 'Buy me a '; const SUPPORT_TEXT_LINK = 'book'; const SUPPORT_TEXT_POST = ' ❤️'; function getPrompts() { const builtIn = PROMPTS_BY_LANG[currentLang] || PROMPTS_BY_LANG.ES; // Los "de fábrica" vienen del código (pueden cambiar en una futura // actualización del script) y son de solo lectura desde el panel; // los personalizados del usuario se leen de almacenamiento // persistente, sobreviven a esas actualizaciones y sí son editables. return builtIn.concat(getCustomPrompts(currentLang)); } function getUIStrings() { return UI_STRINGS[currentLang] || UI_STRINGS.ES; } // Cómo se combinan los prompts marcados entre sí (un único párrafo, separados por espacio) const PROMPT_JOIN = " "; // Cómo se separa el mensaje del usuario del bloque de prompts (una línea en blanco) const SEPARATOR = "\n\n"; // ========================================================= // 2) PERSISTENCIA (qué prompts están marcados) // ========================================================= // Misma clave en los tres sitios: si usas Tampermonkey, la selección de // prompts se comparte entre DeepSeek, ChatGPT y Claude (es el mismo script). const STORAGE_KEY_SELECTED_IDS = 'ds_sysprompt_selected_ids'; const hasGM = typeof GM_getValue === 'function' && typeof GM_setValue === 'function'; function loadValue(key, def) { try { if (hasGM) { const v = GM_getValue(key, undefined); return v === undefined ? def : v; } const v = localStorage.getItem(key); return v === null ? def : JSON.parse(v); } catch (err) { console.error('[SystemPrompt] Error leyendo valor guardado:', err); return def; } } function saveValue(key, value) { try { if (hasGM) { GM_setValue(key, value); } else { localStorage.setItem(key, JSON.stringify(value)); } } catch (err) { console.error('[SystemPrompt] Error guardando valor:', err); } } // Array de ids marcados. Por defecto, ninguno (nada se inyecta hasta que el usuario marque algo). let selectedIds = loadValue(STORAGE_KEY_SELECTED_IDS, []); function isSelected(id) { return selectedIds.includes(id); } function toggleSelected(id, checked) { if (checked && !selectedIds.includes(id)) { selectedIds.push(id); } else if (!checked) { selectedIds = selectedIds.filter(x => x !== id); } saveValue(STORAGE_KEY_SELECTED_IDS, selectedIds); } // ========================================================= // 2.1) PROMPTS PERSONALIZADOS DEL USUARIO // ========================================================= // Los prompts de PROMPTS_BY_LANG viven en el código del script: si algún // día se actualiza el archivo (nueva versión), ese array se sobrescribe // entero. Para que los prompts que el propio usuario añada desde el panel // NO se pierdan al actualizar, se guardan aparte, en almacenamiento // persistente (independiente del código), y se combinan en tiempo de // ejecución con los "de fábrica" en getPrompts(). const STORAGE_KEY_CUSTOM_PROMPTS = 'ai_sysprompt_custom_prompts'; let customPromptsByLang = loadValue(STORAGE_KEY_CUSTOM_PROMPTS, {}); function getCustomPrompts(langCode) { return Array.isArray(customPromptsByLang[langCode]) ? customPromptsByLang[langCode] : []; } function addCustomPrompt(langCode, name, text) { const id = 'custom_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); const list = getCustomPrompts(langCode).concat([{ id, name, text }]); customPromptsByLang = Object.assign({}, customPromptsByLang, { [langCode]: list }); saveValue(STORAGE_KEY_CUSTOM_PROMPTS, customPromptsByLang); return id; } function deleteCustomPrompt(langCode, id) { const list = getCustomPrompts(langCode).filter(p => p.id !== id); customPromptsByLang = Object.assign({}, customPromptsByLang, { [langCode]: list }); saveValue(STORAGE_KEY_CUSTOM_PROMPTS, customPromptsByLang); if (isSelected(id)) toggleSelected(id, false); // si estaba marcado, se desmarca también } function updateCustomPrompt(langCode, id, name, text) { const list = getCustomPrompts(langCode).map(p => (p.id === id ? Object.assign({}, p, { name, text }) : p)); customPromptsByLang = Object.assign({}, customPromptsByLang, { [langCode]: list }); saveValue(STORAGE_KEY_CUSTOM_PROMPTS, customPromptsByLang); } // ========================================================= // 2.1) PERSISTENCIA DEL IDIOMA SELECCIONADO // ========================================================= const STORAGE_KEY_LANG = 'ai_sysprompt_lang'; function detectDefaultLang() { const nav = (navigator.language || 'es').slice(0, 2).toUpperCase(); return LANGUAGES.includes(nav) ? nav : 'ES'; } let currentLang = loadValue(STORAGE_KEY_LANG, detectDefaultLang()); if (!LANGUAGES.includes(currentLang)) currentLang = 'ES'; function setLang(code) { if (!LANGUAGES.includes(code)) return; currentLang = code; saveValue(STORAGE_KEY_LANG, currentLang); } // ========================================================= // 3) INYECCIÓN EN EL CAMPO DE TEXTO // ========================================================= // Guarda los ids de los prompts que estaban activos la última vez que se // tocó el mensaje en ESTE chat. Se resetea a [] al cambiar de conversación. let lastInjectedIds = []; function sameIdSet(a, b) { if (a.length !== b.length) return false; const setB = new Set(b); return a.every(id => setB.has(id)); } // DeepSeek usa un <textarea> normal. ChatGPT y Claude usan un editor // "contenteditable" (tipo ProseMirror) como campo real, pero ADEMÁS // mantienen un <textarea> oculto (fallback de accesibilidad/autofill, // vacío y con display:none) que NO hay que confundir con el campo real. function isVisible(el) { if (!el) return false; const style = window.getComputedStyle(el); if (style.display === 'none' || style.visibility === 'hidden') return false; if (el.offsetWidth === 0 && el.offsetHeight === 0 && style.position !== 'fixed') return false; return true; } function getInputElement() { if (SITE === 'deepseek') { return document.querySelector('textarea'); } // ChatGPT / Claude: priorizar el editor contenteditable visible. const ceCandidates = [ '#prompt-textarea[contenteditable="true"]', 'div[contenteditable="true"].ProseMirror', 'div[contenteditable="true"]', ]; for (const sel of ceCandidates) { const el = document.querySelector(sel); if (el && isVisible(el)) return el; } // Fallback: un <textarea> visible (si el sitio cambia y vuelve a usar uno real) const visibleTextarea = Array.from(document.querySelectorAll('textarea')).find(isVisible); if (visibleTextarea) return visibleTextarea; // Último recurso: lo que haya, aunque no esté marcado como visible return document.querySelector('div[contenteditable="true"]') || document.querySelector('textarea') || null; } function isTextarea(el) { return el && el.tagName === 'TEXTAREA'; } function getInputText(el) { return isTextarea(el) ? el.value : (el.innerText || ''); } function isInputEmpty(el) { return !getInputText(el).trim(); } function setTextareaValue(el, value) { const nativeSetter = Object.getOwnPropertyDescriptor( window.HTMLTextAreaElement.prototype, 'value' ).set; nativeSetter.call(el, value); el.dispatchEvent(new Event('input', { bubbles: true })); // Mantener el scroll y el cursor arriba, para que el texto añadido // (que queda por debajo, tras el mensaje del usuario) no se vea al // insertarlo: no queremos que salte a mostrar el final del contenido. requestAnimationFrame(() => { try { el.setSelectionRange(0, 0); el.scrollTop = 0; } catch (err) { // Algunos navegadores/inputs pueden no soportar setSelectionRange en este momento } }); } // Editores contenteditable (ChatGPT / Claude, tipo ProseMirror) no basta // con tocar el innerHTML: el editor mantiene su propio modelo interno de // documento y, si lo modificamos "a mano" por fuera de su pipeline de // edición, no se entera del cambio y al enviar el mensaje usa su propio // contenido (sin lo que hayamos insertado). Por eso aquí simulamos una // inserción real: colocamos el cursor al final y usamos execCommand // ('insertText'), que dispara los eventos nativos (beforeinput/input) // que estos editores sí escuchan para actualizar su estado interno. function appendToContentEditable(el, addition) { el.focus(); // Cursor al final del contenido actual const range = document.createRange(); range.selectNodeContents(el); range.collapse(false); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); let inserted = false; try { inserted = document.execCommand('insertText', false, addition); } catch (err) { inserted = false; } if (!inserted) { // Fallback si execCommand no está disponible: disparamos los // eventos nativos manualmente tras insertar el texto. el.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'insertText', data: addition })); document.execCommand ? null : (el.textContent += addition); el.dispatchEvent(new InputEvent('input', { bubbles: true, cancelable: true, inputType: 'insertText', data: addition })); } // Ocultar el bloque insertado: devolver el cursor al principio y el // scroll arriba, igual que se hace con el textarea de DeepSeek. requestAnimationFrame(() => { try { const range2 = document.createRange(); range2.selectNodeContents(el); range2.collapse(true); const sel2 = window.getSelection(); sel2.removeAllRanges(); sel2.addRange(range2); el.scrollTop = 0; } catch (err) { // Algunos editores pueden gestionar la selección de otra forma } }); } function tryInject() { const PROMPTS = getPrompts(); const currentIds = PROMPTS.filter(p => isSelected(p.id)).map(p => p.id); console.log('[SystemPrompt] tryInject(). currentIds=', currentIds, 'lastInjectedIds=', lastInjectedIds, 'lang=', currentLang); // Sin cambios respecto a la última vez que se aplicó en este chat -> no tocar nada if (sameIdSet(currentIds, lastInjectedIds)) { console.log('[SystemPrompt] Sin cambios respecto a la última vez, no se inyecta nada.'); return; } const addedIds = currentIds.filter(id => !lastInjectedIds.includes(id)); const removedIds = lastInjectedIds.filter(id => !currentIds.includes(id)); console.log('[SystemPrompt] addedIds=', addedIds, 'removedIds=', removedIds); // Frase por cada prompt recién activado: activateTemplate(nombre, texto) let block = ''; if (addedIds.length > 0) { const ui = getUIStrings(); block = PROMPTS.filter(p => addedIds.includes(p.id)) .map(p => ui.activateTemplate(p.name, p.text)) .join(PROMPT_JOIN); } // Una única frase agrupando TODOS los prompts recién desactivados en esta pasada if (removedIds.length > 0) { const removedNames = PROMPTS.filter(p => removedIds.includes(p.id)).map(p => p.name); if (block) block += ' '; block += getUIStrings().deactivateTemplate(quotedNames(removedNames)); } console.log('[SystemPrompt] Bloque a insertar:', JSON.stringify(block)); if (!block) { lastInjectedIds = currentIds; console.log('[SystemPrompt] Bloque vacío, no hay nada que insertar.'); return; } const input = getInputElement(); console.log('[SystemPrompt] input encontrado:', input, 'valor actual:', input ? JSON.stringify(getInputText(input)) : null); if (!input || isInputEmpty(input)) { console.log('[SystemPrompt] No se inyecta: no hay campo de texto o está vacío.'); return; } // El mensaje del usuario va primero (visible), el bloque de prompts se // añade DESPUÉS, oculto por debajo del scroll. const addition = SEPARATOR + block; if (isTextarea(input)) { setTextareaValue(input, getInputText(input) + addition); } else { appendToContentEditable(input, addition); } lastInjectedIds = currentIds; console.log('[SystemPrompt] Inyectado. Bloque añadido:', JSON.stringify(addition)); } document.addEventListener( 'keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { tryInject(); } }, true ); document.addEventListener( 'click', (e) => { const btn = e.target.closest('button'); if (!btn) return; const label = (btn.getAttribute('aria-label') || btn.textContent || '').toLowerCase(); if (label.includes('send') || label.includes('enviar')) { tryInject(); } }, true ); // Al cambiar de conversación (nueva URL), olvidamos lo aplicado hasta // ahora — pero SOLO si de verdad es una conversación nueva (sin mensajes // todavía). Muchos sitios cambian la URL justo después del primer envío // (para asignarle un ID real a la conversación) sin que sea una // conversación distinta; en ese caso NO hay que resetear. let lastUrl = location.href; setInterval(() => { if (location.href !== lastUrl) { lastUrl = location.href; const bubbles = document.querySelectorAll( '[class*="message"], [data-testid*="message"], [class*="chat-message"], [data-message-author-role], [data-testid*="conversation-turn"]' ); if (bubbles.length === 0) { lastInjectedIds = []; console.log('[SystemPrompt] Nueva conversación detectada, se reinicia el registro.'); } else { console.log('[SystemPrompt] Cambio de URL sin conversación nueva (ya hay mensajes), no se reinicia.'); } } }, 500); // ========================================================= // 4) LOCALIZAR EL BOTÓN "ANCLA" JUNTO AL QUE COLGAR EL ICONO // ========================================================= // - DeepSeek y ChatGPT: el botón "Share" de la barra superior. // - Claude: no hay un "Share" cómodo en la barra del composer, así que // usamos el botón de enviar mensaje (abajo a la derecha), que es lo // que hace que el icono quede "abajo" como pide el usuario. function findShareButtonGeneric() { const candidates = [ 'button[aria-label*="share" i]', 'button[title*="share" i]', '[data-testid*="share" i]', 'a[aria-label*="share" i]', ]; for (const sel of candidates) { const el = document.querySelector(sel); if (el) return el; } const buttons = Array.from(document.querySelectorAll('button, a')); return buttons.find(b => /^(share|compartir)$/i.test((b.textContent || '').trim())) || null; } function findSendButtonClaude() { const candidates = [ 'button[aria-label*="send message" i]', 'button[aria-label*="send" i]', '[data-testid="send-message-button"]', ]; for (const sel of candidates) { const el = document.querySelector(sel); if (el) return el; } return null; } function findAnchorButton() { if (SITE === 'claude') return findSendButtonClaude(); return findShareButtonGeneric(); // deepseek y chatgpt } // ========================================================= // 5) ICONO REDONDO + DESPLEGABLE (Shadow DOM, aislado del CSS del sitio) // ========================================================= const HOST_ID = 'ds-sysprompt-host'; const PANEL_ID = 'ds-sysprompt-panel-host'; let hostEl = null; // icono, se inserta dentro de la barra junto al botón ancla let panelHostEl = null; // panel desplegable, SIEMPRE flotante en <body>, posicionado por JS let panelDropdown = null; let iconEl = null; let langLabelEl = null; // <span> con el código de idioma (ES/EN/...) dentro del icono let openLanguagePicker = null; // se asigna dentro de buildWidget() let checkboxEls = {}; // id -> input function buildWidget() { if (document.getElementById(HOST_ID)) return; // ---------- 1) ICONO ---------- hostEl = document.createElement('div'); hostEl.id = HOST_ID; // Posición de emergencia (SIEMPRE visible) mientras no se localiza el botón ancla. const fp = siteConfig.fallbackPos; let fallbackStyle = 'all: initial !important;' + 'position: fixed !important;' + 'z-index: 2147483647 !important;' + 'pointer-events: auto !important;' + `right: ${fp.right} !important;`; fallbackStyle += fp.top ? `top: ${fp.top} !important;` : `bottom: ${fp.bottom} !important;`; hostEl.setAttribute('style', fallbackStyle); const shadow = hostEl.attachShadow({ mode: 'open' }); const style = document.createElement('style'); style.textContent = ` :host { all: initial; } * { box-sizing: border-box; } .icon { height: ${ICON_HEIGHT}px; padding: 0 10px; border-radius: ${ICON_HEIGHT / 2}px; background: #2a2a2a; border: 1px solid #555; color: #eee; display: flex; align-items: center; justify-content: center; gap: 6px; font-size: 14px; cursor: pointer; user-select: none; box-shadow: 0 1px 4px rgba(0,0,0,0.4); white-space: nowrap; } .icon.active { border-color: #7aa2ff; box-shadow: 0 0 0 2px rgba(122,162,255,0.4); } .icon .gear { font-size: 14px; line-height: 1; } .icon .lang-code { font-size: 11px; font-weight: 700; letter-spacing: 0.5px; color: #cfd8ff; border-left: 1px solid #555; padding-left: 6px; cursor: pointer; } .icon .lang-code:hover { color: #7aa2ff; } `; iconEl = document.createElement('div'); iconEl.className = 'icon'; iconEl.title = getUIStrings().title; const gearSpan = document.createElement('span'); gearSpan.className = 'gear'; gearSpan.textContent = '🐍'; const langSpan = document.createElement('span'); langSpan.className = 'lang-code'; langSpan.textContent = currentLang; langSpan.title = 'Click para elegir idioma / choose language'; langSpan.addEventListener('click', (e) => { e.stopPropagation(); openLanguagePicker(); }); langLabelEl = langSpan; iconEl.appendChild(gearSpan); iconEl.appendChild(langSpan); shadow.appendChild(style); shadow.appendChild(iconEl); document.body.appendChild(hostEl); // ---------- 2) PANEL DESPLEGABLE (independiente, flotante en <body>) ---------- panelHostEl = document.createElement('div'); panelHostEl.id = PANEL_ID; panelHostEl.setAttribute( 'style', 'all: initial !important;' + 'position: fixed !important;' + 'z-index: 2147483647 !important;' + 'pointer-events: auto !important;' + 'display: none !important;' ); const panelShadow = panelHostEl.attachShadow({ mode: 'open' }); const panelStyle = document.createElement('style'); panelStyle.textContent = ` :host { all: initial; } * { box-sizing: border-box; } .dropdown { font-family: system-ui, sans-serif; background: #1e1e1e; border: 1px solid #555; border-radius: 8px; padding: 8px; min-width: 220px; max-width: 280px; box-shadow: 0 4px 16px rgba(0,0,0,0.5); color: #eee; font-size: 12px; } .item { display: flex; align-items: flex-start; gap: 6px; padding: 4px 2px; } .item:hover { background: rgba(255,255,255,0.06); border-radius: 4px; } .item input { margin-top: 2px; cursor: pointer; } .item-label { line-height: 1.3; flex: 1; } .item-label-editable { cursor: pointer; } .item-label-editable:hover { color: #7aa2ff; text-decoration: underline dotted; } .del-btn { color: #999; cursor: pointer; padding: 0 3px; font-size: 14px; line-height: 1; } .del-btn:hover { color: #ff6b6b; } .add-form { margin-top: 6px; padding-top: 6px; border-top: 1px solid #444; display: flex; flex-direction: column; gap: 4px; } .add-form input, .add-form textarea { background: #2a2a2a; border: 1px solid #555; border-radius: 4px; color: #eee; font-family: inherit; font-size: 11px; padding: 4px 6px; resize: vertical; } .form-buttons { display: flex; align-items: center; gap: 8px; } .add-form button { background: #2a2a2a; border: 1px solid #555; border-radius: 4px; color: #cfd8ff; cursor: pointer; font-size: 11px; padding: 4px 6px; } .add-form button:hover { border-color: #7aa2ff; } .cancel-edit { font-size: 11px; color: #999; cursor: pointer; } .cancel-edit:hover { color: #ff6b6b; } .empty-note { margin-top: 4px; padding-top: 6px; border-top: 1px solid #444; font-size: 11px; color: #999; } .support-line { margin-top: 6px; padding-top: 6px; border-top: 1px solid #444; font-size: 11px; color: #999; } .support-line a { color: #7aa2ff; text-decoration: none; } .support-line a:hover { text-decoration: underline; } .share-line { margin-top: 6px; padding-top: 6px; border-top: 1px solid #444; } .share-header { font-size: 11px; color: #999; margin-bottom: 4px; } .share-icons { display: flex; gap: 12px; } .share-icon { font-size: 16px; line-height: 1; color: #cfd8ff; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; transition: transform 0.1s ease, color 0.15s ease; } .share-icon svg { display: block; } .share-icon:hover { color: #7aa2ff; transform: scale(1.2); } .lang-list { display: flex; flex-direction: column; gap: 2px; max-height: 260px; overflow-y: auto; } .lang-item { padding: 6px 8px; border-radius: 4px; cursor: pointer; font-size: 12px; } .lang-item:hover { background: rgba(255,255,255,0.08); } .lang-item.active { color: #7aa2ff; font-weight: 600; } `; panelDropdown = document.createElement('div'); panelDropdown.className = 'dropdown'; renderPanelContents(); panelShadow.appendChild(panelStyle); panelShadow.appendChild(panelDropdown); document.body.appendChild(panelHostEl); // ---------- 3) ABRIR / CERRAR con retraso (evita cierres al mover el cursor) ---------- let closeTimer = null; let pinned = false; // true tras un click, se mantiene abierto hasta click fuera let pickerOpen = false; // true mientras se muestra la lista de idiomas en vez de los prompts function openPanel() { if (closeTimer) { clearTimeout(closeTimer); closeTimer = null; } positionPanel(); panelHostEl.style.setProperty('display', 'block', 'important'); } function scheduleClose() { if (pinned) return; if (closeTimer) clearTimeout(closeTimer); closeTimer = setTimeout(() => { panelHostEl.style.setProperty('display', 'none', 'important'); }, 300); } function cancelClose() { if (closeTimer) { clearTimeout(closeTimer); closeTimer = null; } } // Abre el panel mostrando la lista de idiomas (cada uno en su propio // idioma) en vez de los checkboxes de prompts. Al elegir uno, se // vuelve automáticamente a la vista de prompts. openLanguagePicker = function () { pinned = true; cancelClose(); openPanel(); pickerOpen = true; renderLanguagePicker((code) => { setLang(code); if (langLabelEl) langLabelEl.textContent = currentLang; if (iconEl) iconEl.title = getUIStrings().title; pickerOpen = false; renderPanelContents(); positionPanel(); console.log('[SystemPrompt] Idioma cambiado a', currentLang); }); }; iconEl.addEventListener('mouseenter', () => { cancelClose(); openPanel(); }); iconEl.addEventListener('mouseleave', scheduleClose); panelHostEl.addEventListener('mouseenter', cancelClose); panelHostEl.addEventListener('mouseleave', scheduleClose); iconEl.addEventListener('click', (e) => { e.stopPropagation(); pinned = !pinned; if (pinned) { if (pickerOpen) { pickerOpen = false; renderPanelContents(); } openPanel(); } else { panelHostEl.style.setProperty('display', 'none', 'important'); } }); document.addEventListener('click', (e) => { if (pinned && !panelHostEl.contains(e.target) && e.target !== iconEl) { pinned = false; panelHostEl.style.setProperty('display', 'none', 'important'); } }); updateIconState(); console.log('[SystemPrompt] Widget inyectado. Sitio detectado:', SITE, '- dirección de despliegue:', siteConfig.direction, '- idioma:', currentLang); function updateIconState() { const anySelected = getPrompts().some(p => isSelected(p.id)); iconEl.classList.toggle('active', anySelected); } } // Reconstruye el contenido del panel (checkboxes, nota y línea de apoyo) // con los textos del idioma actualmente seleccionado. El estado marcado // de cada checkbox se conserva porque depende de "selectedIds" (los ids // son iguales en todos los idiomas), no del DOM. function renderPanelContents() { if (!panelDropdown) return; panelDropdown.innerHTML = ''; checkboxEls = {}; const prompts = getPrompts(); const ui = getUIStrings(); // ---------- Formulario para añadir/editar un prompt ---------- // Se declara ANTES de construir la lista para que los prompts puedan // rellenarlo al hacer click en su nombre (modo edición). Se guarda en // almacenamiento persistente (no en el código), así que no se pierde // si el script se actualiza más adelante. const addForm = document.createElement('div'); addForm.className = 'add-form'; const nameInput = document.createElement('input'); nameInput.type = 'text'; nameInput.placeholder = ui.namePlaceholder; nameInput.maxLength = 60; const textInput = document.createElement('textarea'); textInput.placeholder = ui.textPlaceholder; textInput.rows = 2; const formButtons = document.createElement('div'); formButtons.className = 'form-buttons'; const addBtn = document.createElement('button'); addBtn.type = 'button'; addBtn.textContent = ui.addButton; const cancelBtn = document.createElement('span'); cancelBtn.className = 'cancel-edit'; cancelBtn.textContent = ui.cancelLabel; cancelBtn.style.display = 'none'; // id del prompt en edición (null = modo "añadir nuevo") let editingId = null; function exitEditMode() { editingId = null; nameInput.value = ''; textInput.value = ''; addBtn.textContent = ui.addButton; cancelBtn.style.display = 'none'; } function enterEditMode(p) { editingId = p.id; nameInput.value = p.name; textInput.value = p.text; addBtn.textContent = ui.updateButton; cancelBtn.style.display = 'inline'; nameInput.focus(); } addBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const name = nameInput.value.trim(); const text = textInput.value.trim(); if (!name || !text) return; if (editingId) { updateCustomPrompt(currentLang, editingId, name, text); } else { addCustomPrompt(currentLang, name, text); } renderPanelContents(); positionPanel(); }); cancelBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); exitEditMode(); }); formButtons.appendChild(addBtn); formButtons.appendChild(cancelBtn); addForm.appendChild(nameInput); addForm.appendChild(textInput); addForm.appendChild(formButtons); // ---------- Lista de prompts ---------- // La casilla es lo ÚNICO que activa/desactiva el prompt. Al pulsar // sobre el NOMBRE, en cambio, se carga en el formulario de arriba // para poder editarlo (tanto los de fábrica como los personalizados); // "Actualizar prompt" guarda los cambios sobre ese mismo prompt. prompts.forEach(p => { const item = document.createElement('div'); item.className = 'item'; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = isSelected(p.id); cb.addEventListener('change', () => { toggleSelected(p.id, cb.checked); if (iconEl) { const anySelected = getPrompts().some(pp => isSelected(pp.id)); iconEl.classList.toggle('active', anySelected); } }); checkboxEls[p.id] = cb; const label = document.createElement('span'); label.className = 'item-label'; label.textContent = p.name; // Solo los prompts personalizados (id "custom_...") son editables. // Los de fábrica son de solo lectura desde el panel: si quieres // una versión distinta de uno, créate una copia personalizada en // vez de mutar el original en silencio (sin aviso ni "deshacer"). if (p.id.startsWith('custom_')) { label.classList.add('item-label-editable'); label.title = ui.updateButton; label.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); enterEditMode(p); }); } item.appendChild(cb); item.appendChild(label); // Los prompts personalizados (creados por el usuario, id "custom_...") // llevan un botón para borrarlos; los "de fábrica" no se pueden borrar // (pero sí editar, ver arriba). if (p.id.startsWith('custom_')) { const del = document.createElement('span'); del.className = 'del-btn'; del.textContent = '×'; del.title = 'Eliminar este prompt'; del.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); deleteCustomPrompt(currentLang, p.id); renderPanelContents(); positionPanel(); }); item.appendChild(del); } panelDropdown.appendChild(item); }); panelDropdown.appendChild(addForm); const note = document.createElement('div'); note.className = 'empty-note'; note.textContent = ui.note; panelDropdown.appendChild(note); // ---------- Compartir (instalación fácil vía Greasy Fork) ---------- // Enlaces directos a cada app (funcionan en cualquier navegador, sin // depender de que soporte el selector nativo) + copiar enlace, y el // selector nativo del sistema como opción extra si el navegador lo soporta. const shareWrap = document.createElement('div'); shareWrap.className = 'share-line'; const shareHeader = document.createElement('div'); shareHeader.className = 'share-header'; shareHeader.textContent = ui.shareLabel; shareWrap.appendChild(shareHeader); const shareIcons = document.createElement('div'); shareIcons.className = 'share-icons'; function buildShareUrl(kind, text) { const encodedUrl = encodeURIComponent(SITE_URL); const encodedText = encodeURIComponent(text); if (kind === 'whatsapp') return `https://wa.me/?text=${encodedText}%20${encodedUrl}`; if (kind === 'telegram') return `https://t.me/share/url?url=${encodedUrl}&text=${encodedText}`; if (kind === 'email') return `mailto:?subject=${encodeURIComponent('SyssPrompt')}&body=${encodedText}%20${encodedUrl}`; return SITE_URL; } [ { kind: 'whatsapp', svg: SHARE_ICONS_SVG.whatsapp, label: 'WhatsApp' }, { kind: 'telegram', svg: SHARE_ICONS_SVG.telegram, label: 'Telegram' }, { kind: 'email', svg: SHARE_ICONS_SVG.email, label: 'Email' }, ].forEach(({ kind, svg, label }) => { const a = document.createElement('a'); a.className = 'share-icon'; a.href = buildShareUrl(kind, getUIStrings().shareText); a.target = '_blank'; a.rel = 'noopener noreferrer'; a.title = label; a.innerHTML = svg; shareIcons.appendChild(a); }); const copyBtn = document.createElement('span'); copyBtn.className = 'share-icon'; copyBtn.innerHTML = SHARE_ICONS_SVG.link; copyBtn.title = 'Copiar enlace'; copyBtn.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); const uiNow = getUIStrings(); try { await navigator.clipboard.writeText(SITE_URL); copyBtn.innerHTML = SHARE_ICONS_SVG.check; copyBtn.title = uiNow.copiedLabel; setTimeout(() => { copyBtn.innerHTML = SHARE_ICONS_SVG.link; copyBtn.title = 'Copiar enlace'; }, 1500); } catch (err) { window.open(SITE_URL, '_blank', 'noopener,noreferrer'); } }); shareIcons.appendChild(copyBtn); // Selector nativo del sistema (WhatsApp/Telegram/Mail/SMS/lo que tengas // instalado), como opción EXTRA: solo aparece si el navegador lo soporta // de verdad (Chrome/Edge de escritorio, la mayoría de móviles). No // sustituye a los iconos de arriba porque Firefox de escritorio, por // ejemplo, no lo soporta y se quedaría sin ninguna opción. if (navigator.share) { const nativeBtn = document.createElement('span'); nativeBtn.className = 'share-icon'; nativeBtn.innerHTML = SHARE_ICONS_SVG.nativeShare; nativeBtn.title = 'Más opciones'; nativeBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); navigator.share({ title: 'SyssPrompt', text: getUIStrings().shareText, url: SITE_URL }).catch(() => {}); }); shareIcons.appendChild(nativeBtn); } shareWrap.appendChild(shareIcons); panelDropdown.appendChild(shareWrap); // ---------- APOYO (opcional): borra este bloque si no lo quieres ---------- const supportLine = document.createElement('div'); supportLine.className = 'support-line'; supportLine.append(SUPPORT_TEXT_PRE); const supportBookLink = document.createElement('a'); supportBookLink.href = SUPPORT_URL; supportBookLink.target = '_blank'; supportBookLink.rel = 'noopener noreferrer'; supportBookLink.textContent = SUPPORT_TEXT_LINK; supportLine.appendChild(supportBookLink); supportLine.append(SUPPORT_TEXT_POST); panelDropdown.appendChild(supportLine); // ---------- fin bloque APOYO ---------- } // Nombre de cada idioma escrito en sí mismo (para el desplegable de selección). const LANGUAGE_NATIVE_NAMES = { ES: 'Español', CA: 'Català', EU: 'Euskara', GL: 'Galego', EN: 'English', FR: 'Français', DE: 'Deutsch', IT: 'Italiano', PT: 'Português', RU: 'Русский', ZH: '中文', JA: '日本語', KA: 'ქართული', }; // Sustituye el contenido del panel por la lista de idiomas disponibles // (cada uno escrito en su propio idioma). onPick(code) se llama al elegir uno. function renderLanguagePicker(onPick) { if (!panelDropdown) return; panelDropdown.innerHTML = ''; const list = document.createElement('div'); list.className = 'lang-list'; LANGUAGES.forEach((code) => { const item = document.createElement('div'); item.className = 'lang-item' + (code === currentLang ? ' active' : ''); item.textContent = LANGUAGE_NATIVE_NAMES[code] || code; item.addEventListener('click', (e) => { e.stopPropagation(); onPick(code); }); list.appendChild(item); }); panelDropdown.appendChild(list); } // ========================================================= // 5.5) POSICIONAR EL PANEL FLOTANTE, alineado a la derecha del icono // ========================================================= // - direction 'down' (DeepSeek/ChatGPT): el panel cae justo debajo del icono. // - direction 'up' (Claude): el panel sube justo encima del icono, para que // quepa en pantalla al estar el icono abajo del todo (junto al botón de enviar). // En ambos casos el orden de las opciones dentro del panel es el mismo // (de arriba a abajo), solo cambia el punto de anclaje vertical. function positionPanel() { if (!hostEl || !panelHostEl) return; const rect = hostEl.getBoundingClientRect(); const gap = 6; const right = Math.max(4, window.innerWidth - rect.right); panelHostEl.style.setProperty('right', `${right}px`, 'important'); panelHostEl.style.setProperty('left', 'auto', 'important'); if (siteConfig.direction === 'up') { const bottom = Math.max(4, window.innerHeight - rect.top + gap); panelHostEl.style.setProperty('bottom', `${bottom}px`, 'important'); panelHostEl.style.setProperty('top', 'auto', 'important'); } else { const top = rect.bottom + gap; panelHostEl.style.setProperty('top', `${top}px`, 'important'); panelHostEl.style.setProperty('bottom', 'auto', 'important'); } } // ========================================================= // 6) INSERTAR EL ICONO JUNTO AL BOTÓN ANCLA (en el propio DOM de la barra) // ========================================================= // En vez de superponer un elemento con position:fixed (que puede quedar // tapado por elementos de la página con su propia capa de apilamiento), // insertamos el icono como un hermano más del botón ancla, dentro de // su mismo contenedor. Así hereda la misma visibilidad y alineación. // - DeepSeek/ChatGPT: ese contenedor es la barra superior (icono arriba). // - Claude: ese contenedor es la barra del composer (icono abajo). let attachedInline = false; function attachNextToAnchor() { // Claude: el icono se queda SIEMPRE en su posición fija (bottom/right), // nunca se inserta dentro del DOM de la página. Esto evita que la barra // del composer (que centra sus hijos) lo empuje hacia el centro. if (siteConfig.attachMode === 'fixed') return false; if (!hostEl) return false; const anchorBtn = findAnchorButton(); if (!anchorBtn || !anchorBtn.parentElement) return false; // Si ya está correctamente colocado justo antes del botón ancla, no tocar nada if (hostEl.parentElement === anchorBtn.parentElement && hostEl.nextElementSibling === anchorBtn) { return true; } hostEl.style.setProperty('all', 'unset', 'important'); hostEl.style.setProperty('position', 'static', 'important'); hostEl.style.setProperty('display', 'inline-flex', 'important'); hostEl.style.setProperty('align-items', 'center', 'important'); hostEl.style.setProperty('margin-right', '6px', 'important'); if (siteConfig.inlineOffsetY) { hostEl.style.setProperty('margin-top', `${siteConfig.inlineOffsetY}px`, 'important'); } hostEl.style.setProperty('vertical-align', 'middle', 'important'); hostEl.style.setProperty('z-index', '2147483647', 'important'); anchorBtn.parentElement.insertBefore(hostEl, anchorBtn); console.log('[SystemPrompt] Icono insertado junto al botón ancla (', SITE, ').'); return true; } function repositionWidget() { if (!hostEl) return; attachedInline = attachNextToAnchor(); if (!attachedInline && siteConfig.attachMode !== 'fixed') { console.log('[SystemPrompt] Botón ancla no encontrado todavía; icono en posición de emergencia.'); } if (panelHostEl && panelHostEl.style.display === 'block') { positionPanel(); } } // Construir el widget y engancharlo junto al botón ancla en cuanto exista. // Seguimos comprobando indefinidamente (baja frecuencia) porque una SPA puede // re-renderizar esa barra y desenganchar nuestro nodo del DOM. const initInterval = setInterval(() => { try { if (document.documentElement) { buildWidget(); repositionWidget(); // Mientras no hayamos podido engancharlo junto al botón ancla (fallback fijo), // lo reinsertamos como último hijo de <body> para ganar cualquier empate // de z-index frente a overlays que la página añada después. if (!attachedInline && hostEl && hostEl.parentNode === document.body && hostEl.parentNode.lastElementChild !== hostEl) { hostEl.parentNode.appendChild(hostEl); } } } catch (err) { console.error('[SystemPrompt] Error inicializando el widget:', err); } }, 1000); // También reposicionar en scroll/resize, por si el botón ancla se mueve window.addEventListener('resize', repositionWidget, true); window.addEventListener('scroll', repositionWidget, true); })();