CI / lint (push) Successful in 55s
CI / security (push) Successful in 37s
CI / test (push) Successful in 1m19s
CI / build (push) Successful in 34s
CI / e2e (push) Successful in 10m27s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
API (#72): - backend/openapi_docs.py: 18 tags documentes, assignation auto par prefixe, securite bearerAuth/cookieAuth, erreurs 401/403/404/422/500, exemples, page /api autonome (liens /docs, /redoc, /openapi.json) - backend/schemas.py: response_model Pydantic pour ~35 endpoints sans modele - main.py: FastAPI enrichi + override app.openapi; routes /api et /api/ - ai_routes/bookslm_routes: response_model + doc SSE - frontend: entree 'API' du menu (i18n FR/EN) - tests/test_openapi.py (58 tests) AI UI: - BooksLM: requetes authentifiees (AuthManager), parsing SSE {token}/error, message utilisateur correct (plus le placeholder vide), barre de contexte - AI Editor: Ctrl/Cmd+J lie une seule fois, libelles i18n, modale de reecriture accessible a la place de window.prompt() - tests/frontend/ai.test.mjs (7 tests) + CI
718 lines
24 KiB
JavaScript
718 lines
24 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 ──
|
||
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 = `
|
||
<h3 class="ai-modal-title">${t('ai.custom_rewrite')}</h3>
|
||
<p class="ai-modal-hint">${t('ai.rewrite_prompt')}</p>
|
||
<textarea class="ai-modal-input" rows="3" placeholder="${t('ai.rewrite_placeholder')}"></textarea>
|
||
<div class="ai-modal-actions">
|
||
<button type="button" class="ai-modal-btn ai-modal-cancel">${t('button.cancel')}</button>
|
||
<button type="button" class="ai-modal-btn primary ai-modal-ok">${t('ai.rewrite')}</button>
|
||
</div>`;
|
||
overlay.appendChild(modal);
|
||
document.body.appendChild(overlay);
|
||
|
||
const input = modal.querySelector('.ai-modal-input');
|
||
const close = (value) => {
|
||
overlay.remove();
|
||
document.removeEventListener('keydown', onKey, true);
|
||
resolve(value);
|
||
};
|
||
const onKey = (e) => {
|
||
if (e.key === 'Escape') { e.preventDefault(); close(null); }
|
||
else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||
e.preventDefault();
|
||
close(input.value.trim() || null);
|
||
}
|
||
};
|
||
document.addEventListener('keydown', onKey, true);
|
||
modal.querySelector('.ai-modal-cancel').addEventListener('click', () => close(null));
|
||
modal.querySelector('.ai-modal-ok').addEventListener('click', () => close(input.value.trim() || null));
|
||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(null); });
|
||
input.focus();
|
||
});
|
||
}
|
||
|
||
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.setAttribute('aria-label', 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.toolbar_edit'), [
|
||
{ label: '🪄 ' + t('ai.improve'), action: () => action('improve'), hint: t('ai.hint_improve') },
|
||
{ label: '🔤 ' + t('ai.fix_spelling'), action: () => action('fix-spelling'), hint: t('ai.fix_spelling_hint') },
|
||
{ label: '📏 ' + t('ai.shorter'), action: () => action('make-shorter'), hint: t('ai.shorter_hint') },
|
||
{ label: '📐 ' + t('ai.longer'), action: () => action('make-longer'), hint: t('ai.hint_add_details') },
|
||
{ label: '📋 ' + t('ai.simplify'), action: () => action('simplify'), hint: t('ai.simplify_hint') },
|
||
], t('ai.hint_edit_selection'));
|
||
|
||
// ── Tone menu ──
|
||
const toneBtn = createDropdownBtn(t('ai.toolbar_tone'), [
|
||
{ label: '💼 ' + t('ai.professional'), action: () => action('tone', { tone: 'professional' }) },
|
||
{ label: '💬 ' + t('ai.casual'), action: () => action('tone', { tone: 'casual' }) },
|
||
], t('ai.tone_hint'));
|
||
|
||
// ── Translate menu ──
|
||
const translateBtn = createDropdownBtn(t('ai.toolbar_translate'), [
|
||
{ 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.toolbar_generate'), [
|
||
{ label: 'ℹ️ ' + t('ai.explain'), action: () => action('explain', {}, 'append') },
|
||
{ label: '📝 ' + t('ai.summarize'), action: () => action('summarize', {}, 'append') },
|
||
{ label: '✏️ ' + t('ai.continue'), action: () => action('continue', {}, 'append') },
|
||
], t('ai.hint_generate'));
|
||
|
||
// ── Custom Rewrite ──
|
||
const rewriteBtn = document.createElement('button');
|
||
rewriteBtn.className = 'ai-toolbar-btn';
|
||
rewriteBtn.innerHTML = t('ai.toolbar_rewrite');
|
||
rewriteBtn.title = t('ai.rewrite_title');
|
||
rewriteBtn.setAttribute('aria-label', 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);
|
||
if (!text.trim()) {
|
||
showToast(t('ai.select_text'), 'warning');
|
||
return;
|
||
}
|
||
const instruction = await _promptRewriteInstruction();
|
||
if (!instruction) return;
|
||
rewriteBtn.disabled = true;
|
||
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');
|
||
} finally {
|
||
rewriteBtn.disabled = false;
|
||
}
|
||
});
|
||
|
||
// ── Toolbox menu ──
|
||
const toolboxBtn = createDropdownBtn(t('ai.toolbar_toolbox'), [
|
||
{ label: '📋 ' + t('ai.to_list'), action: () => action('to-list') },
|
||
{ label: '📊 ' + t('ai.to_table'), action: () => action('to-table') },
|
||
{ label: '⚙️ ' + t('ai.frontmatter'), action: () => action('frontmatter', {}, 'before') },
|
||
{ label: '🔷 ' + t('ai.to_canvas'), action: () => action('to-canvas', {}, 'append') },
|
||
], t('ai.toolbox_hint'));
|
||
|
||
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 = t('ai.processing');
|
||
el.setAttribute('role', 'status');
|
||
const header = document.querySelector('#editor-modal .editor-header');
|
||
(header || toolbar).appendChild(el);
|
||
}
|
||
el.style.display = 'inline-flex';
|
||
} else if (el) {
|
||
el.style.display = 'none';
|
||
}
|
||
}
|
||
// ── Keyboard shortcut Ctrl+J for inline completion ──
|
||
_activeAiBtn = aiBtn;
|
||
_bindAiShortcut();
|
||
|
||
return toolbar;
|
||
}
|
||
|
||
function createDropdownBtn(text, items, tooltip = '') {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'ai-toolbar-btn';
|
||
btn.innerHTML = text + ' ▾';
|
||
btn.title = tooltip || text;
|
||
btn.setAttribute('aria-haspopup', 'true');
|
||
btn.setAttribute('aria-expanded', 'false');
|
||
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,
|
||
_promptRewriteInstruction,
|
||
};
|