feat(ai): sidebar epuree, suivi visuel des requetes et correctif menu @ (BUG-012, #82)
BUG-012: le menu @ est toujours rendu et _mentionVault() retombe sur le vault de contexte puis le premier vault disponible. #82: suppression des intitules Fournisseur & modele / Fournisseur:, description du contexte General en info-bulle, placeholder retire, bouton Envoyer en emoji, indicateur d'activite (envoi/reception/outils/confirmation/succes/echec), SW_VERSION v5.
This commit is contained in:
+2
-6
@@ -139,13 +139,10 @@ async function _buildPickerUI() {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'ai-picker';
|
||||
|
||||
// ── Provider select (compact) ──
|
||||
const providerLabel = document.createElement('span');
|
||||
providerLabel.className = 'ai-picker-label';
|
||||
providerLabel.textContent = t('ai.provider') + ':';
|
||||
|
||||
// ── Provider select (compact, no label to keep the toolbar discreet) ──
|
||||
const providerSelect = document.createElement('select');
|
||||
providerSelect.className = 'ai-picker-select';
|
||||
providerSelect.setAttribute('aria-label', t('ai.provider'));
|
||||
const defaultOpt = document.createElement('option');
|
||||
defaultOpt.value = '';
|
||||
defaultOpt.textContent = t('ai.provider_default');
|
||||
@@ -445,7 +442,6 @@ async function _buildPickerUI() {
|
||||
_renderList();
|
||||
}
|
||||
|
||||
wrap.appendChild(providerLabel);
|
||||
wrap.appendChild(providerSelect);
|
||||
wrap.appendChild(modelSelect);
|
||||
wrap.appendChild(combo);
|
||||
|
||||
+79
-18
@@ -107,6 +107,8 @@ class BooksLM {
|
||||
this._menuKind = null;
|
||||
// Monotonic token used to drop stale async menu renders (fast typing).
|
||||
this._menuSeq = 0;
|
||||
// Timer that hides the "done" activity indicator after a short delay.
|
||||
this._activityHideTimer = null;
|
||||
}
|
||||
|
||||
_readAgentMode() {
|
||||
@@ -333,6 +335,20 @@ class BooksLM {
|
||||
return this._resolveVault();
|
||||
}
|
||||
|
||||
/**
|
||||
* Concrete vault used to browse files in the `@` mention menu. Falls back to
|
||||
* the sidebar's selected context vault (then the first vault) so the menu can
|
||||
* list files even in General mode where no vault is otherwise resolved.
|
||||
*/
|
||||
_mentionVault() {
|
||||
const resolved = this._contextVault();
|
||||
if (resolved) return resolved;
|
||||
const selected = state.selectedContextVault;
|
||||
if (selected && selected !== 'all') return selected;
|
||||
const first = Array.isArray(state.allVaults) ? state.allVaults[0] : null;
|
||||
return (first && (first.name || first)) || null;
|
||||
}
|
||||
|
||||
/** Open a file mentioned in an assistant answer in the main viewer. */
|
||||
async _openFileLink(path) {
|
||||
const vault = this._resolveVault();
|
||||
@@ -433,7 +449,9 @@ class BooksLM {
|
||||
return {
|
||||
icon: 'sparkles',
|
||||
title: t('ai.context_general'),
|
||||
subtitle: t('ai.context_general_desc'),
|
||||
// The description is kept for the tooltip only, to keep the header clean.
|
||||
subtitle: '',
|
||||
tooltip: t('ai.context_general_desc'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -468,12 +486,15 @@ class BooksLM {
|
||||
</div>
|
||||
<div class="bookslm-history-menu hidden"></div>
|
||||
<div class="bookslm-toolbar">
|
||||
<span class="bookslm-toolbar-label">${t('ai.model_settings')}</span>
|
||||
<span class="bookslm-picker-host"></span>
|
||||
</div>
|
||||
<div class="bookslm-status"></div>
|
||||
<div class="bookslm-suggestions"></div>
|
||||
<div class="bookslm-messages"></div>
|
||||
<div class="bookslm-activity hidden" role="status" aria-live="polite">
|
||||
<span class="bookslm-activity-spinner" aria-hidden="true"></span>
|
||||
<span class="bookslm-activity-text"></span>
|
||||
</div>
|
||||
<div class="bookslm-attachments"></div>
|
||||
<div class="bookslm-menu-layer">
|
||||
<div class="bookslm-command-menu hidden" role="listbox" aria-label="${t('ai.commands')}"></div>
|
||||
@@ -482,8 +503,8 @@ class BooksLM {
|
||||
<div class="bookslm-input-area">
|
||||
<button class="bookslm-btn-attach" title="${t('ai.attach_image')}" aria-label="${t('ai.attach_image')}"><i data-lucide="image-plus" style="width:18px;height:18px"></i></button>
|
||||
<input type="file" class="bookslm-file-input" accept="image/*" multiple hidden>
|
||||
<textarea placeholder="${t('bookslm.placeholder')}" rows="1" aria-label="${t('bookslm.placeholder')}"></textarea>
|
||||
<button class="bookslm-btn-send" title="${t('bookslm.send')}">${t('bookslm.send')}</button>
|
||||
<textarea placeholder="${t('bookslm.placeholder')}" rows="1" aria-label="${t('ai.composer_label')}"></textarea>
|
||||
<button class="bookslm-btn-send" title="${t('bookslm.send')}" aria-label="${t('bookslm.send')}">✈️</button>
|
||||
</div>
|
||||
<div class="bookslm-input-hint">${t('ai.input_hint')}</div>
|
||||
`;
|
||||
@@ -820,12 +841,7 @@ class BooksLM {
|
||||
async _showMentionMenu(query) {
|
||||
if (!this._panel) return;
|
||||
const seq = ++this._menuSeq;
|
||||
const vault = this._contextVault();
|
||||
if (!vault && !query) {
|
||||
// Nothing to list without a vault; a typed query can still search all.
|
||||
this._hideMenus();
|
||||
return;
|
||||
}
|
||||
const vault = this._mentionVault();
|
||||
try {
|
||||
let results = [];
|
||||
if (query) {
|
||||
@@ -834,24 +850,25 @@ class BooksLM {
|
||||
`/api/tree-search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(searchVault)}`
|
||||
);
|
||||
results = (data.results || []).slice(0, 20);
|
||||
} else {
|
||||
} else if (vault) {
|
||||
const data = await api(
|
||||
`/api/vault/${encodeURIComponent(vault)}/files?limit=20&recursive=true`
|
||||
);
|
||||
results = (data.files || []).slice(0, 20).map((f) => ({ path: f.path, type: 'file' }));
|
||||
results = (data.files || []).slice(0, 20).map((f) => ({ path: f.path, type: 'file', vault }));
|
||||
}
|
||||
if (seq !== this._menuSeq) return; // superseded while the request ran
|
||||
const items = results.map((r) => {
|
||||
const path = r.path || r;
|
||||
const rawType = r.type || (this._classifyPath(path) === 'dir' ? 'dir' : 'file');
|
||||
const type = rawType === 'directory' ? 'dir' : rawType;
|
||||
return { id: path, label: path, type, vault: r.vault || null };
|
||||
return { id: path, label: path, type, vault: r.vault || vault || null };
|
||||
});
|
||||
// Always render the menu (even empty) so typing `@` gives visible feedback.
|
||||
this._renderMenu('mention', items, (item) => this._selectMention(item));
|
||||
} catch (e) {
|
||||
if (seq !== this._menuSeq) return;
|
||||
console.warn('AI assistant: mention search failed', e);
|
||||
this._hideMenus();
|
||||
this._renderMenu('mention', [], () => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1185,37 +1202,68 @@ class BooksLM {
|
||||
_updateHeader() {
|
||||
if (!this._panel) return;
|
||||
const meta = this._contextMeta();
|
||||
const titles = this._panel.querySelector('.bookslm-header-titles');
|
||||
const title = this._panel.querySelector('.bookslm-title');
|
||||
const subtitle = this._panel.querySelector('.bookslm-subtitle');
|
||||
const icon = this._panel.querySelector('.bookslm-context-icon i');
|
||||
if (title) title.textContent = meta.title;
|
||||
if (subtitle) subtitle.textContent = meta.subtitle || '';
|
||||
if (titles) titles.title = meta.tooltip || meta.subtitle || meta.title || '';
|
||||
if (icon) icon.setAttribute('data-lucide', meta.icon);
|
||||
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the assistant activity state in the panel (visual workflow).
|
||||
* @param {'idle'|'working'|'done'|'error'} kind
|
||||
* @param {string} [text]
|
||||
*/
|
||||
_setActivity(kind, text) {
|
||||
if (!this._panel) return;
|
||||
if (this._activityHideTimer) {
|
||||
clearTimeout(this._activityHideTimer);
|
||||
this._activityHideTimer = null;
|
||||
}
|
||||
const el = this._panel.querySelector('.bookslm-activity');
|
||||
if (!el) return;
|
||||
const label = el.querySelector('.bookslm-activity-text');
|
||||
if (label) label.textContent = text || '';
|
||||
el.classList.toggle('hidden', kind === 'idle');
|
||||
el.classList.toggle('working', kind === 'working');
|
||||
el.classList.toggle('done', kind === 'done');
|
||||
el.classList.toggle('error', kind === 'error');
|
||||
}
|
||||
|
||||
_updateStatus(data) {
|
||||
if (!this._panel) return;
|
||||
const status = this._panel.querySelector('.bookslm-status');
|
||||
if (!status) return;
|
||||
|
||||
if (data && data.error) {
|
||||
status.classList.remove('hidden');
|
||||
status.innerHTML = `<span class="bookslm-status-error">⚠ ${t('bookslm.no_context')}</span>`;
|
||||
return;
|
||||
}
|
||||
if (data && Array.isArray(data.files)) {
|
||||
if (data.scope === MODE.GENERAL) {
|
||||
// No document context: keep the header clean. The description is
|
||||
// available as a tooltip on the header title.
|
||||
status.classList.add('hidden');
|
||||
status.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
status.classList.remove('hidden');
|
||||
const count = data.files.length;
|
||||
const chars = data.total_chars || 0;
|
||||
const maxChars = data.max_total_chars || 200000;
|
||||
const pct = Math.min(100, Math.round((chars / maxChars) * 100));
|
||||
const label = data.scope === MODE.GENERAL
|
||||
? t('ai.context_general_desc')
|
||||
: `${t('bookslm.files_indexed', { count })}, ${t('bookslm.chars_loaded', { chars: Math.round(chars / 1000) + 'K' })}`;
|
||||
const label = `${t('bookslm.files_indexed', { count })}, ${t('bookslm.chars_loaded', { chars: Math.round(chars / 1000) + 'K' })}`;
|
||||
status.innerHTML = `
|
||||
<span class="bookslm-status-text">${label}</span>
|
||||
<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.classList.remove('hidden');
|
||||
status.textContent = '⏳ …';
|
||||
}
|
||||
}
|
||||
@@ -1630,6 +1678,7 @@ class BooksLM {
|
||||
if (sendBtn) sendBtn.disabled = true;
|
||||
|
||||
this._abortCtrl = new AbortController();
|
||||
this._setActivity('working', t('ai.activity_sending'));
|
||||
|
||||
try {
|
||||
let resp = await this._postChat(payload);
|
||||
@@ -1643,6 +1692,7 @@ class BooksLM {
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
this._setActivity('working', t('ai.activity_streaming'));
|
||||
await this._streamResponse(resp, assistantMsg, payload);
|
||||
|
||||
if (!assistantMsg.sources.length && this._contextFiles.length) {
|
||||
@@ -1654,10 +1704,15 @@ class BooksLM {
|
||||
})
|
||||
.map((f) => f.path || f);
|
||||
}
|
||||
this._setActivity('done', t('ai.activity_done'));
|
||||
this._activityHideTimer = setTimeout(() => this._setActivity('idle'), 1500);
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
assistantMsg.content = `⚠ Error: ${e.message}`;
|
||||
console.warn('AI assistant chat error:', e);
|
||||
this._setActivity('error', t('ai.activity_error'));
|
||||
} else {
|
||||
this._setActivity('idle');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1714,6 +1769,7 @@ class BooksLM {
|
||||
|
||||
if (currentEvent === 'tool') {
|
||||
assistantMsg.toolCalls.push({ name: data.name, ok: data.ok !== false, arguments: data.arguments || {} });
|
||||
this._setActivity('working', t('ai.activity_tool', { name: data.name }));
|
||||
return;
|
||||
}
|
||||
if (currentEvent === 'confirmation') {
|
||||
@@ -1721,15 +1777,20 @@ class BooksLM {
|
||||
pending: data.pending || data,
|
||||
messages: data.messages || [],
|
||||
};
|
||||
this._setActivity('working', t('ai.activity_confirmation'));
|
||||
return;
|
||||
}
|
||||
if (currentEvent === 'error' || data.error) {
|
||||
const msg = data.error || t('bookslm.error');
|
||||
assistantMsg.content += (assistantMsg.content ? '\n\n' : '') + '⚠ ' + msg;
|
||||
this._setActivity('error', t('ai.activity_error'));
|
||||
return;
|
||||
}
|
||||
const chunk = data.token || data.content || '';
|
||||
if (chunk) assistantMsg.content += chunk;
|
||||
if (chunk) {
|
||||
if (!assistantMsg.content) this._setActivity('working', t('ai.activity_streaming'));
|
||||
assistantMsg.content += chunk;
|
||||
}
|
||||
if (data.sources) assistantMsg.sources = data.sources;
|
||||
};
|
||||
|
||||
|
||||
@@ -142,6 +142,13 @@
|
||||
"ai.cap_video": "Video",
|
||||
"ai.cap_vision": "Vision capable",
|
||||
"ai.commands": "Commands",
|
||||
"ai.activity_sending": "Sending request…",
|
||||
"ai.activity_streaming": "Receiving response…",
|
||||
"ai.activity_tool": "Tool: {name}…",
|
||||
"ai.activity_confirmation": "Waiting for confirmation…",
|
||||
"ai.activity_error": "Request failed",
|
||||
"ai.activity_done": "Done",
|
||||
"ai.composer_label": "Message to the assistant",
|
||||
"ai.create_skill": "Create a skill",
|
||||
"ai.image": "image",
|
||||
"ai.keys": "Stored API keys",
|
||||
@@ -1712,7 +1719,7 @@
|
||||
"bookslm.title": "BooksLM",
|
||||
"bookslm.files_indexed": "{count} files indexed",
|
||||
"bookslm.chars_loaded": "{chars} chars loaded",
|
||||
"bookslm.placeholder": "Ask a question about these documents...",
|
||||
"bookslm.placeholder": "",
|
||||
"bookslm.send": "Send",
|
||||
"bookslm.new_conversation": "New conversation",
|
||||
"bookslm.export": "Export conversation",
|
||||
|
||||
@@ -142,6 +142,13 @@
|
||||
"ai.cap_video": "Video",
|
||||
"ai.cap_vision": "Vision capable",
|
||||
"ai.commands": "Commandes",
|
||||
"ai.activity_sending": "Envoi de la requête…",
|
||||
"ai.activity_streaming": "Réponse en cours…",
|
||||
"ai.activity_tool": "Outil : {name}…",
|
||||
"ai.activity_confirmation": "En attente de confirmation…",
|
||||
"ai.activity_error": "Échec de la requête",
|
||||
"ai.activity_done": "Terminé",
|
||||
"ai.composer_label": "Message à l'assistant",
|
||||
"ai.create_skill": "Créer un skill",
|
||||
"ai.image": "image",
|
||||
"ai.keys": "Clés API enregistrées",
|
||||
@@ -1712,7 +1719,7 @@
|
||||
"bookslm.title": "BooksLM",
|
||||
"bookslm.files_indexed": "{count} fichiers indexés",
|
||||
"bookslm.chars_loaded": "{chars} caractères chargés",
|
||||
"bookslm.placeholder": "Posez une question sur ces documents...",
|
||||
"bookslm.placeholder": "",
|
||||
"bookslm.send": "Envoyer",
|
||||
"bookslm.new_conversation": "Nouvelle conversation",
|
||||
"bookslm.export": "Exporter la conversation",
|
||||
|
||||
@@ -9202,6 +9202,19 @@ body.popup-mode .content-area {
|
||||
.bookslm-status .bookslm-context-bar { flex: 1; height: 4px; border-radius: 2px;
|
||||
background: var(--surface2); overflow: hidden; }
|
||||
.bookslm-status .bookslm-context-fill { height: 100%; border-radius: 2px; background: var(--accent); transition: width 0.3s; }
|
||||
/* Assistant activity indicator (visual workflow: sending/streaming/tools/done/error). */
|
||||
.bookslm-activity { display: flex; align-items: center; gap: 8px; padding: 6px 16px;
|
||||
font-size: 12px; color: var(--text-secondary); border-top: 1px solid var(--border);
|
||||
background: var(--bg-secondary); }
|
||||
.bookslm-activity.hidden { display: none; }
|
||||
.bookslm-activity.error { color: #f87171; }
|
||||
.bookslm-activity.done { color: var(--success, #3fb950); }
|
||||
.bookslm-activity-spinner { width: 12px; height: 12px; border: 2px solid var(--border);
|
||||
border-top-color: var(--accent); border-radius: 50%; flex-shrink: 0;
|
||||
animation: bookslm-spin 0.8s linear infinite; }
|
||||
.bookslm-activity.done .bookslm-activity-spinner,
|
||||
.bookslm-activity.error .bookslm-activity-spinner { animation: none; border-top-color: currentColor; }
|
||||
@keyframes bookslm-spin { to { transform: rotate(360deg); } }
|
||||
.bookslm-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.bookslm-bubble { max-width: 85%; padding: 10px 14px; border-radius: 12px; font-size: 14px; line-height: 1.5; word-wrap: break-word; }
|
||||
.bookslm-bubble.user { align-self: flex-end; background: var(--accent); color: #fff; border-bottom-right-radius: 4px; }
|
||||
@@ -9220,6 +9233,7 @@ body.popup-mode .content-area {
|
||||
.bookslm-input-area textarea:focus { border-color: var(--accent); outline: none; }
|
||||
.bookslm-input-area button { padding: 8px 16px; border-radius: 8px; border: none;
|
||||
background: var(--accent); color: #fff; cursor: pointer; font-size: 14px; }
|
||||
.bookslm-input-area button.bookslm-btn-send { padding: 8px 12px; font-size: 16px; line-height: 1; }
|
||||
.bookslm-input-area button:hover { opacity: 0.9; }
|
||||
.bookslm-input-area button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.bookslm-input-hint { padding: 0 16px 10px; font-size: 10px; color: var(--text-secondary);
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
|
||||
* a separate store. Bumping SW_VERSION invalidates it on every release.
|
||||
*/
|
||||
const SW_VERSION = 'v4';
|
||||
const SW_VERSION = 'v5';
|
||||
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
|
||||
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
|
||||
const API_CACHE = `obsigate-api-${SW_VERSION}`;
|
||||
|
||||
Reference in New Issue
Block a user