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:
@@ -627,6 +627,30 @@
|
||||
>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="menu-list-row menu-list-button"
|
||||
id="api-docs-menu-row"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
>
|
||||
<span
|
||||
class="menu-list-icon"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i
|
||||
data-lucide="code-2"
|
||||
style="width: 17px; height: 17px"
|
||||
></i>
|
||||
</span>
|
||||
<span class="menu-list-content">
|
||||
<span class="menu-list-title" data-i18n="header.menu_api"
|
||||
>API</span
|
||||
>
|
||||
<span class="menu-list-subtitle" data-i18n="header.menu_api_desc"
|
||||
>Documentation OpenAPI</span
|
||||
>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="menu-list-row menu-list-button"
|
||||
id="config-open-btn"
|
||||
|
||||
+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() {
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"ai.error_invalid_key": "Invalid API key",
|
||||
"ai.explain": "Explain",
|
||||
"ai.fix_spelling": "Fix spelling",
|
||||
"ai.fix_spelling_hint": "Fixes spelling and grammar",
|
||||
"ai.frontmatter": "Generate frontmatter",
|
||||
"ai.generate": "Generate",
|
||||
"ai.hint_add_details": "Adds details",
|
||||
@@ -113,16 +114,20 @@
|
||||
"ai.rewrite": "💬 Rewrite",
|
||||
"ai.rewrite_done": "AI: text rewritten",
|
||||
"ai.rewrite_prompt": "Rewrite instruction:",
|
||||
"ai.rewrite_placeholder": "e.g. make the tone more formal and add a conclusion",
|
||||
"ai.rewrite_title": "Rewrite text according to your instructions",
|
||||
"ai.select_text": "AI: select text to process",
|
||||
"ai.shorter": "Shorter",
|
||||
"ai.shorter_hint": "Makes the text more concise",
|
||||
"ai.simplify": "Simplify",
|
||||
"ai.simplify_hint": "Simplifies the language",
|
||||
"ai.summarize": "Summarize",
|
||||
"ai.text_processed": "AI: text processed ✓",
|
||||
"ai.text_rewritten": "AI: text rewritten",
|
||||
"ai.to_canvas": "Convert to Canvas",
|
||||
"ai.to_list": "Convert to list",
|
||||
"ai.to_table": "Convert to table",
|
||||
"ai.tone_hint": "Changes the text tone",
|
||||
"ai.toolbar_edit": "Edit",
|
||||
"ai.toolbar_generate": "Generate",
|
||||
"ai.toolbar_rewrite": "Rewrite",
|
||||
@@ -130,6 +135,7 @@
|
||||
"ai.toolbar_toolbox": "Toolbox",
|
||||
"ai.toolbar_translate": "Translate",
|
||||
"ai.toolbox": "🧰 Toolbox",
|
||||
"ai.toolbox_hint": "Conversion tools",
|
||||
"ai.translate_to": "Translate to {lang}",
|
||||
"auth.cannot_delete_self": "Cannot delete your own account",
|
||||
"auth.logged_out": "Signed out",
|
||||
@@ -624,6 +630,8 @@
|
||||
"header.menu_about_desc": "Version, credits & info",
|
||||
"header.menu_admin": "Administration",
|
||||
"header.menu_admin_desc": "Manage users",
|
||||
"header.menu_api": "API",
|
||||
"header.menu_api_desc": "OpenAPI documentation",
|
||||
"header.menu_config": "Settings",
|
||||
"header.menu_config_desc": "Application settings",
|
||||
"header.menu_help": "User guide",
|
||||
@@ -1597,6 +1605,7 @@
|
||||
"bookslm.export": "Export conversation",
|
||||
"bookslm.toggle_sidebar": "Hide/Show AI sidebar",
|
||||
"bookslm.copy": "Copy",
|
||||
"bookslm.error": "AI service error",
|
||||
"bookslm.regenerate": "Regenerate",
|
||||
"bookslm.suggestion_summary": "Summarize this directory",
|
||||
"bookslm.suggestion_themes": "What are the main themes?",
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"ai.error_invalid_key": "clé API invalide",
|
||||
"ai.explain": "Expliquer",
|
||||
"ai.fix_spelling": "Corriger l'orthographe",
|
||||
"ai.fix_spelling_hint": "Corrige les fautes",
|
||||
"ai.frontmatter": "Générer frontmatter",
|
||||
"ai.generate": "Générer",
|
||||
"ai.hint_add_details": "Ajoute des détails",
|
||||
@@ -113,16 +114,20 @@
|
||||
"ai.rewrite": "💬 Réécrire",
|
||||
"ai.rewrite_done": "AI: texte réécrit",
|
||||
"ai.rewrite_prompt": "Instruction de réécriture :",
|
||||
"ai.rewrite_placeholder": "Ex. : rends le ton plus formel et ajoute une conclusion",
|
||||
"ai.rewrite_title": "Réécrit le texte selon vos instructions",
|
||||
"ai.select_text": "AI: sélectionnez du texte à traiter",
|
||||
"ai.shorter": "Raccourcir",
|
||||
"ai.shorter_hint": "Rend le texte plus concis",
|
||||
"ai.simplify": "Simplifier",
|
||||
"ai.simplify_hint": "Simplifie le langage",
|
||||
"ai.summarize": "Résumer",
|
||||
"ai.text_processed": "AI: texte traité ✓",
|
||||
"ai.text_rewritten": "AI: texte réécrit",
|
||||
"ai.to_canvas": "Convertir en Canvas",
|
||||
"ai.to_list": "Convertir en liste",
|
||||
"ai.to_table": "Convertir en tableau",
|
||||
"ai.tone_hint": "Change le ton du texte",
|
||||
"ai.toolbar_edit": "Éditer",
|
||||
"ai.toolbar_generate": "Générer",
|
||||
"ai.toolbar_rewrite": "Réécrire",
|
||||
@@ -130,6 +135,7 @@
|
||||
"ai.toolbar_toolbox": "Toolbox",
|
||||
"ai.toolbar_translate": "Traduire",
|
||||
"ai.toolbox": "🧰 Boîte",
|
||||
"ai.toolbox_hint": "Outils de conversion",
|
||||
"ai.translate_to": "Traduire en {lang}",
|
||||
"auth.cannot_delete_self": "Impossible de supprimer son propre compte",
|
||||
"auth.logged_out": "Déconnecté",
|
||||
@@ -624,6 +630,8 @@
|
||||
"header.menu_about_desc": "Version, crédits & info",
|
||||
"header.menu_admin": "Administration",
|
||||
"header.menu_admin_desc": "Gérer les utilisateurs",
|
||||
"header.menu_api": "API",
|
||||
"header.menu_api_desc": "Documentation OpenAPI",
|
||||
"header.menu_config": "Configurations",
|
||||
"header.menu_config_desc": "Paramètres de l'application",
|
||||
"header.menu_help": "Guide d'utilisation",
|
||||
@@ -1597,6 +1605,7 @@
|
||||
"bookslm.export": "Exporter la conversation",
|
||||
"bookslm.toggle_sidebar": "Masquer/Afficher la sidebar AI",
|
||||
"bookslm.copy": "Copier",
|
||||
"bookslm.error": "Erreur du service AI",
|
||||
"bookslm.regenerate": "Régénérer",
|
||||
"bookslm.suggestion_summary": "Résume ce répertoire",
|
||||
"bookslm.suggestion_themes": "Quels sont les thèmes principaux ?",
|
||||
|
||||
@@ -2675,6 +2675,88 @@ select {
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
/* AI custom-rewrite modal — replaces the browser prompt() for a consistent,
|
||||
theme-aware and keyboard-accessible experience. */
|
||||
.ai-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10050;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.ai-modal {
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 18px 18px 14px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.ai-modal-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-modal-hint {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ai-modal-input {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
min-height: 72px;
|
||||
padding: 8px 10px;
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ai-modal-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.25);
|
||||
}
|
||||
|
||||
.ai-modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.ai-modal-btn {
|
||||
padding: 6px 14px;
|
||||
font-size: 0.8rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-modal-btn:hover { background: var(--bg-hover); }
|
||||
|
||||
.ai-modal-btn.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ai-modal-btn.primary:hover { filter: brightness(1.08); }
|
||||
|
||||
.editor-title {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 0.9rem;
|
||||
|
||||
Reference in New Issue
Block a user