// ==UserScript==
// @name Black Russia Forum Enhanced AI Helper (v0.6)
// @namespace http://tampermonkey.net/
// @version 0.8
// @description Modern UI + Context Menu AI Features (Summarize, Rephrase, Grammar) for forum.blackrussia.online using Google Gemini
// @author M. Ageev (Gemini AI)
// @match https://forum.blackrussia.online/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @connect generativelanguage.googleapis.com
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// --- Configuration for Google Gemini API ---
const API_ENDPOINT_BASE = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent'; // Using Flash for speed/cost
const API_METHOD = 'POST';
const STORAGE_KEY = 'br_gemini_api_key_v2'; // Use a new key for potentially different structure needs
// --- State Variables ---
let apiKey = GM_getValue(STORAGE_KEY, '');
let apiStatus = 'Not Configured';
let settingsPanelVisible = false;
let contextMenuVisible = false;
let resultModalVisible = false;
let currentSelection = null; // Store currently selected text info
// --- DOM Elements (created later) ---
let settingsPanel, toggleIcon, contextMenu, resultModal, resultModalContent, resultModalCopyBtn, resultModalCloseBtn;
// --- Helper Functions (API Interaction - slightly modified) ---
function constructApiRequestData(prompt, task = 'generate') {
// Customize safety/generation settings based on task if needed
let generationConfig = {
temperature: 0.7, // Balanced default
maxOutputTokens: 2048,
};
if (task === 'summarize') generationConfig.temperature = 0.5;
if (task === 'rephrase') generationConfig.temperature = 0.8;
if (task === 'grammar') generationConfig.temperature = 0.3; // More deterministic for grammar
return JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: generationConfig,
safetySettings: [
{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_MEDIUM_AND_ABOVE" },
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_MEDIUM_AND_ABOVE" },
{ category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "BLOCK_MEDIUM_AND_ABOVE" },
{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_MEDIUM_AND_ABOVE" }
]
});
}
function parseApiResponse(responseDetails) {
// (Same parsing logic as before - checking candidates, promptFeedback, errors)
try {
const response = JSON.parse(responseDetails.responseText);
if (response.candidates && response.candidates.length > 0 && response.candidates[0].content?.parts?.[0]?.text) {
return response.candidates[0].content.parts[0].text.trim();
} else if (response.promptFeedback?.blockReason) {
const reason = response.promptFeedback.blockReason;
console.warn('Gemini AI Helper: Prompt blocked due to:', reason, response.promptFeedback.safetyRatings);
let blockMessage = `(AI-ответ заблокирован: ${reason})`;
const harmfulRating = response.promptFeedback.safetyRatings?.find(r => r.probability !== 'NEGLIGIBLE' && r.probability !== 'LOW');
if(harmfulRating) blockMessage += ` - Причина: ${harmfulRating.category.replace('HARM_CATEGORY_', '')}`;
return blockMessage;
} else if (response.candidates?.[0]?.finishReason === 'SAFETY') {
console.warn('Gemini AI Helper: Response content blocked due to safety settings.', response.candidates[0].safetyRatings);
return `(AI-ответ заблокирован из-за настроек безопасности)`;
} else {
console.error('Gemini AI Helper: Unexpected Gemini API response format:', response);
// Try to extract Google's error message if available
if (response.error && response.error.message) {
return `(Ошибка API: ${response.error.message})`;
}
return null;
}
} catch (error) {
console.error('Gemini AI Helper: Error parsing Gemini API response:', error, responseDetails.responseText);
try {
const errResponse = JSON.parse(responseDetails.responseText);
if (errResponse.error && errResponse.error.message) return `(Ошибка API: ${errResponse.error.message})`;
} catch (e) { /* Ignore inner parse error */ }
return null;
}
}
function updateStatusDisplay(newStatus, message = '') {
apiStatus = newStatus;
const statusEl = document.getElementById('br-ai-status');
const messageEl = document.getElementById('br-ai-status-message');
if (statusEl) {
let icon = '❓'; // Default: Unknown
if (newStatus === 'Working') icon = '✅'; // Green check
else if (newStatus === 'Error' || newStatus === 'Not Configured') icon = '❌'; // Red cross
else if (newStatus === 'Checking' || newStatus === 'Saving...') icon = '⏳'; // Hourglass
statusEl.innerHTML = `${icon} Статус: ${apiStatus}`; // Use innerHTML for icon
statusEl.className = `status-${apiStatus.toLowerCase().replace(/[^a-z0-9-]/g, '-')}`;
}
if (messageEl) {
messageEl.textContent = message;
messageEl.style.display = message ? 'block' : 'none';
}
}
function saveApiKey() {
const inputEl = document.getElementById('br-ai-api-key-input');
if (inputEl) {
const newApiKey = inputEl.value.trim();
// Basic validation removed for brevity, add back if desired
updateStatusDisplay('Saving...');
GM_setValue(STORAGE_KEY, newApiKey);
apiKey = newApiKey;
console.log('Gemini AI Helper: API Key saved.');
checkApiKey(); // Check the new key
}
}
function checkApiKey(silent = false) { // Add silent option to avoid spamming alerts
return new Promise((resolve) => { // Return a promise to know when check is done
if (!apiKey) {
updateStatusDisplay('Not Configured', 'API ключ не введен.');
return resolve(false);
}
if (!API_ENDPOINT_BASE) {
updateStatusDisplay('Error', 'API Endpoint не настроен в скрипте!');
return resolve(false);
}
updateStatusDisplay('Checking...', silent ? '' : 'Отправка тестового запроса...');
const testPrompt = "Привет! Просто ответь 'OK'.";
const requestData = constructApiRequestData(testPrompt, 'test');
const fullApiUrl = `${API_ENDPOINT_BASE}?key=${apiKey}`;
GM_xmlhttpRequest({
method: API_METHOD, url: fullApiUrl,
headers: { 'Content-Type': 'application/json' },
data: requestData, timeout: 15000,
onload: function(response) {
let success = false;
if (response.status === 200) {
const testResult = parseApiResponse(response);
if (testResult !== null && !testResult.startsWith('(')) { // Check it's not an error/blocked message
updateStatusDisplay('Working', 'API ключ работает.');
success = true;
} else if (testResult !== null) {
updateStatusDisplay('Error', `Ключ принят, но ошибка: ${testResult}`);
} else {
updateStatusDisplay('Error', 'Ключ работает, но не удалось обработать ответ API.');
}
} else {
let errorMsg = `Ошибка API (Статус: ${response.status}). Проверьте ключ.`;
try { const errResp = JSON.parse(response.responseText); if(errResp.error?.message) errorMsg = `Ошибка API: ${errResp.error.message}`; } catch(e){}
updateStatusDisplay('Error', errorMsg);
console.error('Gemini AI Helper: Key check fail - Status:', response.status, response.responseText);
}
resolve(success);
},
onerror: function(response) {
updateStatusDisplay('Error', 'Ошибка сети или CORS. Проверьте @connect.');
console.error('Gemini AI Helper: Key check fail - Network error:', response);
resolve(false);
},
ontimeout: function() {
updateStatusDisplay('Error', 'Тайм-аут запроса к API.');
console.error('Gemini AI Helper: Key check fail - Timeout.');
resolve(false);
}
});
});
}
// --- Core AI Call Function (Modified for Loading Indicator) ---
function callAI(prompt, task = 'generate', loadingIndicatorElement = null) {
return new Promise(async (resolve) => { // Return promise
if (apiStatus !== 'Working') {
// Maybe try a silent check?
const keyOk = await checkApiKey(true); // Silent check
if (!keyOk) {
alert('Gemini AI Helper: API не настроен или не работает. Проверьте настройки.');
if (loadingIndicatorElement) loadingIndicatorElement.disabled = false;
return resolve(null); // Resolve with null on failure
}
// If check passed, status is now 'Working', continue
}
if (!prompt) {
alert('Gemini AI Helper: Нет текста для отправки AI.');
if (loadingIndicatorElement) loadingIndicatorElement.disabled = false;
return resolve(null);
}
console.log(`Gemini AI Helper: Calling Gemini for task '${task}'`);
if (loadingIndicatorElement) loadingIndicatorElement.disabled = true; // Disable button/element
const requestData = constructApiRequestData(prompt, task);
const fullApiUrl = `${API_ENDPOINT_BASE}?key=${apiKey}`;
GM_xmlhttpRequest({
method: API_METHOD, url: fullApiUrl,
headers: { 'Content-Type': 'application/json' },
data: requestData, timeout: 90000, // Increased timeout for potentially longer tasks
onload: function(response) {
let result = null;
if (response.status === 200) {
result = parseApiResponse(response);
if (result === null) { // Explicit parse error
alert('Gemini AI Helper: Ошибка обработки ответа от AI.');
}
} else {
let errorMsg = `Ошибка API (Статус: ${response.status}).`;
try { const errResp = JSON.parse(response.responseText); if(errResp.error?.message) errorMsg = `Ошибка API: ${errResp.error.message}`; } catch(e){}
alert(`Gemini AI Helper: ${errorMsg}`);
console.error('Gemini AI Helper: AI call fail - Status:', response.status, response.responseText);
result = `(Сетевая ошибка: ${response.status})`; // Return error info
}
if (loadingIndicatorElement) loadingIndicatorElement.disabled = false;
resolve(result); // Resolve promise with result (string or null)
},
onerror: function(response) {
alert('Gemini AI Helper: Ошибка сети при вызове AI.');
console.error('Gemini AI Helper: AI call fail - Network error:', response);
if (loadingIndicatorElement) loadingIndicatorElement.disabled = false;
resolve('(Сетевая ошибка)');
},
ontimeout: function() {
alert('Gemini AI Helper: Тайм-аут при вызове AI.');
console.error('Gemini AI Helper: AI call fail - Timeout.');
if (loadingIndicatorElement) loadingIndicatorElement.disabled = false;
resolve('(Тайм-аут)');
},
});
});
}
// --- UI Creation ---
/** Inject CSS */
function injectStyles() {
GM_addStyle(`
/* --- CSS Variables (Theme) --- */
:root {
--ai-bg-primary: #2d2d2d;
--ai-bg-secondary: #3a3a3a;
--ai-bg-tertiary: #454545;
--ai-text-primary: #e0e0e0;
--ai-text-secondary: #b0b0b0;
--ai-accent-primary: #00aaff; /* Bright blue */
--ai-accent-secondary: #00cfaa; /* Teal */
--ai-success: #4CAF50;
--ai-error: #F44336;
--ai-warning: #FFC107;
--ai-info: #2196F3;
--ai-border-color: #555555;
--ai-border-radius: 6px;
--ai-font-family: 'Roboto', 'Segoe UI', sans-serif; /* Modern font stack */
--ai-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
--ai-transition: all 0.25s ease-in-out;
}
/* --- Toggle Icon --- */
#br-ai-toggle-icon {
position: fixed; bottom: 25px; right: 25px; width: 50px; height: 50px;
background: linear-gradient(135deg, var(--ai-accent-primary), var(--ai-accent-secondary));
color: white; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 26px; cursor: pointer; z-index: 10000;
box-shadow: var(--ai-shadow); user-select: none;
transition: var(--ai-transition), transform 0.15s ease;
border: none;
}
#br-ai-toggle-icon:hover { transform: scale(1.1); box-shadow: 0 6px 16px rgba(0, 180, 220, 0.4); }
#br-ai-toggle-icon:active { transform: scale(0.95); }
/* --- Settings Panel --- */
#br-ai-settings-panel {
position: fixed; bottom: 90px; right: 25px; width: 350px;
background-color: var(--ai-bg-primary); color: var(--ai-text-primary);
border: 1px solid var(--ai-border-color); border-radius: var(--ai-border-radius);
padding: 0; /* Remove padding, handle inside */
z-index: 10001; box-shadow: var(--ai-shadow);
font-family: var(--ai-font-family); font-size: 14px;
overflow: hidden; /* Needed for border-radius with tabs */
transform: translateY(10px) scale(0.98); opacity: 0; /* Initial state for transition */
transition: var(--ai-transition), transform 0.2s ease, opacity 0.2s ease;
display: none; /* Controlled by JS */
}
#br-ai-settings-panel.visible {
display: block;
transform: translateY(0) scale(1);
opacity: 1;
}
/* Panel Tabs */
.br-ai-panel-tabs {
display: flex;
background-color: var(--ai-bg-secondary);
border-bottom: 1px solid var(--ai-border-color);
}
.br-ai-panel-tab {
flex: 1;
padding: 12px 15px;
text-align: center;
cursor: pointer;
color: var(--ai-text-secondary);
border-bottom: 3px solid transparent;
transition: var(--ai-transition);
font-weight: 500;
}
.br-ai-panel-tab:hover { background-color: var(--ai-bg-tertiary); color: var(--ai-text-primary); }
.br-ai-panel-tab.active {
color: var(--ai-text-primary);
border-bottom-color: var(--ai-accent-primary);
}
/* Panel Content Area */
.br-ai-panel-content { padding: 20px; }
.br-ai-tab-pane { display: none; }
.br-ai-tab-pane.active { display: block; }
/* Form Elements */
#br-ai-settings-panel h3 { margin-top: 0; margin-bottom: 15px; font-size: 18px; font-weight: 500; color: var(--ai-text-primary); padding-bottom: 8px; border-bottom: 1px solid var(--ai-border-color); }
#br-ai-settings-panel label { display: block; margin-bottom: 6px; font-weight: 500; color: var(--ai-text-secondary); }
#br-ai-api-key-input {
width: 100%;
padding: 10px 12px;
margin-bottom: 15px;
background-color: var(--ai-bg-tertiary);
color: var(--ai-text-primary);
border: 1px solid var(--ai-border-color);
border-radius: var(--ai-border-radius);
font-size: 14px;
box-sizing: border-box; /* Include padding in width */
transition: var(--ai-transition);
}
#br-ai-api-key-input:focus { border-color: var(--ai-accent-primary); box-shadow: 0 0 0 2px rgba(0, 170, 255, 0.3); outline: none; }
.br-ai-button-group { display: flex; gap: 10px; margin-top: 10px; }
#br-ai-settings-panel button {
flex-grow: 1; /* Make buttons fill space */
padding: 10px 15px;
font-size: 14px;
font-weight: 500;
border: none; border-radius: var(--ai-border-radius);
cursor: pointer; transition: var(--ai-transition);
display: flex; align-items: center; justify-content: center; gap: 5px;
}
#br-ai-save-key { background-color: var(--ai-success); color: white; }
#br-ai-test-key { background-color: var(--ai-info); color: white; }
#br-ai-settings-panel button:hover { filter: brightness(1.1); }
#br-ai-settings-panel button:active { filter: brightness(0.9); transform: scale(0.98); }
#br-ai-settings-panel button:disabled { background-color: var(--ai-bg-tertiary); color: var(--ai-text-secondary); cursor: not-allowed; }
/* Status Display */
#br-ai-status { margin-top: 20px; font-weight: 500; display: flex; align-items: center; gap: 8px; }
#br-ai-status-message { margin-top: 8px; font-size: 13px; color: var(--ai-text-secondary); word-wrap: break-word; line-height: 1.4; }
/* Status colors */
.status-not-configured, .status-error { color: var(--ai-error); }
.status-checking, .status-saving { color: var(--ai-warning); }
.status-working { color: var(--ai-success); }
/* About Tab */
#br-ai-about p { margin-bottom: 10px; line-height: 1.5; color: var(--ai-text-secondary); }
#br-ai-about strong { color: var(--ai-text-primary); }
#br-ai-about a { color: var(--ai-accent-primary); text-decoration: none; }
#br-ai-about a:hover { text-decoration: underline; }
#br-ai-about ul { list-style: disc; padding-left: 25px; margin-top: 10px;}
#br-ai-about li { margin-bottom: 5px;}
/* --- Context Menu --- */
#br-ai-context-menu {
position: absolute; /* Positioned by JS */
min-width: 180px;
background-color: var(--ai-bg-secondary);
border: 1px solid var(--ai-border-color);
border-radius: var(--ai-border-radius);
box-shadow: var(--ai-shadow);
z-index: 10005; /* Above panel */
padding: 5px 0;
font-family: var(--ai-font-family);
font-size: 14px;
opacity: 0; /* Start hidden */
transform: scale(0.95);
transition: opacity 0.15s ease, transform 0.15s ease;
display: none; /* Controlled by JS */
}
#br-ai-context-menu.visible { display: block; opacity: 1; transform: scale(1); }
.br-ai-context-menu-item {
display: flex; /* Use flex for icon alignment */
align-items: center;
gap: 8px;
padding: 8px 15px;
color: var(--ai-text-primary);
cursor: pointer;
transition: background-color 0.15s ease;
}
.br-ai-context-menu-item:hover { background-color: var(--ai-bg-tertiary); }
.br-ai-context-menu-item span { flex-grow: 1; } /* Text takes remaining space */
.br-ai-context-menu-item i { /* Basic icon styling */
font-style: normal;
width: 1.2em; /* Ensure consistent icon space */
text-align: center;
color: var(--ai-accent-secondary);
}
/* --- Result Modal --- */
#br-ai-result-modal {
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%) scale(0.95);
width: 90%; max-width: 600px;
background-color: var(--ai-bg-primary);
color: var(--ai-text-primary);
border: 1px solid var(--ai-border-color);
border-radius: var(--ai-border-radius);
box-shadow: var(--ai-shadow);
z-index: 10010; /* Above context menu */
font-family: var(--ai-font-family);
opacity: 0;
transition: opacity 0.25s ease, transform 0.25s ease;
display: none; /* Controlled by JS */
}
#br-ai-result-modal.visible { display: block; opacity: 1; transform: translate(-50%, -50%) scale(1); }
.br-ai-modal-header {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 15px;
background-color: var(--ai-bg-secondary);
border-bottom: 1px solid var(--ai-border-color);
border-top-left-radius: var(--ai-border-radius); /* Match parent */
border-top-right-radius: var(--ai-border-radius);
}
.br-ai-modal-header h4 { margin: 0; font-size: 16px; font-weight: 500; }
#br-ai-result-modal-close { background: none; border: none; color: var(--ai-text-secondary); font-size: 20px; cursor: pointer; padding: 0 5px; transition: color 0.15s ease; }
#br-ai-result-modal-close:hover { color: var(--ai-text-primary); }
.br-ai-modal-content {
padding: 15px;
max-height: 60vh; /* Limit height and allow scrolling */
overflow-y: auto;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap; /* Preserve whitespace and newlines */
background-color: var(--ai-bg-tertiary); /* Slightly different bg for content */
margin: 15px; /* Add margin around content */
border-radius: 4px;
}
/* Style scrollbar for modal content */
.br-ai-modal-content::-webkit-scrollbar { width: 8px; }
.br-ai-modal-content::-webkit-scrollbar-track { background: var(--ai-bg-secondary); border-radius: 4px; }
.br-ai-modal-content::-webkit-scrollbar-thumb { background: var(--ai-border-color); border-radius: 4px; }
.br-ai-modal-content::-webkit-scrollbar-thumb:hover { background: #6b6b6b; }
.br-ai-modal-footer {
padding: 10px 15px;
text-align: right;
border-top: 1px solid var(--ai-border-color);
}
#br-ai-result-modal-copy {
padding: 8px 15px;
background-color: var(--ai-accent-primary);
color: white; border: none;
border-radius: var(--ai-border-radius);
cursor: pointer; font-size: 14px; font-weight: 500;
transition: var(--ai-transition);
}
#br-ai-result-modal-copy:hover { filter: brightness(1.1); }
#br-ai-result-modal-copy:active { filter: brightness(0.9); }
/* Editor Button Styling (from previous version, maybe adjusted) */
.br-ai-editor-button {
margin-left: 8px; padding: 5px 10px !important; font-size: 13px !important;
line-height: 1.5 !important; cursor: pointer; background-color: var(--ai-accent-secondary); /* Teal */
color: white; border: none; border-radius: 4px; transition: background-color 0.2s ease;
display: inline-flex; align-items: center; gap: 4px; /* Align icon/text */
}
.br-ai-editor-button:hover { filter: brightness(1.1); }
.br-ai-editor-button:disabled { background-color: #aaa; cursor: not-allowed; filter: grayscale(50%); }
`);
}
/** Creates the main settings panel with tabs */
function createSettingsPanel() {
// --- Toggle Icon ---
toggleIcon = document.createElement('div');
toggleIcon.id = 'br-ai-toggle-icon';
toggleIcon.innerHTML = '✨'; // Sparkles icon
toggleIcon.title = 'Настройки Gemini AI Помощника';
document.body.appendChild(toggleIcon);
// --- Settings Panel ---
settingsPanel = document.createElement('div');
settingsPanel.id = 'br-ai-settings-panel';
settingsPanel.innerHTML = `
<div class="br-ai-panel-tabs">
<div class="br-ai-panel-tab active" data-tab="settings">Настройки</div>
<div class="br-ai-panel-tab" data-tab="about">О скрипте</div>
</div>
<div class="br-ai-panel-content">
<div class="br-ai-tab-pane active" id="br-ai-settings">
<h3>Настройки Gemini AI</h3>
<p style="font-size:11px; color:var(--ai-warning); margin-bottom:10px;"><b>Важно:</b> Никогда не делитесь API ключом! <a href="https://aistudio.google.com/app/apikey" target="_blank" style="color:var(--ai-accent-primary);">Управлять ключами</a></p>
<label for="br-ai-api-key-input">Ваш Google AI API Ключ:</label>
<input type="password" id="br-ai-api-key-input" placeholder="Введите ваш API ключ (начинается с AIza...)">
<div class="br-ai-button-group">
<button id="br-ai-save-key" title="Сохранить ключ и проверить">💾 Сохранить</button>
<button id="br-ai-test-key" title="Проверить текущий сохраненный ключ">📡 Проверить</button>
</div>
<div id="br-ai-status">Статус: Инициализация...</div>
<div id="br-ai-status-message"></div>
</div>
<div class="br-ai-tab-pane" id="br-ai-about">
<h3>О скрипте Enhanced AI Helper</h3>
<p><strong>Версия:</strong> 0.6</p>
<p>Этот скрипт добавляет функции Google Gemini AI на форум Black Russia:</p>
<ul>
<li>Генерация текста в редакторе (кнопка ✨ AI).</li>
<li>Контекстное меню (при выделении текста):
<ul>
<li>📝 Суммаризировать</li>
<li>🔄 Перефразировать</li>
<li>✅ Исправить грамматику</li>
</ul>
</li>
<li>Современная панель настроек API ключа.</li>
</ul>
<p>Создано с помощью AI и адаптировано.</p>
<p><strong>Внимание:</strong> Использование API может быть платным. Ответственность за использование ключа лежит на вас.</p>
</div>
</div>
`;
document.body.appendChild(settingsPanel);
// --- Event Listeners ---
toggleIcon.addEventListener('click', toggleSettingsPanel);
// Tab switching logic
settingsPanel.querySelectorAll('.br-ai-panel-tab').forEach(tab => {
tab.addEventListener('click', () => {
settingsPanel.querySelector('.br-ai-panel-tab.active').classList.remove('active');
settingsPanel.querySelector('.br-ai-tab-pane.active').classList.remove('active');
tab.classList.add('active');
settingsPanel.querySelector(`#br-ai-${tab.dataset.tab}`).classList.add('active');
});
});
// Button listeners
document.getElementById('br-ai-save-key').addEventListener('click', saveApiKey);
document.getElementById('br-ai-test-key').addEventListener('click', () => checkApiKey()); // Non-silent check on button press
// Load saved key
document.getElementById('br-ai-api-key-input').value = apiKey;
}
/** Toggles the settings panel visibility */
function toggleSettingsPanel() {
settingsPanelVisible = !settingsPanelVisible;
if (settingsPanelVisible) {
settingsPanel.classList.add('visible');
// Refresh status on open
updateStatusDisplay(apiStatus, document.getElementById('br-ai-status-message')?.textContent || '');
} else {
settingsPanel.classList.remove('visible');
}
}
/** Creates the custom context menu (initially hidden) */
function createContextMenu() {
contextMenu = document.createElement('div');
contextMenu.id = 'br-ai-context-menu';
contextMenu.innerHTML = `
<div class="br-ai-context-menu-item" data-action="summarize"><i>📝</i> <span>Суммаризировать</span></div>
<div class="br-ai-context-menu-item" data-action="rephrase"><i>🔄</i> <span>Перефразировать</span></div>
<div class="br-ai-context-menu-item" data-action="grammar"><i>✅</i> <span>Исправить грамматику</span></div>
`;
document.body.appendChild(contextMenu);
// Add listener for menu item clicks
contextMenu.addEventListener('click', handleContextMenuAction);
}
/** Creates the result modal (initially hidden) */
function createResultModal() {
resultModal = document.createElement('div');
resultModal.id = 'br-ai-result-modal';
resultModal.innerHTML = `
<div class="br-ai-modal-header">
<h4>Результат AI</h4>
<button id="br-ai-result-modal-close" title="Закрыть">×</button>
</div>
<div class="br-ai-modal-content" id="br-ai-result-modal-content">
Загрузка...
</div>
<div class="br-ai-modal-footer">
<button id="br-ai-result-modal-copy" title="Копировать результат">📋 Копировать</button>
</div>
`;
document.body.appendChild(resultModal);
resultModalContent = document.getElementById('br-ai-result-modal-content');
resultModalCopyBtn = document.getElementById('br-ai-result-modal-copy');
resultModalCloseBtn = document.getElementById('br-ai-result-modal-close');
resultModalCloseBtn.addEventListener('click', hideResultModal);
resultModalCopyBtn.addEventListener('click', copyModalContent);
// Optional: Close modal on clicking outside
resultModal.addEventListener('click', (e) => {
if (e.target === resultModal) { // Check if click is on the backdrop itself
hideResultModal();
}
});
}
/** Shows the custom context menu */
function showContextMenu(x, y) {
// Hide any previous instances immediately
hideContextMenu();
hideResultModal(); // Also hide modal if open
contextMenu.style.left = `${x}px`;
contextMenu.style.top = `${y}px`;
contextMenu.classList.add('visible');
contextMenuVisible = true;
}
/** Hides the custom context menu */
function hideContextMenu() {
if (contextMenu) {
contextMenu.classList.remove('visible');
}
contextMenuVisible = false;
}
/** Shows the result modal with content */
function showResultModal(content, title = "Результат AI") {
hideContextMenu(); // Hide context menu when showing modal
if (!resultModal) createResultModal(); // Create if it doesn't exist yet
resultModal.querySelector('.br-ai-modal-header h4').textContent = title;
resultModalContent.textContent = content || "Не удалось получить результат."; // Handle null content
resultModal.classList.add('visible');
resultModalVisible = true;
}
/** Hides the result modal */
function hideResultModal() {
if (resultModal) {
resultModal.classList.remove('visible');
}
resultModalVisible = false;
}
/** Copies the content of the result modal to clipboard */
function copyModalContent() {
if (resultModalContent) {
navigator.clipboard.writeText(resultModalContent.textContent)
.then(() => {
// Optional: Show feedback like "Copied!"
resultModalCopyBtn.textContent = '✅ Скопировано!';
setTimeout(() => { resultModalCopyBtn.textContent = '📋 Копировать'; }, 1500);
})
.catch(err => {
console.error('Gemini AI Helper: Failed to copy text: ', err);
alert('Не удалось скопировать текст.');
});
}
}
/** Handles clicks on context menu items */
async function handleContextMenuAction(event) {
const menuItem = event.target.closest('.br-ai-context-menu-item');
if (!menuItem || !currentSelection?.text) {
hideContextMenu();
return;
}
const action = menuItem.dataset.action;
const selectedText = currentSelection.text;
let prompt = '';
let taskTitle = '';
// Change button text to indicate loading
menuItem.innerHTML = `<i>⏳</i> <span>Обработка...</span>`;
hideContextMenu(); // Hide menu immediately after click
switch (action) {
case 'summarize':
prompt = `Создай краткое содержание (summary) следующего текста:\n\n---\n${selectedText}\n---`;
taskTitle = "Суммаризация текста";
break;
case 'rephrase':
prompt = `Перефразируй следующий текст, сохранив основной смысл, но используя другие слова и структуру предложений:\n\n---\n${selectedText}\n---`;
taskTitle = "Перефразирование текста";
break;
case 'grammar':
prompt = `Проверь и исправь грамматику, орфографию и пунктуацию в следующем тексте. Верни только исправленный текст без дополнительных комментариев:\n\n---\n${selectedText}\n---`;
taskTitle = "Исправление грамматики";
break;
default:
// Restore original text if action not found (shouldn't happen)
menuItem.innerHTML = {
summarize: '<i>📝</i> <span>Суммаризировать</span>',
rephrase: '<i>🔄</i> <span>Перефразировать</span>',
grammar: '<i>✅</i> <span>Исправить грамматику</span>'
}[action] || menuItem.innerHTML;
return;
}
// Show modal in loading state immediately
showResultModal("Запрос к AI...", taskTitle);
// Call AI (await the promise)
const aiResult = await callAI(prompt, action); // Pass action as task
// Update modal with the actual result (or error message)
if (resultModalVisible) { // Check if modal wasn't closed by user
showResultModal(aiResult || "Ошибка: Нет ответа от AI.", taskTitle);
}
// Note: No need to restore menu item text here as it's hidden now
}
/** Adds the AI button to the forum's text editor */
function addAiButtonToEditor() {
const editorToolbarSelectors = ['.fr-toolbar', '.xf-editor-toolbar', 'div[data-xf-init="editor"] .fr-toolbar'];
let toolbar = null;
for (const selector of editorToolbarSelectors) {
toolbar = document.querySelector(selector);
if (toolbar) break;
}
if (toolbar && !toolbar.querySelector('.br-ai-editor-button')) {
const aiButton = document.createElement('button');
aiButton.innerHTML = '✨ AI'; // Can use innerHTML for icon + text
aiButton.title = 'Генерация текста с Gemini AI';
aiButton.classList.add('br-ai-editor-button');
aiButton.type = 'button';
aiButton.addEventListener('click', async (e) => { // Make async
e.preventDefault();
if (aiButton.disabled) return;
const editorContentArea = document.querySelector('.fr-element.fr-view') || document.querySelector('textarea.input.codeEditor');
if (!editorContentArea) {
alert('Gemini AI Helper: Не удалось найти поле ввода редактора.');
return;
}
const currentText = editorContentArea.innerText || editorContentArea.value || '';
const promptText = prompt('Введите ваш запрос для Gemini AI (генерация текста):', currentText.slice(-500));
if (promptText) {
const originalButtonText = aiButton.innerHTML;
aiButton.innerHTML = '⏳ AI...';
aiButton.disabled = true;
const aiResponse = await callAI(promptText, 'generate', aiButton); // Pass button for disabling
if (aiResponse !== null) {
let formattedResponse = aiResponse;
if (aiResponse.startsWith('(')) { // Handle error/blocked messages
formattedResponse = `\n\n--- ${aiResponse} --- \n\n`;
} else {
formattedResponse = aiResponse.replace(/\n/g, editorContentArea.isContentEditable ? '<br>' : '\n');
}
// Insert response (simple append/replace for now)
if (editorContentArea.isContentEditable) {
document.execCommand('insertHTML', false, (formattedResponse.includes('<br>') ? formattedResponse : '<p>' + formattedResponse + '</p>'));
} else if (editorContentArea.value !== undefined) {
editorContentArea.value += '\n\n' + formattedResponse;
}
}
// Restore button state (already handled by callAI if button passed)
aiButton.innerHTML = originalButtonText;
// aiButton.disabled = false; // This is handled within callAI now
}
});
const lastButtonGroup = toolbar.querySelector('.fr-btn-group:last-child') || toolbar;
lastButtonGroup.appendChild(aiButton);
}
}
// --- Global Event Listeners ---
/** Handles mouse up events to detect text selection for context menu */
function handleMouseUp(event) {
// Don't show context menu if clicking inside our own UI elements
if (event.target.closest('#br-ai-settings-panel, #br-ai-context-menu, #br-ai-result-modal, #br-ai-toggle-icon')) {
return;
}
// Debounce or delay slightly to ensure selection is finalized
setTimeout(() => {
const selection = window.getSelection();
const selectedText = selection ? selection.toString().trim() : '';
if (selectedText && selectedText.length > 5) { // Require minimum length
currentSelection = { text: selectedText, range: selection.getRangeAt(0) };
// Calculate position near the end of the selection
const rect = currentSelection.range.getBoundingClientRect();
const posX = event.clientX; // Use mouse X
const posY = rect.bottom + window.scrollY + 5; // Position below selection rect end
showContextMenu(posX, posY);
} else {
currentSelection = null;
hideContextMenu();
}
}, 50); // Small delay
}
/** Handles clicks outside UI elements to hide them */
function handleMouseDown(event) {
// Hide context menu if clicking outside it
if (contextMenuVisible && !event.target.closest('#br-ai-context-menu')) {
hideContextMenu();
}
// Hide settings panel if clicking outside it AND outside the toggle icon
if (settingsPanelVisible && !event.target.closest('#br-ai-settings-panel') && event.target !== toggleIcon) {
toggleSettingsPanel(); // Use toggle to handle visibility state
}
// Note: Modal closes via its own close button or backdrop click, handled separately
}
// --- Initialization ---
function initialize() {
console.log("Gemini AI Helper v0.6 Initializing...");
injectStyles();
createSettingsPanel();
createContextMenu(); // Create context menu structure
createResultModal(); // Create result modal structure
// Add global listeners
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('mousedown', handleMouseDown); // Use mousedown to hide before potential mouseup selection
// Attempt to add editor button (using observer is more robust)
const observer = new MutationObserver((mutationsList) => {
for(let mutation of mutationsList) {
if (mutation.type === 'childList') {
if (document.querySelector('.fr-toolbar') && !document.querySelector('.br-ai-editor-button')) {
addAiButtonToEditor();
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
// Fallback checks for editor button
setTimeout(addAiButtonToEditor, 1500);
setTimeout(addAiButtonToEditor, 4000);
// Initial API key check (silent)
setTimeout(() => checkApiKey(true), 500);
console.log("Gemini AI Helper Initialized.");
}
// --- Run Script ---
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
})();