feat(api,ai): #72 OpenAPI 3.1 enrichie + fiabilisation de l'outil AI UI
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
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
This commit is contained in:
+110
-33
@@ -376,6 +376,76 @@ function createSubMenu(items, parentEl) {
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
@@ -424,6 +494,7 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
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;
|
||||
@@ -445,22 +516,22 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
});
|
||||
|
||||
// ── 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' },
|
||||
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('Ton', [
|
||||
{ label: '💼 Professional tone', action: () => action('tone', { tone: 'professional' }) },
|
||||
{ label: '💬 Casual tone', action: () => action('tone', { tone: 'casual' }) },
|
||||
], 'Change le ton du texte');
|
||||
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('Traduire', [
|
||||
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' }) },
|
||||
@@ -470,17 +541,18 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
], 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') },
|
||||
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.rewrite');
|
||||
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',
|
||||
@@ -494,24 +566,31 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
const v = ev();
|
||||
if (!v) return;
|
||||
const text = getSelection(v);
|
||||
const instruction = prompt(t('ai.rewrite_prompt'), '');
|
||||
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.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');
|
||||
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());
|
||||
@@ -577,9 +656,10 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
el.id = 'ai-processing-indicator';
|
||||
el.className = 'ai-processing-indicator';
|
||||
el.innerHTML = '<span class="ai-spinner"></span> AI...';
|
||||
el.title = 'Traitement AI en cours';
|
||||
el.title = t('ai.processing');
|
||||
el.setAttribute('role', 'status');
|
||||
const header = document.querySelector('#editor-modal .editor-header');
|
||||
if (header) header.appendChild(el);
|
||||
(header || toolbar).appendChild(el);
|
||||
}
|
||||
el.style.display = 'inline-flex';
|
||||
} else if (el) {
|
||||
@@ -587,14 +667,8 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
}
|
||||
}
|
||||
// ── 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();
|
||||
}
|
||||
});
|
||||
_activeAiBtn = aiBtn;
|
||||
_bindAiShortcut();
|
||||
|
||||
return toolbar;
|
||||
}
|
||||
@@ -604,6 +678,8 @@ function createDropdownBtn(text, items, tooltip = '') {
|
||||
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',
|
||||
@@ -637,4 +713,5 @@ export {
|
||||
_writePicker,
|
||||
PICKER_STORAGE_KEY,
|
||||
_buildPickerUI as buildAIPickerUI,
|
||||
_promptRewriteInstruction,
|
||||
};
|
||||
|
||||
+76
-47
@@ -2,6 +2,7 @@
|
||||
import { t } from './i18n.js';
|
||||
import { buildAIPickerUI } from './ai.js';
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
import { api, AuthManager } from './auth.js';
|
||||
|
||||
class BooksLM {
|
||||
constructor() {
|
||||
@@ -58,15 +59,14 @@ class BooksLM {
|
||||
});
|
||||
this._isOpen = true;
|
||||
|
||||
// Load context
|
||||
// Load context (authenticated — `api` injects the bearer token and
|
||||
// transparently refreshes it once on 401).
|
||||
try {
|
||||
const resp = await fetch('/api/ai/bookslm/context', {
|
||||
const data = await api('/api/ai/bookslm/context', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vault, directory })
|
||||
body: JSON.stringify({ vault, directory }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
this._contextFiles = data.files || [];
|
||||
this._updateStatus(data);
|
||||
this._showSuggestions();
|
||||
@@ -241,10 +241,11 @@ class BooksLM {
|
||||
if (data && data.files) {
|
||||
const count = data.files.length;
|
||||
const chars = data.total_chars || 0;
|
||||
const pct = Math.min(100, Math.round((chars / 100000) * 100));
|
||||
const maxChars = data.max_total_chars || 200000;
|
||||
const pct = Math.min(100, Math.round((chars / maxChars) * 100));
|
||||
status.innerHTML = `
|
||||
<span>${t('bookslm.files_indexed', { count })}, ${t('bookslm.chars_loaded', { chars: Math.round(chars / 1000) + 'K' })}</span>
|
||||
<div class="bookslm-context-bar"><div class="bookslm-context-fill" style="width:${pct}%"></div></div>
|
||||
<div class="bookslm-context-bar" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100"><div class="bookslm-context-fill" style="width:${pct}%"></div></div>
|
||||
`;
|
||||
} else if (this._isLoading) {
|
||||
status.textContent = '⏳ ...';
|
||||
@@ -330,6 +331,12 @@ class BooksLM {
|
||||
const sugEl = this._panel.querySelector('.bookslm-suggestions');
|
||||
if (sugEl) sugEl.innerHTML = '';
|
||||
|
||||
// Snapshot the prior turns BEFORE appending the new user message so the
|
||||
// backend receives the correct `message` + `conversation_history` split.
|
||||
const history = this._messages
|
||||
.filter((m) => m.content && m.content.trim())
|
||||
.map((m) => ({ role: m.role, content: m.content }));
|
||||
|
||||
// Add user message
|
||||
this._messages.push({ role: 'user', content: text });
|
||||
this._renderMessages();
|
||||
@@ -356,33 +363,70 @@ class BooksLM {
|
||||
model = picker.model || null;
|
||||
} catch { /* */ }
|
||||
|
||||
const resp = await fetch('/api/ai/bookslm/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
vault: this._vault,
|
||||
directory: this._directory,
|
||||
// The backend expects `message` + `conversation_history`,
|
||||
// not a generic `messages` array. Convert before sending.
|
||||
message: this._messages.length
|
||||
? this._messages[this._messages.length - 1].content
|
||||
: '',
|
||||
conversation_history: this._messages
|
||||
.slice(0, -1)
|
||||
.filter((m) => m.content && m.content.trim())
|
||||
.map((m) => ({ role: m.role, content: m.content })),
|
||||
context_files: this._contextFiles.map((f) => f.path || f),
|
||||
provider,
|
||||
model,
|
||||
}),
|
||||
signal: this._abortCtrl.signal
|
||||
});
|
||||
const payload = {
|
||||
vault: this._vault,
|
||||
directory: this._directory,
|
||||
message: text,
|
||||
conversation_history: history,
|
||||
context_files: this._contextFiles.map((f) => f.path || f),
|
||||
provider,
|
||||
model,
|
||||
};
|
||||
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
// Authenticated streaming request (raw fetch so we can read the SSE
|
||||
// body; `api()` would consume the JSON response instead).
|
||||
const doFetch = () => {
|
||||
const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) };
|
||||
return fetch('/api/ai/bookslm/chat', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
signal: this._abortCtrl.signal,
|
||||
});
|
||||
};
|
||||
let resp = await doFetch();
|
||||
if (resp.status === 401 && AuthManager._authEnabled) {
|
||||
await AuthManager.refreshAccessToken();
|
||||
resp = await doFetch();
|
||||
}
|
||||
if (!resp.ok) {
|
||||
let detail = `HTTP ${resp.status}`;
|
||||
try { detail = (await resp.json()).detail || detail; } catch { /* */ }
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let currentEvent = 'message';
|
||||
|
||||
const handleLine = (line) => {
|
||||
if (line.startsWith('event:')) {
|
||||
currentEvent = line.slice(6).trim();
|
||||
return;
|
||||
}
|
||||
if (!line.startsWith('data:')) return;
|
||||
const raw = line.slice(5).trim();
|
||||
if (!raw || raw === '[DONE]') return;
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
assistantMsg.content += raw;
|
||||
return;
|
||||
}
|
||||
if (currentEvent === 'error' || data.error) {
|
||||
const msg = data.error || t('bookslm.error');
|
||||
assistantMsg.content += (assistantMsg.content ? '\n\n' : '') + '⚠ ' + msg;
|
||||
return;
|
||||
}
|
||||
// The backend streams `{token: "..."}`; accept `content` too for
|
||||
// forward compatibility.
|
||||
const chunk = data.token || data.content || '';
|
||||
if (chunk) assistantMsg.content += chunk;
|
||||
if (data.sources) assistantMsg.sources = data.sources;
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
@@ -392,25 +436,10 @@ class BooksLM {
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const payload = line.slice(6);
|
||||
if (payload === '[DONE]') continue;
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
if (data.content) {
|
||||
assistantMsg.content += data.content;
|
||||
}
|
||||
if (data.sources) {
|
||||
assistantMsg.sources = data.sources;
|
||||
}
|
||||
} catch {
|
||||
// Raw text token
|
||||
assistantMsg.content += payload;
|
||||
}
|
||||
this._renderMessages();
|
||||
}
|
||||
for (const line of lines) handleLine(line);
|
||||
this._renderMessages();
|
||||
}
|
||||
if (buffer) handleLine(buffer);
|
||||
|
||||
// Extract sources from context files if mentioned
|
||||
if (!assistantMsg.sources.length) {
|
||||
|
||||
@@ -216,6 +216,15 @@ export function initHeaderMenu() {
|
||||
menuDropdown.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// API documentation (#72) — opens the OpenAPI/Swagger page in a new tab.
|
||||
const apiRow = document.getElementById("api-docs-menu-row");
|
||||
if (apiRow) {
|
||||
apiRow.addEventListener("click", () => {
|
||||
closeHeaderMenu();
|
||||
window.open("/docs", "_blank", "noopener");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function closeHeaderMenu() {
|
||||
|
||||
Reference in New Issue
Block a user