feat(ai): commandes @/ & skills, analyse d'images et capacites des modeles (#81)
This commit is contained in:
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user