/* ObsiGate — AI Editor Toolbar Provides AI-powered text editing features: - Inline completion (Ctrl+J) - Edit (improve, fix spelling, shorter, longer, simplify) - Tone (professional, casual) - Translate (multiple languages) - Generate (explain, summarize, continue) - AI Custom Rewrite - AI Toolbox (to list, to table, frontmatter, to canvas) */ import { api } from './auth.js'; import { t } from './i18n.js'; // ── API call helper ── async function aiAction(endpoint, text, extra = {}) { // Inject current provider + model from the per-section picker. const pickerState = _readPicker(); const body = { text, ...extra, ...(pickerState.provider ? { provider: pickerState.provider } : {}), ...(pickerState.model ? { model: pickerState.model } : {}), }; const data = await api(`/api/ai/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); return data.result; } // ── Per-section provider/model picker ─────────────────────────────────────── // State is stored in localStorage so the user choice persists across sections. const PICKER_STORAGE_KEY = 'obsigate_ai_picker'; function _readPicker() { try { return JSON.parse(localStorage.getItem(PICKER_STORAGE_KEY) || '{}'); } catch { return {}; } } function _writePicker(state) { try { localStorage.setItem(PICKER_STORAGE_KEY, JSON.stringify(state)); } catch { /* */ } } /** * Build a provider+model picker that appears in every section's AI toolbar * (Forge editor, BooksLM, etc.). The picker reads /api/ai/status to discover * available providers, then /api/config/ai-models?provider=X to list models. */ async function _buildPickerUI() { const pickerState = _readPicker(); // Fetch configured providers let availableProviders = {}; try { const status = await api('/api/ai/status'); availableProviders = status.providers || {}; } catch { /* */ } const providerNames = Object.keys(availableProviders).filter( (p) => availableProviders[p]?.available ); if (!providerNames.length) return null; const wrap = document.createElement('div'); wrap.className = 'ai-picker'; Object.assign(wrap.style, { display: 'inline-flex', alignItems: 'center', gap: '4px', marginLeft: '8px', paddingLeft: '8px', borderLeft: '1px solid var(--border)', fontSize: '0.7rem', color: 'var(--text-muted)', flexShrink: '0', zIndex: '1', }); // Provider select const providerLabel = document.createElement('span'); providerLabel.textContent = t('ai.provider') + ':'; wrap.appendChild(providerLabel); const providerSelect = document.createElement('select'); providerSelect.className = 'ai-picker-select'; Object.assign(providerSelect.style, { fontSize: '0.7rem', background: 'var(--surface)', color: 'var(--text-primary)', border: '1px solid var(--border)', borderRadius: '4px', padding: '2px 4px', cursor: 'pointer', }); const defaultOpt = document.createElement('option'); defaultOpt.value = ''; defaultOpt.textContent = t('ai.provider_default'); providerSelect.appendChild(defaultOpt); providerNames.forEach((p) => { const opt = document.createElement('option'); opt.value = p; opt.textContent = p; if (p === pickerState.provider) opt.selected = true; providerSelect.appendChild(opt); }); // Model select (sibling of provider) const modelSelect = document.createElement('select'); modelSelect.className = 'ai-picker-model'; Object.assign(modelSelect.style, { fontSize: '0.7rem', background: 'var(--surface)', color: 'var(--text-primary)', border: '1px solid var(--border)', borderRadius: '4px', padding: '2px 4px', cursor: 'pointer', minWidth: '120px', maxWidth: '220px', }); const placeholderOpt = document.createElement('option'); placeholderOpt.value = ''; placeholderOpt.textContent = t('ai.model_default'); modelSelect.appendChild(placeholderOpt); async function _loadModels(provider) { modelSelect.innerHTML = ''; const ph = document.createElement('option'); ph.value = ''; ph.textContent = t('ai.model_loading'); modelSelect.appendChild(ph); try { const data = await api(`/api/config/ai-models?provider=${encodeURIComponent(provider)}`); modelSelect.innerHTML = ''; const def = document.createElement('option'); def.value = ''; def.textContent = t('ai.model_default') + (data.source === 'fallback' ? ` (${t('ai.model_offline')})` : ''); modelSelect.appendChild(def); (data.models || []).forEach((m) => { const opt = document.createElement('option'); opt.value = m; opt.textContent = m; if (m === pickerState.model && provider === pickerState.provider) opt.selected = true; modelSelect.appendChild(opt); }); } catch (err) { modelSelect.innerHTML = ''; const opt = document.createElement('option'); opt.value = ''; opt.textContent = t('ai.model_load_error'); modelSelect.appendChild(opt); } } providerSelect.addEventListener('change', () => { pickerState.provider = providerSelect.value || null; // Reset model when provider changes pickerState.model = null; _writePicker(pickerState); if (pickerState.provider) { _loadModels(pickerState.provider); } else { modelSelect.innerHTML = ''; const ph = document.createElement('option'); ph.value = ''; ph.textContent = t('ai.model_default'); modelSelect.appendChild(ph); } }); modelSelect.addEventListener('change', () => { pickerState.model = modelSelect.value || null; _writePicker(pickerState); }); // Load models for the initial provider selection if (pickerState.provider && providerNames.includes(pickerState.provider)) { _loadModels(pickerState.provider); } wrap.appendChild(providerSelect); wrap.appendChild(modelSelect); return wrap; } // ── Get selected text from CodeMirror ── function getSelection(editorView) { if (!editorView) return ''; const { from, to } = editorView.state.selection.main; if (from === to) { // No selection — get entire document return editorView.state.doc.toString(); } return editorView.state.doc.sliceString(from, to); } // ── Replace or insert text ── function replaceSelection(editorView, newText, mode = 'replace') { if (!editorView) return; const { from, to } = editorView.state.selection.main; if (mode === 'append') { // Insert at end of selection editorView.dispatch({ changes: { from: to, insert: '\n' + newText }, selection: { anchor: to + 1 + newText.length }, }); } else if (mode === 'before') { editorView.dispatch({ changes: { from: from, insert: newText + '\n' }, }); } else { // Replace selection editorView.dispatch({ changes: { from, to, insert: newText }, selection: { anchor: from + newText.length }, }); } } // ── Show toast notification ── function showToast(msg, type = 'info') { // Use global showToast if available, otherwise console if (typeof window._obsigateShowToast === 'function') { window._obsigateShowToast(msg, type); } } // ── Dropdown menu builder ── function createMenu(items, parentEl) { // Remove any existing menu const existing = document.querySelector('.ai-dropdown-menu'); if (existing) existing.remove(); const menu = document.createElement('div'); menu.className = 'ai-dropdown-menu'; // Position relative to parent, but attached to body to avoid // being clipped by overflow:hidden on the editor container. const rect = parentEl.getBoundingClientRect(); const menuTop = rect.bottom + 4; const menuLeft = rect.left; Object.assign(menu.style, { position: 'fixed', top: menuTop + 'px', left: menuLeft + 'px', background: 'var(--bg-primary)', border: '1px solid var(--border)', borderRadius: '6px', padding: '4px 0', minWidth: '200px', maxHeight: '60vh', overflowY: 'auto', zIndex: '10000', boxShadow: '0 4px 16px rgba(0,0,0,0.4)', }); // If menu overflows bottom of viewport, flip above the button requestAnimationFrame(() => { const menuRect = menu.getBoundingClientRect(); if (menuRect.bottom > window.innerHeight - 8) { menu.style.top = Math.max(8, rect.top - menuRect.height - 4) + 'px'; } // If menu overflows right edge, align right if (menuRect.right > window.innerWidth - 8) { menu.style.left = Math.max(8, window.innerWidth - menuRect.width - 8) + 'px'; } }); items.forEach(item => { const el = document.createElement('div'); el.className = 'ai-menu-item'; Object.assign(el.style, { padding: '6px 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.8rem', color: 'var(--text-primary)', whiteSpace: 'nowrap', background: 'transparent', transition: 'background 0.1s', }); el.innerHTML = item.label; if (item.hint) { const hint = document.createElement('span'); hint.style.cssText = 'margin-left:auto;font-size:0.65rem;color:var(--text-muted)'; hint.textContent = item.hint; el.appendChild(hint); } if (item.children) { const arrow = document.createElement('span'); arrow.style.cssText = 'margin-left:auto;font-size:0.7rem;color:var(--text-muted)'; arrow.textContent = '▶'; el.appendChild(arrow); } el.addEventListener('mouseenter', () => { el.style.background = 'rgba(255,255,255,0.06)'; // Show submenu if (item.children) { const sub = el.querySelector('.ai-submenu'); if (!sub) { const subMenu = createSubMenu(item.children, el); el.appendChild(subMenu); } } }); el.addEventListener('mouseleave', (e) => { el.style.background = 'transparent'; }); el.addEventListener('click', (e) => { e.stopPropagation(); if (item.action) item.action(); menu.remove(); }); menu.appendChild(el); }); document.body.appendChild(menu); // Close on outside click const closeHandler = (e) => { if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', closeHandler); } }; setTimeout(() => document.addEventListener('click', closeHandler), 0); } function createSubMenu(items, parentEl) { const menu = document.createElement('div'); menu.className = 'ai-submenu'; Object.assign(menu.style, { position: 'absolute', left: '100%', top: '0', background: 'var(--bg-primary)', border: '1px solid var(--border)', borderRadius: '6px', padding: '4px 0', minWidth: '180px', zIndex: '101', boxShadow: '0 4px 16px rgba(0,0,0,0.4)', }); items.forEach(item => { const el = document.createElement('div'); el.className = 'ai-menu-item'; Object.assign(el.style, { padding: '6px 12px', cursor: 'pointer', fontSize: '0.78rem', color: 'var(--text-primary)', whiteSpace: 'nowrap', }); el.innerHTML = item.label; el.addEventListener('mouseenter', () => { el.style.background = 'rgba(255,255,255,0.06)'; }); el.addEventListener('mouseleave', () => { el.style.background = 'transparent'; }); el.addEventListener('click', (e) => { e.stopPropagation(); if (item.action) item.action(); // Close all menus document.querySelectorAll('.ai-dropdown-menu, .ai-submenu').forEach(m => m.remove()); }); menu.appendChild(el); }); return menu; } // ── Main AI Toolbar ── let _activeAiBtn = null; let _aiShortcutBound = false; /** * Bind the Ctrl/Cmd+J inline-completion shortcut exactly once, regardless of * how many times the editor (and therefore the toolbar) is re-created. * Without this guard every editor open added a new listener, so pressing the * shortcut fired the AI request N times. */ function _bindAiShortcut() { if (_aiShortcutBound) return; _aiShortcutBound = true; document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'j') { const modal = document.getElementById('editor-modal'); if (!modal || !modal.classList.contains('active')) return; if (!_activeAiBtn) return; e.preventDefault(); _activeAiBtn.click(); } }); } /** * Professional replacement for `window.prompt()` for the custom-rewrite * instruction. Returns a Promise resolving to the entered instruction or * `null` when cancelled. Escape cancels, Ctrl/Cmd+Enter submits. */ function _promptRewriteInstruction() { return new Promise((resolve) => { const overlay = document.createElement('div'); overlay.className = 'ai-modal-overlay'; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-label', t('ai.rewrite_title')); const modal = document.createElement('div'); modal.className = 'ai-modal'; modal.innerHTML = `
${t('ai.rewrite_prompt')}