feat(ai): commandes @/ & skills, analyse d'images et capacites des modeles (#81)
This commit is contained in:
@@ -2015,6 +2015,18 @@
|
||||
<option value="">--</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-row" style="align-items: flex-start">
|
||||
<label
|
||||
class="config-label"
|
||||
data-i18n="config.ai_capabilities"
|
||||
>Capacites du modele</label
|
||||
>
|
||||
<div
|
||||
id="cfg-ai-default-model-caps"
|
||||
class="ai-picker-caps"
|
||||
style="flex: 1"
|
||||
></div>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label
|
||||
class="config-label"
|
||||
@@ -4259,6 +4271,25 @@
|
||||
chercher) ; les actions de modification demandent une
|
||||
confirmation avec aperçu des changements.
|
||||
</li>
|
||||
<li data-i18n="help.assistant_resize">
|
||||
Le bord gauche du panneau est redimensionnable ; la largeur est
|
||||
mémorisée.
|
||||
</li>
|
||||
<li data-i18n="help.assistant_at">
|
||||
Tapez <code>@</code> pour joindre un fichier ou un répertoire au
|
||||
contexte, ou pour attacher une image d'un répertoire.
|
||||
</li>
|
||||
<li data-i18n="help.assistant_slash">
|
||||
Tapez <code>/</code> pour lancer un skill (recherche, résumé,
|
||||
correction, plan…) ou une commande admin
|
||||
(<code>/help</code>, <code>/providers</code>,
|
||||
<code>/model</code>, <code>/keys</code>).
|
||||
</li>
|
||||
<li data-i18n="help.assistant_images">
|
||||
Collez une image dans la zone de saisie (ou joignez-la) pour
|
||||
l'analyser avec un modèle compatible vision ; les capacités du
|
||||
modèle sélectionné sont affichées.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -33,6 +33,12 @@ async function aiAction(endpoint, text, extra = {}) {
|
||||
// State is stored in localStorage so the user choice persists across sections.
|
||||
const PICKER_STORAGE_KEY = 'obsigate_ai_picker';
|
||||
|
||||
// Capability flags exposed by GET /api/ai/model-capabilities.
|
||||
const AI_CAPABILITY_KEYS = [
|
||||
'chat', 'embeddings', 'rerank', 'images',
|
||||
'video', 'audio_speech', 'audio_transcription', 'vision',
|
||||
];
|
||||
|
||||
function _readPicker() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(PICKER_STORAGE_KEY) || '{}');
|
||||
@@ -45,6 +51,32 @@ function _writePicker(state) {
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
/** Fetch the curated capability flags for a provider/model pair. */
|
||||
async function getModelCapabilities(provider, model) {
|
||||
if (!provider) return null;
|
||||
try {
|
||||
const data = await api(
|
||||
`/api/ai/model-capabilities?provider=${encodeURIComponent(provider)}&model=${encodeURIComponent(model || '')}`
|
||||
);
|
||||
return data.capabilities || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Render the capability checklist (☑/□) for a model. */
|
||||
function renderCapabilityList(caps) {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'ai-picker-caps';
|
||||
if (!caps) return box;
|
||||
AI_CAPABILITY_KEYS.forEach((key) => {
|
||||
const item = document.createElement('span');
|
||||
const on = !!caps[key];
|
||||
item.className = 'ai-cap-item' + (on ? ' on' : '');
|
||||
item.textContent = `${on ? '☑' : '□'} ${t(`ai.cap_${key}`)}`;
|
||||
box.appendChild(item);
|
||||
});
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -128,6 +160,24 @@ async function _buildPickerUI() {
|
||||
placeholderOpt.textContent = t('ai.model_default');
|
||||
modelSelect.appendChild(placeholderOpt);
|
||||
|
||||
// Capability checklist, refreshed whenever the selected model changes.
|
||||
const capsHost = document.createElement('div');
|
||||
capsHost.className = 'ai-picker-caps-host';
|
||||
|
||||
async function _updateCaps() {
|
||||
capsHost.innerHTML = '';
|
||||
if (!pickerState.provider || !pickerState.model) {
|
||||
delete pickerState.capabilities;
|
||||
_writePicker(pickerState);
|
||||
return;
|
||||
}
|
||||
const caps = await getModelCapabilities(pickerState.provider, pickerState.model);
|
||||
if (!caps) return;
|
||||
pickerState.capabilities = caps;
|
||||
_writePicker(pickerState);
|
||||
capsHost.appendChild(renderCapabilityList(caps));
|
||||
}
|
||||
|
||||
async function _loadModels(provider) {
|
||||
modelSelect.innerHTML = '';
|
||||
const ph = document.createElement('option');
|
||||
@@ -148,6 +198,13 @@ async function _buildPickerUI() {
|
||||
if (m === pickerState.model && provider === pickerState.provider) opt.selected = true;
|
||||
modelSelect.appendChild(opt);
|
||||
});
|
||||
// If the provider default model is known, reflect it so capabilities
|
||||
// are shown even when the user keeps the "default" placeholder.
|
||||
if (!pickerState.model && data.models && data.models.length) {
|
||||
pickerState.model = data.models[0];
|
||||
_writePicker(pickerState);
|
||||
}
|
||||
await _updateCaps();
|
||||
} catch (err) {
|
||||
modelSelect.innerHTML = '';
|
||||
const opt = document.createElement('option');
|
||||
@@ -161,7 +218,9 @@ async function _buildPickerUI() {
|
||||
pickerState.provider = providerSelect.value || null;
|
||||
// Reset model when provider changes
|
||||
pickerState.model = null;
|
||||
delete pickerState.capabilities;
|
||||
_writePicker(pickerState);
|
||||
capsHost.innerHTML = '';
|
||||
if (pickerState.provider) {
|
||||
_loadModels(pickerState.provider);
|
||||
} else {
|
||||
@@ -175,7 +234,9 @@ async function _buildPickerUI() {
|
||||
|
||||
modelSelect.addEventListener('change', () => {
|
||||
pickerState.model = modelSelect.value || null;
|
||||
delete pickerState.capabilities;
|
||||
_writePicker(pickerState);
|
||||
_updateCaps();
|
||||
});
|
||||
|
||||
// Load models for the initial provider selection
|
||||
@@ -185,6 +246,7 @@ async function _buildPickerUI() {
|
||||
|
||||
wrap.appendChild(providerSelect);
|
||||
wrap.appendChild(modelSelect);
|
||||
wrap.appendChild(capsHost);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -712,6 +774,9 @@ export {
|
||||
_readPicker,
|
||||
_writePicker,
|
||||
PICKER_STORAGE_KEY,
|
||||
AI_CAPABILITY_KEYS,
|
||||
getModelCapabilities,
|
||||
renderCapabilityList,
|
||||
_buildPickerUI as buildAIPickerUI,
|
||||
_promptRewriteInstruction,
|
||||
};
|
||||
|
||||
+666
-6
@@ -5,7 +5,7 @@
|
||||
// • documents — the file(s) currently open in tabs/panes
|
||||
// • general — no document open: app help + file/directory creation actions
|
||||
import { t } from './i18n.js';
|
||||
import { buildAIPickerUI } from './ai.js';
|
||||
import { buildAIPickerUI, getModelCapabilities } from './ai.js';
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
import { api, AuthManager } from './auth.js';
|
||||
import { showToast } from './ui.js';
|
||||
@@ -23,6 +23,12 @@ export const MODE = Object.freeze({
|
||||
const FILE_EXT_RE = /\.(?:md|markdown|mdx|txt|pdf|excalidraw|canvas|png|jpe?g|gif|svg|webp|bmp|avif|csv|tsv|json|ya?ml|toml|ini|cfg|conf|html?|css|scss|sass|less|jsx?|tsx?|vue|svelte|py|rb|go|rs|java|kt|c|cc|cpp|h|hpp|cs|php|sh|bash|zsh|ps1|bat|sql|xml|log|tex|org|rtf|docx?|xlsx?|pptx?)$/i;
|
||||
// A path-like token: optional `dir/` segments followed by a final segment.
|
||||
const PATH_WITH_DIR_RE = /(^|[\s(>"'])((?:[\w.-]+\/)+[\w.-]+\.[A-Za-z0-9]{1,8})/g;
|
||||
// Image files that can be attached to a vision-capable model.
|
||||
const IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|svg|webp|bmp|avif|ico)$/i;
|
||||
// Resizable panel bounds (px).
|
||||
const PANEL_MIN_WIDTH = 320;
|
||||
const PANEL_MAX_WIDTH = 1000;
|
||||
const PANEL_WIDTH_KEY = 'obsigate-bookslm-width';
|
||||
|
||||
/**
|
||||
* Gather the files currently open across the singleton TabManager and every
|
||||
@@ -77,6 +83,16 @@ class BooksLM {
|
||||
// Agent mode: routes chat through /api/ai/bookslm/agent so the model can
|
||||
// call read/search tools and propose mutations (confirmation cards).
|
||||
this._agentMode = this._readAgentMode();
|
||||
// Ad-hoc context added with `@` (files/directories) and images attached
|
||||
// by paste or by mentioning an image file.
|
||||
this._adhocFiles = [];
|
||||
this._adhocDirs = [];
|
||||
this._images = [];
|
||||
this._activeSkill = null;
|
||||
this._skills = [];
|
||||
this._menuItems = [];
|
||||
this._menuIndex = 0;
|
||||
this._menuKind = null;
|
||||
}
|
||||
|
||||
_readAgentMode() {
|
||||
@@ -147,12 +163,17 @@ class BooksLM {
|
||||
this._effectiveScope = mode;
|
||||
this._messages = [];
|
||||
this._contextFiles = [];
|
||||
this._adhocFiles = [];
|
||||
this._adhocDirs = [];
|
||||
this._images = [];
|
||||
this._activeSkill = null;
|
||||
this._isLoading = true;
|
||||
this._loadHistory();
|
||||
|
||||
if (!this._panel) {
|
||||
this._panel = this._render();
|
||||
document.body.appendChild(this._panel);
|
||||
this._applyPanelWidth();
|
||||
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||||
}
|
||||
|
||||
@@ -163,6 +184,7 @@ class BooksLM {
|
||||
this._updateStatus();
|
||||
this._renderMessages();
|
||||
this._showSuggestions();
|
||||
this._renderAttachments();
|
||||
this._closeHistoryMenu();
|
||||
this._renderHistoryMenu();
|
||||
|
||||
@@ -331,7 +353,8 @@ class BooksLM {
|
||||
// ── Context ─────────────────────────────────────────────────────────
|
||||
|
||||
async _loadContext() {
|
||||
if (this._mode === MODE.GENERAL) {
|
||||
const hasAdhoc = this._adhocFiles.length > 0 || this._adhocDirs.length > 0;
|
||||
if (this._mode === MODE.GENERAL && !hasAdhoc) {
|
||||
this._effectiveScope = MODE.GENERAL;
|
||||
this._contextFiles = [];
|
||||
this._updateStatus({ files: [], total_chars: 0, file_count: 0, scope: MODE.GENERAL, max_total_chars: 200000 });
|
||||
@@ -341,9 +364,11 @@ class BooksLM {
|
||||
|
||||
const payload = {
|
||||
mode: this._mode,
|
||||
vault: this._vault,
|
||||
vault: this._vault || this._resolveVault(),
|
||||
directory: this._directory,
|
||||
context_files: this._mode === MODE.DOCUMENTS ? this._documents.map((d) => d.path) : [],
|
||||
extra_files: this._adhocFiles.map((f) => f.path),
|
||||
extra_directories: this._adhocDirs.map((d) => d.path),
|
||||
};
|
||||
try {
|
||||
const data = await api('/api/ai/bookslm/context', {
|
||||
@@ -396,6 +421,7 @@ class BooksLM {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'bookslm-panel';
|
||||
panel.innerHTML = `
|
||||
<div class="bookslm-resize-handle" role="separator" aria-orientation="vertical" title="${t('ai.resize_panel')}" aria-label="${t('ai.resize_panel')}"></div>
|
||||
<div class="bookslm-header">
|
||||
<button class="bookslm-btn-toggle" title="${t('bookslm.toggle_sidebar')}" aria-label="${t('bookslm.toggle_sidebar')}">
|
||||
<i data-lucide="panel-right-close" style="width:16px;height:16px"></i>
|
||||
@@ -422,7 +448,14 @@ class BooksLM {
|
||||
<div class="bookslm-status"></div>
|
||||
<div class="bookslm-suggestions"></div>
|
||||
<div class="bookslm-messages"></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>
|
||||
<div class="bookslm-mention-menu hidden" role="listbox" aria-label="${t('ai.add_context')}"></div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
@@ -460,11 +493,14 @@ class BooksLM {
|
||||
else this._openFileLink(path);
|
||||
});
|
||||
|
||||
// Close the session menu when clicking elsewhere in the panel.
|
||||
// Close the session / command menus when clicking elsewhere in the panel.
|
||||
panel.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.bookslm-history-menu') && !e.target.closest('.bookslm-btn-history')) {
|
||||
this._closeHistoryMenu();
|
||||
}
|
||||
if (!e.target.closest('.bookslm-command-menu') && !e.target.closest('.bookslm-mention-menu')) {
|
||||
this._hideMenus();
|
||||
}
|
||||
});
|
||||
panel.querySelector('.bookslm-btn-fullscreen').addEventListener('click', () => {
|
||||
this._isFullscreen = !this._isFullscreen;
|
||||
@@ -478,12 +514,31 @@ class BooksLM {
|
||||
|
||||
panel.querySelector('.bookslm-btn-send').addEventListener('click', () => this._sendMessage());
|
||||
|
||||
// Resizable panel (drag the left edge).
|
||||
const resizeHandle = panel.querySelector('.bookslm-resize-handle');
|
||||
if (resizeHandle) resizeHandle.addEventListener('mousedown', (e) => this._startResize(e));
|
||||
|
||||
// Image attachment button (pick image files from disk).
|
||||
const attachBtn = panel.querySelector('.bookslm-btn-attach');
|
||||
const fileInput = panel.querySelector('.bookslm-file-input');
|
||||
if (attachBtn && fileInput) {
|
||||
attachBtn.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', () => {
|
||||
this._addImageFiles(fileInput.files);
|
||||
fileInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
const textarea = panel.querySelector('textarea');
|
||||
textarea.addEventListener('input', () => {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 140) + 'px';
|
||||
this._onComposerInput();
|
||||
});
|
||||
textarea.addEventListener('paste', (e) => this._onPaste(e));
|
||||
textarea.addEventListener('keydown', (e) => {
|
||||
// Menu navigation (mention/command) takes precedence over submit.
|
||||
if (this._handleMenuKey(e)) return;
|
||||
if (e.key !== 'Enter') return;
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
// Ctrl/Cmd+Enter → insert a newline at the caret.
|
||||
@@ -504,6 +559,580 @@ class BooksLM {
|
||||
return panel;
|
||||
}
|
||||
|
||||
// ── Panel resize ────────────────────────────────────────────────────
|
||||
|
||||
_applyPanelWidth() {
|
||||
if (!this._panel) return;
|
||||
let width = 0;
|
||||
try { width = parseInt(localStorage.getItem(PANEL_WIDTH_KEY) || '', 10) || 0; } catch { /* */ }
|
||||
if (width) this._panel.style.width = `${this._clampPanelWidth(width)}px`;
|
||||
}
|
||||
|
||||
_clampPanelWidth(width) {
|
||||
return Math.max(PANEL_MIN_WIDTH, Math.min(PANEL_MAX_WIDTH, width));
|
||||
}
|
||||
|
||||
_startResize(e) {
|
||||
if (!this._panel || this._isFullscreen) return;
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startWidth = this._panel.getBoundingClientRect().width;
|
||||
const onMove = (ev) => {
|
||||
// Panel is anchored to the right edge: dragging left widens it.
|
||||
const next = this._clampPanelWidth(startWidth + (startX - ev.clientX));
|
||||
this._panel.style.width = `${next}px`;
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
document.body.classList.remove('bookslm-resizing');
|
||||
try {
|
||||
localStorage.setItem(PANEL_WIDTH_KEY, String(Math.round(this._panel.getBoundingClientRect().width)));
|
||||
} catch { /* */ }
|
||||
};
|
||||
document.body.classList.add('bookslm-resizing');
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}
|
||||
|
||||
// ── Attachments: ad-hoc context chips & images ──────────────────────
|
||||
|
||||
_renderAttachments() {
|
||||
if (!this._panel) return;
|
||||
const host = this._panel.querySelector('.bookslm-attachments');
|
||||
if (!host) return;
|
||||
host.innerHTML = '';
|
||||
const hasAny = this._adhocFiles.length || this._adhocDirs.length || this._images.length || this._activeSkill;
|
||||
host.classList.toggle('empty', !hasAny);
|
||||
if (!hasAny) return;
|
||||
|
||||
if (this._activeSkill) {
|
||||
const skill = this._skillById(this._activeSkill);
|
||||
host.appendChild(this._chip(
|
||||
`${skill ? skill.icon : '✨'} ${skill ? skill.label : this._activeSkill}`,
|
||||
'skill',
|
||||
() => { this._activeSkill = null; this._renderAttachments(); },
|
||||
));
|
||||
}
|
||||
for (const file of this._adhocFiles) {
|
||||
host.appendChild(this._chip(`📄 ${file.path}`, 'file', () => this._removeAdhocFile(file.path)));
|
||||
}
|
||||
for (const dir of this._adhocDirs) {
|
||||
host.appendChild(this._chip(`📁 ${dir.path}`, 'dir', () => this._removeAdhocDir(dir.path)));
|
||||
}
|
||||
this._images.forEach((img, index) => {
|
||||
const chip = this._chip(`🖼 ${img.name || t('ai.image')}`, 'image', () => {
|
||||
this._images.splice(index, 1);
|
||||
this._renderAttachments();
|
||||
});
|
||||
if (img.previewUrl) {
|
||||
const thumb = document.createElement('img');
|
||||
thumb.className = 'bookslm-chip-thumb';
|
||||
thumb.src = img.previewUrl;
|
||||
chip.insertBefore(thumb, chip.firstChild);
|
||||
}
|
||||
host.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
_chip(label, kind, onRemove) {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = `bookslm-chip bookslm-chip-${kind}`;
|
||||
const text = document.createElement('span');
|
||||
text.className = 'bookslm-chip-label';
|
||||
text.textContent = label;
|
||||
text.title = label;
|
||||
chip.appendChild(text);
|
||||
const close = document.createElement('button');
|
||||
close.type = 'button';
|
||||
close.className = 'bookslm-chip-remove';
|
||||
close.setAttribute('aria-label', t('ai.remove'));
|
||||
close.textContent = '×';
|
||||
close.addEventListener('click', onRemove);
|
||||
chip.appendChild(close);
|
||||
return chip;
|
||||
}
|
||||
|
||||
_addAdhocFile(path) {
|
||||
if (!path || this._adhocFiles.some((f) => f.path === path)) return;
|
||||
this._adhocFiles.push({ path });
|
||||
this._renderAttachments();
|
||||
this._loadContext();
|
||||
}
|
||||
|
||||
_addAdhocDir(path) {
|
||||
const clean = path.replace(/\/+$/, '');
|
||||
if (!clean || this._adhocDirs.some((d) => d.path === clean)) return;
|
||||
this._adhocDirs.push({ path: clean });
|
||||
this._renderAttachments();
|
||||
this._loadContext();
|
||||
}
|
||||
|
||||
_removeAdhocFile(path) {
|
||||
this._adhocFiles = this._adhocFiles.filter((f) => f.path !== path);
|
||||
this._renderAttachments();
|
||||
this._loadContext();
|
||||
}
|
||||
|
||||
_removeAdhocDir(path) {
|
||||
this._adhocDirs = this._adhocDirs.filter((d) => d.path !== path);
|
||||
this._renderAttachments();
|
||||
this._loadContext();
|
||||
}
|
||||
|
||||
_addImageFile(file) {
|
||||
if (!file || !file.type || !file.type.startsWith('image/')) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = String(reader.result || '');
|
||||
const match = result.match(/^data:([^;,]+);base64,(.*)$/);
|
||||
if (!match) return;
|
||||
this._images.push({
|
||||
name: file.name || t('ai.image'),
|
||||
mime_type: match[1],
|
||||
data: match[2],
|
||||
previewUrl: result,
|
||||
});
|
||||
this._renderAttachments();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
_addImageFiles(files) {
|
||||
if (!files) return;
|
||||
Array.from(files).forEach((file) => this._addImageFile(file));
|
||||
}
|
||||
|
||||
_onPaste(e) {
|
||||
const items = e.clipboardData && e.clipboardData.items;
|
||||
if (!items) return;
|
||||
const images = [];
|
||||
for (const item of items) {
|
||||
if (item.kind === 'file' && item.type && item.type.startsWith('image/')) {
|
||||
const file = item.getAsFile();
|
||||
if (file) images.push(file);
|
||||
}
|
||||
}
|
||||
if (images.length) {
|
||||
e.preventDefault();
|
||||
images.forEach((file) => this._addImageFile(file));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Composer: `/` commands and `@` mentions ─────────────────────────
|
||||
|
||||
_onComposerInput() {
|
||||
if (!this._panel) return;
|
||||
const textarea = this._panel.querySelector('textarea');
|
||||
if (!textarea) return;
|
||||
const pos = textarea.selectionStart;
|
||||
const before = textarea.value.slice(0, pos);
|
||||
const command = before.match(/(?:^|\s)\/([a-z0-9-]*)$/i);
|
||||
if (command) {
|
||||
this._showCommandMenu(command[1].toLowerCase());
|
||||
return;
|
||||
}
|
||||
const mention = before.match(/(?:^|\s)@([\w./-]*)$/);
|
||||
if (mention) {
|
||||
this._showMentionMenu(mention[1]);
|
||||
return;
|
||||
}
|
||||
this._hideMenus();
|
||||
}
|
||||
|
||||
async _ensureSkills() {
|
||||
if (this._skills.length) return this._skills;
|
||||
try {
|
||||
const data = await api('/api/ai/skills');
|
||||
this._skills = [...(data.skills || []), ...(data.commands || [])];
|
||||
} catch (e) {
|
||||
console.warn('AI assistant: skills load failed', e);
|
||||
this._skills = [];
|
||||
}
|
||||
return this._skills;
|
||||
}
|
||||
|
||||
_skillById(id) {
|
||||
return this._skills.find((s) => s.id === id) || null;
|
||||
}
|
||||
|
||||
async _showCommandMenu(query) {
|
||||
if (!this._panel) return;
|
||||
const skills = await this._ensureSkills();
|
||||
const q = (query || '').toLowerCase();
|
||||
const matches = skills.filter((s) =>
|
||||
!q || s.id.includes(q) || (s.label || '').toLowerCase().includes(q));
|
||||
this._renderMenu('command', matches, (skill) => this._selectCommand(skill));
|
||||
}
|
||||
|
||||
_selectCommand(skill) {
|
||||
const textarea = this._panel && this._panel.querySelector('textarea');
|
||||
this._hideMenus();
|
||||
if (skill.type === 'admin') {
|
||||
this._runAdminCommand(skill.id);
|
||||
if (textarea) {
|
||||
textarea.value = textarea.value.replace(/(^|\s)\/[a-z0-9-]*$/i, '$1').trim();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (skill.special === 'create_skill') {
|
||||
if (textarea) textarea.value = textarea.value.replace(/(^|\s)\/[a-z0-9-]*$/i, '$1').trim();
|
||||
this._promptCreateSkill();
|
||||
return;
|
||||
}
|
||||
this._activeSkill = skill.id;
|
||||
if (textarea) {
|
||||
textarea.value = textarea.value.replace(/(^|\s)\/[a-z0-9-]*$/i, '$1').trim();
|
||||
textarea.focus();
|
||||
}
|
||||
this._renderAttachments();
|
||||
}
|
||||
|
||||
async _showMentionMenu(query) {
|
||||
if (!this._panel) return;
|
||||
const vault = this._resolveVault();
|
||||
if (!vault) {
|
||||
this._hideMenus();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let results = [];
|
||||
if (query) {
|
||||
const data = await api(
|
||||
`/api/tree-search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vault)}`
|
||||
);
|
||||
results = (data.results || []).slice(0, 20);
|
||||
} else {
|
||||
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' }));
|
||||
}
|
||||
const items = results.map((r) => ({
|
||||
id: r.path || r,
|
||||
label: r.path || r,
|
||||
type: r.type || (this._classifyPath(r.path || r) === 'dir' ? 'dir' : 'file'),
|
||||
}));
|
||||
this._renderMenu('mention', items, (item) => this._selectMention(item));
|
||||
} catch (e) {
|
||||
console.warn('AI assistant: mention search failed', e);
|
||||
this._hideMenus();
|
||||
}
|
||||
}
|
||||
|
||||
_selectMention(item) {
|
||||
const textarea = this._panel && this._panel.querySelector('textarea');
|
||||
this._hideMenus();
|
||||
if (textarea) {
|
||||
textarea.value = textarea.value.replace(/(^|\s)@[\w./-]*$/, '$1').trim();
|
||||
textarea.focus();
|
||||
}
|
||||
const path = item.id;
|
||||
if (IMAGE_EXT_RE.test(path)) {
|
||||
this._addImagePath(path);
|
||||
} else if (item.type === 'dir' || this._classifyPath(path) === 'dir') {
|
||||
this._addAdhocDir(path);
|
||||
} else {
|
||||
this._addAdhocFile(path);
|
||||
}
|
||||
}
|
||||
|
||||
_addImagePath(path) {
|
||||
if (!path || this._images.some((img) => img.path === path)) return;
|
||||
this._images.push({ path, name: path.split('/').pop() });
|
||||
this._renderAttachments();
|
||||
}
|
||||
|
||||
_renderMenu(kind, items, onPick) {
|
||||
if (!this._panel) return;
|
||||
const menu = this._panel.querySelector(`.bookslm-${kind}-menu`);
|
||||
if (!menu) return;
|
||||
menu.innerHTML = '';
|
||||
this._menuKind = kind;
|
||||
this._menuItems = items;
|
||||
this._menuIndex = 0;
|
||||
if (!items.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'bookslm-menu-empty';
|
||||
empty.textContent = t('ai.no_results');
|
||||
menu.appendChild(empty);
|
||||
menu.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
items.forEach((item, index) => {
|
||||
const el = document.createElement('button');
|
||||
el.type = 'button';
|
||||
el.className = 'bookslm-menu-item' + (index === 0 ? ' active' : '');
|
||||
el.dataset.index = String(index);
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'bookslm-menu-icon';
|
||||
icon.textContent = item.icon || (item.type === 'dir' ? '📁' : item.type === 'admin' ? '⚙️' : '📄');
|
||||
const label = document.createElement('span');
|
||||
label.className = 'bookslm-menu-label';
|
||||
label.textContent = item.label || item.id;
|
||||
el.appendChild(icon);
|
||||
el.appendChild(label);
|
||||
if (item.usage) {
|
||||
const usage = document.createElement('span');
|
||||
usage.className = 'bookslm-menu-usage';
|
||||
usage.textContent = item.usage;
|
||||
el.appendChild(usage);
|
||||
} else if (item.description) {
|
||||
const desc = document.createElement('span');
|
||||
desc.className = 'bookslm-menu-desc';
|
||||
desc.textContent = item.description;
|
||||
el.appendChild(desc);
|
||||
}
|
||||
el.addEventListener('mouseenter', () => this._highlightMenu(index));
|
||||
el.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
onPick(item);
|
||||
});
|
||||
menu.appendChild(el);
|
||||
});
|
||||
// Show the other menu hidden.
|
||||
this._panel.querySelectorAll('.bookslm-command-menu, .bookslm-mention-menu').forEach((m) => {
|
||||
if (m !== menu) m.classList.add('hidden');
|
||||
});
|
||||
menu.classList.remove('hidden');
|
||||
}
|
||||
|
||||
_highlightMenu(index) {
|
||||
if (!this._panel || !this._menuKind) return;
|
||||
const menu = this._panel.querySelector(`.bookslm-${this._menuKind}-menu`);
|
||||
if (!menu) return;
|
||||
this._menuIndex = index;
|
||||
menu.querySelectorAll('.bookslm-menu-item').forEach((el, i) => {
|
||||
el.classList.toggle('active', i === index);
|
||||
});
|
||||
}
|
||||
|
||||
_handleMenuKey(e) {
|
||||
if (!this._panel || !this._menuKind) return false;
|
||||
const menu = this._panel.querySelector(`.bookslm-${this._menuKind}-menu`);
|
||||
if (!menu || menu.classList.contains('hidden')) return false;
|
||||
const items = this._menuItems;
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this._hideMenus();
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (!items.length) return true;
|
||||
const delta = e.key === 'ArrowDown' ? 1 : -1;
|
||||
const next = (this._menuIndex + delta + items.length) % items.length;
|
||||
this._highlightMenu(next);
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
const item = items[this._menuIndex];
|
||||
if (!item) return true;
|
||||
const menuEl = menu.querySelector('.bookslm-menu-item.active');
|
||||
if (menuEl) menuEl.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
_hideMenus() {
|
||||
if (!this._panel) return;
|
||||
this._panel.querySelectorAll('.bookslm-command-menu, .bookslm-mention-menu').forEach((m) => {
|
||||
m.classList.add('hidden');
|
||||
});
|
||||
this._menuKind = null;
|
||||
this._menuItems = [];
|
||||
this._menuIndex = 0;
|
||||
}
|
||||
|
||||
// ── Admin commands (`/help`, `/providers`, …) ───────────────────────
|
||||
|
||||
async _runAdminCommand(id) {
|
||||
switch (id) {
|
||||
case 'help': return this._cmdHelp();
|
||||
case 'providers': return this._cmdProviders();
|
||||
case 'keys': return this._cmdKeys();
|
||||
case 'provider': return this._cmdSwitchProvider();
|
||||
case 'model': return this._cmdSwitchModel();
|
||||
default: return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
_pushAssistantNote(markdown) {
|
||||
this._messages.push({ role: 'assistant', content: markdown, sources: [], toolCalls: [], confirmation: null });
|
||||
this._renderMessages();
|
||||
this._saveHistory();
|
||||
}
|
||||
|
||||
async _cmdHelp() {
|
||||
const skills = await this._ensureSkills();
|
||||
const lines = [`## ${t('ai.commands')}`, ''];
|
||||
for (const s of skills) {
|
||||
lines.push(`- ${s.icon || ''} \`/${s.id}\` — ${s.description || s.label}`);
|
||||
}
|
||||
this._pushAssistantNote(lines.join('\n'));
|
||||
}
|
||||
|
||||
async _cmdProviders() {
|
||||
let providers = {};
|
||||
try {
|
||||
const status = await api('/api/ai/status');
|
||||
providers = status.providers || {};
|
||||
} catch { /* */ }
|
||||
const lines = [`## ${t('ai.providers')}`, ''];
|
||||
const names = Object.keys(providers);
|
||||
if (!names.length) lines.push(t('ai.none'));
|
||||
for (const name of names) {
|
||||
const info = providers[name] || {};
|
||||
lines.push(`- ${info.available ? '✅' : '❌'} **${name}** — ${info.model || '—'}`);
|
||||
}
|
||||
this._pushAssistantNote(lines.join('\n'));
|
||||
}
|
||||
|
||||
async _cmdKeys() {
|
||||
const lines = [`## ${t('ai.keys')}`, ''];
|
||||
try {
|
||||
const keys = await api('/api/config/ai-keys');
|
||||
for (const [env, value] of Object.entries(keys)) {
|
||||
lines.push(`- ${value ? '✅' : '❌'} **${env.replace('_API_KEY', '').toLowerCase()}**`);
|
||||
}
|
||||
} catch {
|
||||
const status = await api('/api/ai/status').catch(() => ({ providers: {} }));
|
||||
for (const [name, info] of Object.entries(status.providers || {})) {
|
||||
lines.push(`- ${info.available ? '✅' : '❌'} **${name}**`);
|
||||
}
|
||||
}
|
||||
this._pushAssistantNote(lines.join('\n'));
|
||||
}
|
||||
|
||||
_cmdSwitchProvider() {
|
||||
const names = Array.from(this._panel.querySelectorAll('.ai-picker-select option'))
|
||||
.map((o) => o.value).filter(Boolean);
|
||||
const list = names.join(', ');
|
||||
this._pushAssistantNote(
|
||||
`### ${t('ai.provider')}\n\n${t('ai.usage_provider', { list })}\n\n\`/provider <nom>\``,
|
||||
);
|
||||
}
|
||||
|
||||
_cmdSwitchModel() {
|
||||
const names = Array.from(this._panel.querySelectorAll('.ai-picker-model option'))
|
||||
.map((o) => o.value).filter(Boolean);
|
||||
const list = names.length ? names.join(', ') : '—';
|
||||
this._pushAssistantNote(
|
||||
`### ${t('ai.model')}\n\n${t('ai.usage_model', { list })}\n\n\`/model <nom>\``,
|
||||
);
|
||||
}
|
||||
|
||||
/** Apply a provider/model selection from an admin command text. */
|
||||
_applyPickerSelection(provider, model) {
|
||||
if (!this._panel) return;
|
||||
const providerSelect = this._panel.querySelector('.ai-picker-select');
|
||||
const modelSelect = this._panel.querySelector('.ai-picker-model');
|
||||
let state = {};
|
||||
try { state = JSON.parse(localStorage.getItem('obsigate_ai_picker') || '{}'); } catch { /* */ }
|
||||
if (provider && providerSelect) {
|
||||
state.provider = provider;
|
||||
state.model = model || null;
|
||||
delete state.capabilities;
|
||||
providerSelect.value = provider;
|
||||
providerSelect.dispatchEvent(new Event('change'));
|
||||
if (model) {
|
||||
const applyModel = () => {
|
||||
if (modelSelect && Array.from(modelSelect.options).some((o) => o.value === model)) {
|
||||
modelSelect.value = model;
|
||||
modelSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
};
|
||||
setTimeout(applyModel, 400);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (model && modelSelect) {
|
||||
state.model = model;
|
||||
delete state.capabilities;
|
||||
modelSelect.value = model;
|
||||
modelSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle `/provider X` and `/model X` typed directly (with an argument). */
|
||||
_maybeRunAdminWithArg(text) {
|
||||
const provider = text.match(/^\/provider\s+(\S+)\s*$/i);
|
||||
if (provider) {
|
||||
this._applyPickerSelection(provider[1], null);
|
||||
this._pushAssistantNote(`✅ ${t('ai.provider_changed', { name: provider[1] })}`);
|
||||
return true;
|
||||
}
|
||||
const model = text.match(/^\/model\s+(\S+)\s*$/i);
|
||||
if (model) {
|
||||
this._applyPickerSelection(null, model[1]);
|
||||
this._pushAssistantNote(`✅ ${t('ai.model_changed', { name: model[1] })}`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Custom skill creation (`/create-new-skill`) ─────────────────────
|
||||
|
||||
_promptCreateSkill() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'bookslm-modal-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="bookslm-modal" role="dialog" aria-modal="true" aria-label="${t('ai.create_skill')}">
|
||||
<h3 class="bookslm-modal-title">${t('ai.create_skill')}</h3>
|
||||
<label class="bookslm-modal-field">${t('ai.skill_id')}
|
||||
<input class="bookslm-skill-id" placeholder="mon-skill">
|
||||
</label>
|
||||
<label class="bookslm-modal-field">${t('ai.skill_label')}
|
||||
<input class="bookslm-skill-label" placeholder="${t('ai.skill_label')}">
|
||||
</label>
|
||||
<label class="bookslm-modal-field">${t('ai.skill_prompt')}
|
||||
<textarea class="bookslm-skill-prompt" rows="4" placeholder="${t('ai.skill_prompt_placeholder')}"></textarea>
|
||||
</label>
|
||||
<div class="bookslm-modal-error"></div>
|
||||
<div class="bookslm-modal-actions">
|
||||
<button type="button" class="bookslm-modal-cancel">${t('button.cancel')}</button>
|
||||
<button type="button" class="bookslm-modal-ok primary">${t('common.save')}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
const close = () => overlay.remove();
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
overlay.querySelector('.bookslm-modal-cancel').addEventListener('click', close);
|
||||
overlay.querySelector('.bookslm-modal-ok').addEventListener('click', async () => {
|
||||
const id = overlay.querySelector('.bookslm-skill-id').value.trim().toLowerCase();
|
||||
const label = overlay.querySelector('.bookslm-skill-label').value.trim();
|
||||
const prompt = overlay.querySelector('.bookslm-skill-prompt').value.trim();
|
||||
const err = overlay.querySelector('.bookslm-modal-error');
|
||||
try {
|
||||
await api('/api/ai/skills', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, label, prompt }),
|
||||
});
|
||||
this._skills = [];
|
||||
close();
|
||||
showToast(t('ai.skill_created', { name: label || id }), 'success');
|
||||
} catch (e) {
|
||||
err.textContent = e.message || String(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Vision support ──────────────────────────────────────────────────
|
||||
|
||||
async _modelSupportsVision() {
|
||||
let state = {};
|
||||
try { state = JSON.parse(localStorage.getItem('obsigate_ai_picker') || '{}'); } catch { /* */ }
|
||||
const provider = state.provider || null;
|
||||
const model = state.model || '';
|
||||
if (!provider) return true; // provider default resolution is server-side
|
||||
let caps = state.capabilities;
|
||||
if (!caps) caps = await getModelCapabilities(provider, model);
|
||||
return !caps || caps.vision !== false;
|
||||
}
|
||||
|
||||
_updateHeader() {
|
||||
if (!this._panel) return;
|
||||
const meta = this._contextMeta();
|
||||
@@ -884,6 +1513,26 @@ class BooksLM {
|
||||
const text = (textarea.value || '').trim();
|
||||
if (!text || this._isLoading) return;
|
||||
|
||||
// Admin slash-commands are handled locally (no LLM round-trip).
|
||||
if (/^\/(?:help|providers|keys|provider|model)(?:\s|$)/i.test(text)) {
|
||||
this._hideMenus();
|
||||
textarea.value = '';
|
||||
textarea.style.height = 'auto';
|
||||
if (!this._maybeRunAdminWithArg(text)) {
|
||||
this._runAdminCommand(text.slice(1).split(/\s+/)[0].toLowerCase());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Vision gate: block sending images to a non-vision model.
|
||||
if (this._images.length) {
|
||||
const visionOk = await this._modelSupportsVision();
|
||||
if (!visionOk) {
|
||||
showToast(t('ai.vision_unsupported'), 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
textarea.value = '';
|
||||
textarea.style.height = 'auto';
|
||||
|
||||
@@ -908,9 +1557,15 @@ class BooksLM {
|
||||
|
||||
const payload = {
|
||||
mode: this._mode,
|
||||
vault: this._vault,
|
||||
vault: this._vault || this._resolveVault(),
|
||||
directory: this._directory,
|
||||
context_files: this._mode === MODE.DOCUMENTS ? this._documents.map((d) => d.path) : [],
|
||||
extra_files: this._adhocFiles.map((f) => f.path),
|
||||
extra_directories: this._adhocDirs.map((d) => d.path),
|
||||
images: this._images.map((img) => (
|
||||
img.path ? { path: img.path } : { data: img.data, mime_type: img.mime_type }
|
||||
)),
|
||||
skill: this._activeSkill,
|
||||
message: text,
|
||||
conversation_history: history,
|
||||
provider,
|
||||
@@ -966,7 +1621,12 @@ class BooksLM {
|
||||
|
||||
/** POST to /chat or /agent depending on the agent-mode toggle. */
|
||||
_postChat(payload) {
|
||||
const endpoint = this._agentMode ? '/api/ai/bookslm/agent' : '/api/ai/bookslm/chat';
|
||||
// Images require the plain multimodal chat endpoint (the agent loop is
|
||||
// text/tool oriented).
|
||||
const hasImages = Array.isArray(payload.images) && payload.images.length > 0;
|
||||
const endpoint = (this._agentMode && !hasImages)
|
||||
? '/api/ai/bookslm/agent'
|
||||
: '/api/ai/bookslm/chat';
|
||||
const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) };
|
||||
return fetch(endpoint, {
|
||||
method: 'POST',
|
||||
|
||||
+23
-1
@@ -6,6 +6,7 @@ import { syncVaultSelectors, setSelectedVaultContext, refreshSidebarForContext,
|
||||
import { escapeHtml, safeCreateIcons } from './utils.js';
|
||||
import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js';
|
||||
import { t, setLocale, getLocale } from './i18n.js';
|
||||
import { getModelCapabilities, renderCapabilityList } from './ai.js';
|
||||
|
||||
let _recentTimestampTimer = null;
|
||||
let _recentFilesCache = [];
|
||||
@@ -625,6 +626,13 @@ function initConfigModal() {
|
||||
await _populateAIModelOptions(aiDefaultProviderSel.value, models[aiDefaultProviderSel.value] || "");
|
||||
});
|
||||
}
|
||||
const aiDefaultModelSel = document.getElementById("cfg-ai-default-model");
|
||||
if (aiDefaultModelSel) {
|
||||
aiDefaultModelSel.addEventListener("change", () => {
|
||||
const providerSel = document.getElementById("cfg-ai-default-provider");
|
||||
_renderConfigModelCaps(providerSel ? providerSel.value : null, aiDefaultModelSel.value);
|
||||
});
|
||||
}
|
||||
// Load existing keys/defaults on modal open
|
||||
document.getElementById("config-open-btn").addEventListener("click", loadAIKeys);
|
||||
document.getElementById("config-open-btn").addEventListener("click", loadAIDefaults);
|
||||
@@ -1440,7 +1448,10 @@ async function _populateAIModelOptions(provider, selected) {
|
||||
const modelSel = document.getElementById("cfg-ai-default-model");
|
||||
if (!modelSel) return;
|
||||
modelSel.innerHTML = '<option value="">--</option>';
|
||||
if (!provider) return;
|
||||
if (!provider) {
|
||||
_renderConfigModelCaps(null, null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const m = await api("/api/config/ai-models?provider=" + encodeURIComponent(provider));
|
||||
const list = (m && m.models) || [];
|
||||
@@ -1451,9 +1462,20 @@ async function _populateAIModelOptions(provider, selected) {
|
||||
modelSel.innerHTML += '<option value="' + selected + '">' + selected + '</option>';
|
||||
}
|
||||
modelSel.value = selected || "";
|
||||
_renderConfigModelCaps(provider, modelSel.value);
|
||||
} catch(e) { console.warn("Failed to load default models for", provider, e); }
|
||||
}
|
||||
|
||||
async function _renderConfigModelCaps(provider, model) {
|
||||
const host = document.getElementById("cfg-ai-default-model-caps");
|
||||
if (!host) return;
|
||||
host.innerHTML = "";
|
||||
if (!provider || !model) return;
|
||||
const caps = await getModelCapabilities(provider, model);
|
||||
if (!caps) return;
|
||||
host.appendChild(renderCapabilityList(caps));
|
||||
}
|
||||
|
||||
async function saveAIKeys() {
|
||||
const keys = {};
|
||||
for (const [id, name] of Object.entries(AI_KEY_MAP)) {
|
||||
|
||||
@@ -128,6 +128,35 @@
|
||||
"ai.model_offline": "offline",
|
||||
"ai.model_load_error": "Load error",
|
||||
"ai.model_settings": "Provider & model",
|
||||
"ai.add_context": "Add context",
|
||||
"ai.attach_image": "Attach an image",
|
||||
"ai.cap_audio_speech": "Audio Speech",
|
||||
"ai.cap_audio_transcription": "Audio Transcriptions",
|
||||
"ai.cap_chat": "Chat",
|
||||
"ai.cap_embeddings": "Embeddings",
|
||||
"ai.cap_images": "Images",
|
||||
"ai.cap_rerank": "Rerank",
|
||||
"ai.cap_video": "Video",
|
||||
"ai.cap_vision": "Vision capable",
|
||||
"ai.commands": "Commands",
|
||||
"ai.create_skill": "Create a skill",
|
||||
"ai.image": "image",
|
||||
"ai.keys": "Stored API keys",
|
||||
"ai.model_changed": "Model changed: {name}",
|
||||
"ai.no_results": "No results",
|
||||
"ai.none": "None",
|
||||
"ai.provider_changed": "Provider changed: {name}",
|
||||
"ai.providers": "Active providers",
|
||||
"ai.remove": "Remove",
|
||||
"ai.resize_panel": "Resize panel",
|
||||
"ai.skill_created": "Skill \"{name}\" created",
|
||||
"ai.skill_id": "Identifier",
|
||||
"ai.skill_label": "Name",
|
||||
"ai.skill_prompt": "Prompt / instructions",
|
||||
"ai.skill_prompt_placeholder": "Describe the expected behaviour of the skill…",
|
||||
"ai.usage_model": "Available models: {list}",
|
||||
"ai.usage_provider": "Available providers: {list}",
|
||||
"ai.vision_unsupported": "The selected model does not support image analysis",
|
||||
"ai.quota_exceeded": "AI: quota exceeded or payment required",
|
||||
"ai.rewrite": "💬 Rewrite",
|
||||
"ai.rewrite_done": "AI: text rewritten",
|
||||
@@ -317,6 +346,7 @@
|
||||
"config.add_webhook": "Add",
|
||||
"config.ai_deepseek_label": "DeepSeek API Key",
|
||||
"config.ai_default_model": "Default model",
|
||||
"config.ai_capabilities": "Model capabilities",
|
||||
"config.ai_default_provider": "Default provider",
|
||||
"config.ai_gemini_label": "Gemini API Key",
|
||||
"config.ai_keys": "AI API keys",
|
||||
@@ -1236,6 +1266,10 @@
|
||||
"help.assistant_links": "Cited files and paths are links: click a file to open it, a folder to reveal it in the tree.",
|
||||
"help.assistant_sessions": "The header history icon lists past sessions (reopen or delete); “+” starts a new conversation.",
|
||||
"help.assistant_agent": "The \"agent mode\" button enables tools (read, list, search); modifying actions require confirmation with a change preview.",
|
||||
"help.assistant_resize": "The left edge of the panel is resizable; the width is remembered.",
|
||||
"help.assistant_at": "Type @ to attach a file or directory to the context, or to attach an image from a directory.",
|
||||
"help.assistant_slash": "Type / to run a skill (research, summary, correction, plan…) or an admin command (/help, /providers, /model, /keys).",
|
||||
"help.assistant_images": "Paste an image into the composer (or attach one) to analyse it with a vision-capable model; the selected model's capabilities are displayed.",
|
||||
"help.toolbar_section": "AI toolbar",
|
||||
"help.tree_section": "Vault tree",
|
||||
"help.use_cases": "Use cases",
|
||||
|
||||
@@ -128,6 +128,35 @@
|
||||
"ai.model_offline": "hors ligne",
|
||||
"ai.model_load_error": "Erreur de chargement",
|
||||
"ai.model_settings": "Fournisseur & modèle",
|
||||
"ai.add_context": "Ajouter un contexte",
|
||||
"ai.attach_image": "Joindre une image",
|
||||
"ai.cap_audio_speech": "Audio Speech",
|
||||
"ai.cap_audio_transcription": "Audio Transcriptions",
|
||||
"ai.cap_chat": "Chat",
|
||||
"ai.cap_embeddings": "Embeddings",
|
||||
"ai.cap_images": "Images",
|
||||
"ai.cap_rerank": "Rerank",
|
||||
"ai.cap_video": "Video",
|
||||
"ai.cap_vision": "Vision capable",
|
||||
"ai.commands": "Commandes",
|
||||
"ai.create_skill": "Créer un skill",
|
||||
"ai.image": "image",
|
||||
"ai.keys": "Clés API enregistrées",
|
||||
"ai.model_changed": "Modèle changé : {name}",
|
||||
"ai.no_results": "Aucun résultat",
|
||||
"ai.none": "Aucun",
|
||||
"ai.provider_changed": "Fournisseur changé : {name}",
|
||||
"ai.providers": "Fournisseurs actifs",
|
||||
"ai.remove": "Retirer",
|
||||
"ai.resize_panel": "Redimensionner le panneau",
|
||||
"ai.skill_created": "Skill « {name} » créé",
|
||||
"ai.skill_id": "Identifiant",
|
||||
"ai.skill_label": "Nom",
|
||||
"ai.skill_prompt": "Prompt / instructions",
|
||||
"ai.skill_prompt_placeholder": "Décris le comportement attendu du skill…",
|
||||
"ai.usage_model": "Modèles disponibles : {list}",
|
||||
"ai.usage_provider": "Fournisseurs disponibles : {list}",
|
||||
"ai.vision_unsupported": "Le modèle sélectionné ne supporte pas l'analyse d'images",
|
||||
"ai.quota_exceeded": "AI: quota dépassé ou paiement requis",
|
||||
"ai.rewrite": "💬 Réécrire",
|
||||
"ai.rewrite_done": "AI: texte réécrit",
|
||||
@@ -317,6 +346,7 @@
|
||||
"config.add_webhook": "Ajouter",
|
||||
"config.ai_deepseek_label": "DeepSeek API Key",
|
||||
"config.ai_default_model": "Modèle par défaut",
|
||||
"config.ai_capabilities": "Capacités du modèle",
|
||||
"config.ai_default_provider": "Fournisseur par défaut",
|
||||
"config.ai_gemini_label": "Gemini API Key",
|
||||
"config.ai_keys": "Clés API IA",
|
||||
@@ -1236,6 +1266,10 @@
|
||||
"help.assistant_links": "Les fichiers et chemins cités sont des liens : cliquez sur un fichier pour l'ouvrir, sur un dossier pour le révéler dans l'arborescence.",
|
||||
"help.assistant_sessions": "L'icône historique de l'en-tête liste les sessions passées (recharger ou supprimer) ; « + » démarre une nouvelle conversation.",
|
||||
"help.assistant_agent": "Le bouton « mode agent » active les outils (lire, lister, chercher) ; les actions de modification demandent une confirmation avec aperçu des changements.",
|
||||
"help.assistant_resize": "Le bord gauche du panneau est redimensionnable ; la largeur est mémorisée.",
|
||||
"help.assistant_at": "Tapez @ pour joindre un fichier ou un répertoire au contexte, ou pour attacher une image d'un répertoire.",
|
||||
"help.assistant_slash": "Tapez / pour lancer un skill (recherche, résumé, correction, plan…) ou une commande admin (/help, /providers, /model, /keys).",
|
||||
"help.assistant_images": "Collez une image dans la zone de saisie (ou joignez-la) pour l'analyser avec un modèle compatible vision ; les capacités du modèle sélectionné sont affichées.",
|
||||
"help.toolbar_section": "Barre d'outils AI",
|
||||
"help.tree_section": "Arborescence des vaults",
|
||||
"help.use_cases": "Cas d'usage",
|
||||
|
||||
@@ -9307,7 +9307,69 @@ body.popup-mode .content-area {
|
||||
padding: 0 8px; border-radius: 6px; display: flex; align-items: center; }
|
||||
.bookslm-history-delete:hover { color: #f87171; background: rgba(248,113,113,0.12); }
|
||||
.bookslm-history-empty { padding: 10px; font-size: 12px; color: var(--text-secondary); text-align: center; }
|
||||
/* Resizable left edge. */
|
||||
.bookslm-resize-handle { position: absolute; left: 0; top: 0; bottom: 0; width: 6px;
|
||||
cursor: ew-resize; z-index: 30; background: transparent; }
|
||||
.bookslm-resize-handle:hover { background: var(--accent); opacity: 0.4; }
|
||||
.bookslm-panel.fullscreen .bookslm-resize-handle { display: none; }
|
||||
body.bookslm-resizing { cursor: ew-resize; user-select: none; }
|
||||
/* Attachment chips (ad-hoc context, skill, images). */
|
||||
.bookslm-attachments { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 12px 0; }
|
||||
.bookslm-attachments.empty { display: none; }
|
||||
.bookslm-chip { display: inline-flex; align-items: center; gap: 4px; max-width: 100%;
|
||||
padding: 3px 6px 3px 8px; border: 1px solid var(--border); border-radius: 14px;
|
||||
background: var(--surface2); font-size: 11px; color: var(--text-primary); }
|
||||
.bookslm-chip-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 220px; }
|
||||
.bookslm-chip-remove { background: none; border: none; cursor: pointer; color: var(--text-secondary);
|
||||
font-size: 14px; line-height: 1; padding: 0 2px; border-radius: 50%; }
|
||||
.bookslm-chip-remove:hover { color: #f87171; }
|
||||
.bookslm-chip-thumb { width: 18px; height: 18px; object-fit: cover; border-radius: 4px; }
|
||||
.bookslm-chip-skill { border-color: var(--accent); }
|
||||
/* Composer menus (`/` commands and `@` mentions). */
|
||||
.bookslm-menu-layer { position: relative; }
|
||||
.bookslm-command-menu, .bookslm-mention-menu { position: absolute; left: 12px; right: 12px; bottom: 4px;
|
||||
max-height: 260px; overflow-y: auto; background: var(--bg-primary); border: 1px solid var(--border);
|
||||
border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,0.35); padding: 4px; z-index: 40; }
|
||||
.bookslm-command-menu.hidden, .bookslm-mention-menu.hidden { display: none; }
|
||||
.bookslm-menu-item { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left;
|
||||
background: none; border: none; cursor: pointer; padding: 7px 9px; border-radius: 7px;
|
||||
color: var(--text-primary); font-size: 13px; }
|
||||
.bookslm-menu-item:hover, .bookslm-menu-item.active { background: var(--surface2); }
|
||||
.bookslm-menu-icon { flex-shrink: 0; }
|
||||
.bookslm-menu-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bookslm-menu-desc, .bookslm-menu-usage { margin-left: auto; font-size: 11px; color: var(--text-secondary);
|
||||
white-space: nowrap; }
|
||||
.bookslm-menu-empty { padding: 10px; font-size: 12px; color: var(--text-secondary); text-align: center; }
|
||||
/* Image attach button. */
|
||||
.bookslm-btn-attach { display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
background: none; border: 1px solid var(--border); color: var(--text-secondary); cursor: pointer;
|
||||
border-radius: 8px; padding: 7px; }
|
||||
.bookslm-btn-attach:hover { color: var(--accent); border-color: var(--accent); }
|
||||
.bookslm-file-input { display: none; }
|
||||
/* Create-skill modal. */
|
||||
.bookslm-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 2000;
|
||||
display: flex; align-items: center; justify-content: center; padding: 16px; }
|
||||
.bookslm-modal { background: var(--bg-primary); border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 18px; width: min(520px, 100%); box-shadow: 0 12px 40px rgba(0,0,0,0.45); }
|
||||
.bookslm-modal-title { margin: 0 0 12px; font-size: 15px; }
|
||||
.bookslm-modal-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px;
|
||||
color: var(--text-secondary); margin-bottom: 10px; }
|
||||
.bookslm-modal-field input, .bookslm-modal-field textarea { padding: 8px 10px; border-radius: 8px;
|
||||
border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-primary);
|
||||
font-size: 13px; font-family: inherit; }
|
||||
.bookslm-modal-error { color: #f87171; font-size: 12px; min-height: 14px; margin-bottom: 6px; }
|
||||
.bookslm-modal-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.bookslm-modal-actions button { padding: 7px 14px; border-radius: 8px; border: 1px solid var(--border);
|
||||
background: var(--surface); color: var(--text-primary); cursor: pointer; font-size: 13px; }
|
||||
.bookslm-modal-actions button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
/* Model capability checklist in the picker. */
|
||||
.ai-picker-caps-host { flex-basis: 100%; }
|
||||
.ai-picker-caps { display: flex; flex-wrap: wrap; gap: 4px 10px; margin-top: 4px; font-size: 10px;
|
||||
color: var(--text-secondary); }
|
||||
.ai-cap-item { white-space: nowrap; opacity: 0.65; }
|
||||
.ai-cap-item.on { opacity: 1; color: var(--accent); }
|
||||
@media (max-width: 768px) {
|
||||
.bookslm-resize-handle { display: none; }
|
||||
.bookslm-panel {
|
||||
width: 100%;
|
||||
/* Clear the fixed bottom toolbar so the input box and the send button
|
||||
|
||||
Reference in New Issue
Block a user