Files
ObsiGate/frontend/js/ai.js
T
bruno b077c24bf1
CI / lint (push) Failing after 6s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / security (push) Successful in 11s
fix: corrections UI — indicateur voute, couleurs, statut IA, spinner éditeur
1. Suppression du badge vault-context-pill doublon en mobile
2. Couleurs dynamiques indicateur header:
   - Bleu pour 'All' (.vault-ctx-all)
   - Mauve/violet pour les vaults (.vault-ctx-vault)
   - Jaune/ambre pour les répertoires (.vault-ctx-dir)
3. Statut IA: vérifie la présence du toolbar AI dans le DOM
   au lieu d'appeler /api/ai/status (qui ne fonctionnait pas)
4. Indicateur de traitement AI dans l'éditeur (spinner + badge
   'AI...' dans le header de l'editor modal) au lieu du flash
   blanc sur le badge sync
2026-06-04 08:31:32 -04:00

457 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 = 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-color)',
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-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: 'var(--bg-secondary)',
flexWrap: 'wrap',
position: 'sticky',
top: '0',
zIndex: '5',
});
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) — Complète le texte automatiquement';
aiBtn.addEventListener('click', async () => {
const v = ev();
if (!v) return;
const text = getSelection(v);
if (!text.trim()) return;
const origHTML = aiBtn.innerHTML;
aiBtn.innerHTML = '⏳ AI...';
aiBtn.disabled = true;
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');
} finally {
aiBtn.innerHTML = origHTML;
aiBtn.disabled = false;
}
});
// ── Edit menu ──
const editBtn = createDropdownBtn('Éditer', [
{ label: '🪄 Improve writing', action: () => action('improve'), hint: 'Améliore la qualité du texte' },
{ label: '🔤 Fix spelling & grammar', action: () => action('fix-spelling'), hint: 'Corrige les fautes' },
{ label: '📏 Make shorter', action: () => action('make-shorter'), hint: 'Rend le texte plus concis' },
{ label: '📐 Make longer', action: () => action('make-longer'), hint: 'Ajoute des détails' },
{ label: '📋 Simplify language', action: () => action('simplify'), hint: 'Simplifie le langage' },
], 'Modifie le texte sélectionné');
// ── Tone menu ──
const toneBtn = createDropdownBtn('Ton', [
{ label: '💼 Professional tone', action: () => action('tone', { tone: 'professional' }) },
{ label: '💬 Casual tone', action: () => action('tone', { tone: 'casual' }) },
], 'Change le ton du texte');
// ── 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' }) },
], 'Traduit le texte sélectionné');
// ── 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') },
], 'Génère du contenu à partir de la sélection');
// ── Custom Rewrite ──
const rewriteBtn = document.createElement('button');
rewriteBtn.className = 'ai-toolbar-btn';
rewriteBtn.innerHTML = '💬 Réécrire';
rewriteBtn.title = 'Réécrit le texte selon vos instructions';
Object.assign(rewriteBtn.style, {
fontSize: '0.7rem',
background: 'transparent',
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') },
], 'Outils de conversion');
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;
}
showToast('⏳ AI: traitement en cours...', 'info');
// Show processing indicator in the editor header
_showProcessingIndicator(true);
// Dispatch AI processing start event
document.dispatchEvent(new CustomEvent('ai-processing-start', { detail: { endpoint, mode } }));
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');
}
} finally {
_showProcessingIndicator(false);
// Dispatch AI processing end event
document.dispatchEvent(new CustomEvent('ai-processing-end'));
}
}
// ── In-editor processing indicator ──
function _showProcessingIndicator(show) {
let el = document.getElementById('ai-processing-indicator');
if (show) {
if (!el) {
el = document.createElement('span');
el.id = 'ai-processing-indicator';
el.className = 'ai-processing-indicator';
el.innerHTML = '<span class="ai-spinner"></span> AI...';
el.title = 'Traitement AI en cours';
const header = document.querySelector('#editor-modal .editor-header');
if (header) header.appendChild(el);
}
el.style.display = 'inline-flex';
} else if (el) {
el.style.display = 'none';
}
}
// ── 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, tooltip = '') {
const btn = document.createElement('button');
btn.className = 'ai-toolbar-btn';
btn.innerHTML = text + ' ▾';
btn.title = tooltip || 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;
}