Files
ObsiGate/frontend/js/ai.js
T
bruno 62023acd4d
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
feat(api,ai): #72 OpenAPI 3.1 enrichie + fiabilisation de l'outil AI UI
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
2026-09-11 01:40:21 -04:00

718 lines
24 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';
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,
};