CI / lint (push) Successful in 37s
CI / security (push) Successful in 25s
CI / test (push) Successful in 49s
CI / build (push) Successful in 22s
CI / e2e (push) Successful in 6m7s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
- Bug 1 (422): bookslm.js envoie maintenant {message, conversation_history}
conformes au backend (au lieu de messages:[...])
- Bug 2 (thème BooksLM): style.css lignes 9015-9061 — suppression de
tous les fallbacks hex (--bg, --muted, --text, --surface, --accent,
--border) au profit des variables ObsiGate existantes (--bg-primary,
--text-primary, --text-secondary, --surface, --surface2, --accent,
--border). Ajout de color: var(--text-primary) sur .bookslm-panel
- Bug 3 (picker zindex/flex): bookslm-picker-host + ai-picker +
.bookslm-header button obtiennent flex-shrink:0 + z-index:1 pour
éviter que les selects compressent ou masquent les boutons
header (Nouvelle conversation / Exporter / Plein écran / Fermer)
- Bug 4 (theme picker): ai.js — var(--bg-tertiary, transparent) →
var(--surface), var(--border-color) → var(--border) sur tous les
selects, menus et createSeparator. Plus de variables inexistantes.
Tests: pytest 504 passed, frontend unit 7/7, validate-imports 30 modules
204 exports, pane-manager 9/9, ruff All checks passed.
641 lines
21 KiB
JavaScript
641 lines
21 KiB
JavaScript
/* 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 ──
|
||
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)';
|
||
hint.textContent = t('ai.not_configured');
|
||
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)',
|
||
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 = t('ai.inline_completion_title');
|
||
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(t('ai.completion_added'), 'success');
|
||
} catch (e) {
|
||
showToast('AI: ' + (String(e.message || e).includes('401') ? t('ai.error_invalid_key') : e.message), 'error');
|
||
} finally {
|
||
aiBtn.innerHTML = origHTML;
|
||
aiBtn.disabled = false;
|
||
}
|
||
});
|
||
|
||
// ── Edit menu ──
|
||
const editBtn = createDropdownBtn(t('ai.edit'), [
|
||
{ label: '🪄 Improve writing', action: () => action('improve'), hint: t('ai.hint_improve') },
|
||
{ 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: t('ai.hint_add_details') },
|
||
{ label: '📋 Simplify language', action: () => action('simplify'), hint: 'Simplifie le langage' },
|
||
], t('ai.hint_edit_selection'));
|
||
|
||
// ── 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' }) },
|
||
], t('ai.hint_translate'));
|
||
|
||
// ── Generate menu ──
|
||
const genBtn = createDropdownBtn(t('ai.generate'), [
|
||
{ label: 'ℹ️ Explain this', action: () => action('explain', {}, 'append') },
|
||
{ label: '📝 Summarize', action: () => action('summarize', {}, 'append') },
|
||
{ label: '✏️ Continue writing', action: () => action('continue', {}, 'append') },
|
||
], t('ai.hint_generate'));
|
||
|
||
// ── Custom Rewrite ──
|
||
const rewriteBtn = document.createElement('button');
|
||
rewriteBtn.className = 'ai-toolbar-btn';
|
||
rewriteBtn.innerHTML = t('ai.rewrite');
|
||
rewriteBtn.title = t('ai.rewrite_title');
|
||
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(t('ai.rewrite_prompt'), '');
|
||
if (!instruction) return;
|
||
try {
|
||
const result = await aiAction('rewrite', text, { instruction });
|
||
replaceSelection(v, result);
|
||
showToast(t('ai.text_rewritten'), 'success');
|
||
} catch (e) {
|
||
showToast('AI: ' + (String(e.message || e).includes('401') ? t('ai.error_invalid_key') : e.message), 'error');
|
||
}
|
||
});
|
||
|
||
// ── Toolbox menu ──
|
||
const toolboxBtn = createDropdownBtn(t('ai.toolbox'), [
|
||
{ 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);
|
||
|
||
// Append the per-section provider/model picker on the right.
|
||
const picker = await _buildPickerUI();
|
||
if (picker) {
|
||
toolbar.appendChild(createSeparator());
|
||
toolbar.appendChild(picker);
|
||
}
|
||
|
||
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(t('ai.select_text'), 'warning');
|
||
return;
|
||
}
|
||
showToast(t('ai.processing'), '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(t('ai.text_processed'), 'success');
|
||
} catch (e) {
|
||
const msg = String(e.message || e);
|
||
if (msg.includes('401')) {
|
||
showToast(t('ai.invalid_api_key'), 'error');
|
||
} else if (msg.includes('402') || msg.includes('429')) {
|
||
showToast(t('ai.quota_exceeded'), '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);margin:0 2px';
|
||
return sep;
|
||
}
|
||
|
||
// ── Public exports ────────────────────────────────────────────────────────
|
||
// Other modules (e.g. BooksLM) can import the picker helpers to render the
|
||
// same per-section provider/model picker in their own toolbar.
|
||
export {
|
||
_readPicker,
|
||
_writePicker,
|
||
PICKER_STORAGE_KEY,
|
||
_buildPickerUI as buildAIPickerUI,
|
||
};
|