/* 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'; // ── API call helper ── async function aiAction(endpoint, text, extra = {}) { const body = { text, ...extra }; const data = await api(`/api/ai/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); return data.result; } // ── 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 = parentEl.querySelector('.ai-dropdown-menu'); if (existing) existing.remove(); const menu = document.createElement('div'); menu.className = 'ai-dropdown-menu'; Object.assign(menu.style, { position: 'absolute', top: '100%', left: '0', background: 'var(--bg-primary)', border: '1px solid var(--border-color)', borderRadius: '6px', padding: '4px 0', minWidth: '200px', zIndex: '100', 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', display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.8rem', color: 'var(--text-primary)', whiteSpace: 'nowrap', }); 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); }); parentEl.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-color)', 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 ── export async function createAIToolbar(container, getEditorView) { // Check if AI is configured let aiConfigured = false; try { const status = await api('/api/ai/status'); aiConfigured = status.configured; } catch { aiConfigured = false; } if (!aiConfigured) { const hint = document.createElement('div'); hint.style.cssText = 'padding:6px 12px;font-size:0.7rem;color:var(--text-muted);border-bottom:1px solid var(--border-color)'; hint.textContent = '⚠️ AI non configuré — ajouter DEEPSEEK_API_KEY, OPENROUTER_API_KEY ou GEMINI_API_KEY dans .env'; container.appendChild(hint); return; } const toolbar = document.createElement('div'); toolbar.className = 'ai-toolbar'; Object.assign(toolbar.style, { display: 'flex', alignItems: 'center', gap: '2px', padding: '4px 8px', borderBottom: '1px solid var(--border-color)', background: '#2a2a2a', flexWrap: 'wrap', }); function ev() { return getEditorView(); } // ── Inline completion button ── const aiBtn = document.createElement('button'); aiBtn.className = 'ai-toolbar-btn'; aiBtn.innerHTML = '✦ AI'; Object.assign(aiBtn.style, { color: '#60a5fa', fontWeight: '600', fontSize: '0.75rem', background: 'none', border: 'none', cursor: 'pointer', padding: '3px 8px', borderRadius: '4px', }); aiBtn.title = 'AI Inline Completion (Ctrl+J)'; aiBtn.addEventListener('click', async () => { const v = ev(); if (!v) return; const text = getSelection(v); if (!text.trim()) return; try { const result = await aiAction('inline-complete', text); replaceSelection(v, result, 'append'); showToast('AI: complétion ajoutée', 'success'); } catch (e) { showToast('AI: ' + (String(e.message || e).includes('401') ? 'clé API invalide' : e.message), 'error'); } }); // ── Edit menu ── const editBtn = createDropdownBtn('Éditer', [ { label: '🪄 Improve writing', action: () => action('improve') }, { label: '🔤 Fix spelling & grammar', action: () => action('fix-spelling') }, { label: '📏 Make shorter', action: () => action('make-shorter') }, { label: '📐 Make longer', action: () => action('make-longer') }, { label: '📋 Simplify language', action: () => action('simplify') }, ]); // ── Tone menu ── const toneBtn = createDropdownBtn('Ton', [ { label: '💼 Professional tone', action: () => action('tone', { tone: 'professional' }) }, { label: '💬 Casual tone', action: () => action('tone', { tone: 'casual' }) }, ]); // ── Translate menu ── const translateBtn = createDropdownBtn('Traduire', [ { label: '🇬🇧 English', action: () => action('translate', { target_lang: 'English' }) }, { label: '🇨🇳 Chinese', action: () => action('translate', { target_lang: 'Chinese' }) }, { label: '🇯🇵 Japanese', action: () => action('translate', { target_lang: 'Japanese' }) }, { label: '🇩🇪 German', action: () => action('translate', { target_lang: 'German' }) }, { label: '🇫🇷 French', action: () => action('translate', { target_lang: 'French' }) }, { label: '🇪🇸 Spanish', action: () => action('translate', { target_lang: 'Spanish' }) }, ]); // ── Generate menu ── const genBtn = createDropdownBtn('Générer', [ { label: 'ℹ️ Explain this', action: () => action('explain', {}, 'append') }, { label: '📝 Summarize', action: () => action('summarize', {}, 'append') }, { label: '✏️ Continue writing', action: () => action('continue', {}, 'append') }, ]); // ── Custom Rewrite ── const rewriteBtn = document.createElement('button'); rewriteBtn.className = 'ai-toolbar-btn'; rewriteBtn.innerHTML = '💬 Réécrire'; Object.assign(rewriteBtn.style, { fontSize: '0.7rem', background: 'none', border: 'none', cursor: 'pointer', padding: '3px 6px', borderRadius: '4px', color: 'var(--text-primary)', }); rewriteBtn.addEventListener('click', async () => { const v = ev(); if (!v) return; const text = getSelection(v); const instruction = prompt('Instruction de réécriture :', ''); if (!instruction) return; try { const result = await aiAction('rewrite', text, { instruction }); replaceSelection(v, result); showToast('AI: texte réécrit', 'success'); } catch (e) { showToast('AI: ' + (String(e.message || e).includes('401') ? 'clé API invalide' : e.message), 'error'); } }); // ── Toolbox menu ── const toolboxBtn = createDropdownBtn('🧰 Boîte', [ { label: '📋 Convert to list', action: () => action('to-list') }, { label: '📊 Convert to table', action: () => action('to-table') }, { label: '⚙️ Generate frontmatter', action: () => action('frontmatter', {}, 'before') }, { label: '🔷 Convert to canvas', action: () => action('to-canvas', {}, 'append') }, ]); toolbar.appendChild(aiBtn); toolbar.appendChild(createSeparator()); toolbar.appendChild(editBtn); toolbar.appendChild(toneBtn); toolbar.appendChild(translateBtn); toolbar.appendChild(createSeparator()); toolbar.appendChild(genBtn); toolbar.appendChild(rewriteBtn); toolbar.appendChild(toolboxBtn); container.insertBefore(toolbar, container.firstChild); // ── Action helper ── async function action(endpoint, extra = {}, mode = 'replace') { const v = ev(); if (!v) return; const text = getSelection(v); if (!text.trim()) { showToast('AI: sélectionnez du texte à traiter', 'warning'); return; } try { const result = await aiAction(endpoint, text, extra); replaceSelection(v, result, mode); showToast('AI: texte traité', 'success'); } catch (e) { const msg = String(e.message || e); if (msg.includes('401')) { showToast('AI: clé API invalide. Vérifiez DEEPSEEK_API_KEY dans .env', 'error'); } else if (msg.includes('402') || msg.includes('429')) { showToast('AI: quota dépassé ou paiement requis', 'error'); } else { showToast('AI: ' + msg, 'error'); } } } // ── Keyboard shortcut Ctrl+J for inline completion ── document.addEventListener('keydown', (e) => { if (e.ctrlKey && e.key === 'j') { const modal = document.getElementById('editor-modal'); if (!modal || !modal.classList.contains('active')) return; e.preventDefault(); aiBtn.click(); } }); return toolbar; } function createDropdownBtn(text, items) { const btn = document.createElement('button'); btn.className = 'ai-toolbar-btn'; btn.innerHTML = text + ' ▾'; Object.assign(btn.style, { position: 'relative', fontSize: '0.7rem', background: 'none', border: 'none', cursor: 'pointer', padding: '3px 6px', borderRadius: '4px', color: 'var(--text-primary)', }); btn.addEventListener('click', (e) => { e.stopPropagation(); createMenu(items, btn); }); btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(255,255,255,0.08)'; }); btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; }); return btn; } function createSeparator() { const sep = document.createElement('span'); sep.style.cssText = 'width:1px;height:16px;background:var(--border-color);margin:0 2px'; return sep; }