feat(ai): commandes @/ & skills, analyse d'images et capacites des modeles (#81)
CI / lint (push) Successful in 1m10s
CI / security (push) Successful in 43s
CI / test (push) Successful in 2m34s
CI / build (push) Successful in 43s
CI / e2e (push) Successful in 10m59s

This commit is contained in:
2026-09-12 11:38:46 -04:00
parent 88bb817a62
commit f049e208b6
25 changed files with 2581 additions and 31 deletions
+17
View File
@@ -14,6 +14,23 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Ajouté
- **#81 Assistant IA — Commandes `@`/`/`, skills, images & capacités des modèles** — l'assistant
devient plus flexible et multimodal. **Panneau redimensionnable** à la souris (largeur 320–1000 px
persistée). **Commande `@`** pour attacher des contextes ad-hoc (fichiers, répertoires) au contexte
courant. **Commande `/`** pour lancer des **skills** réutilisables (`/research`, `/resume`,
`/actions`, `/reformuler`, `/correction`, `/brainstorm`, `/plan`, `/ask`, `/meeting-note`,
`/livrable`) ou des commandes admin (`/help`, `/providers`, `/provider`, `/model`, `/keys`) ;
`/create-new-skill` permet de créer des skills personnalisés persistés par utilisateur
(`data/skills.json`). **Analyse d'images** : coller une image dans la zone de saisie ou référencer
une image d'un répertoire (`@image.png`), avec garde-fou de compatibilité vision. **Capacités des
modèles** (Chat, Embeddings, Rerank, Images, Video, Audio Speech, Audio Transcriptions, Vision)
affichées à la sélection dans l'assistant et le panneau de configuration. Backend : table curée
`backend/model_capabilities.py`, endpoints `GET /api/ai/model-capabilities` et `GET /api/ai/skills`,
support multimodal dans `backend/ai_chat.py` et `backend/bookslm_routes.py`. Tests :
`tests/test_model_capabilities.py` (16), `tests/test_skills.py` (16), `tests/test_ai_vision.py` (12)
+ extension `tests/test_ai_models.py` et `tests/frontend/ai.test.mjs` (+9). Détail :
[docs/features/ai-assistant-commands.md](./docs/features/ai-assistant-commands.md).
- **#77 Desktop — Signature des mises à jour Tauri** — la paire de clés `minisign`
de l'updater est générée et sa clé publique est embarquée dans
`desktop/tauri.conf.json` (`plugins.updater.pubkey`, remplace le placeholder) ;
+44 -2
View File
@@ -17,6 +17,7 @@ from __future__ import annotations
import json
import logging
import re
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from typing import Any
@@ -162,6 +163,46 @@ def _gemini_tools(tools: list[dict[str, Any]] | None) -> list[dict[str, Any]] |
return [{"functionDeclarations": declarations}]
_DATA_URL_RE = re.compile(r"^data:([^;,]+);base64,(.*)$", re.DOTALL)
def _content_to_gemini_parts(content: Any) -> list[dict[str, Any]]:
"""Convert OpenAI-style message content to Gemini ``parts``.
Accepts either a plain string or a multimodal content array
(``[{"type": "text", ...}, {"type": "image_url", ...}]``). Data URLs are
turned into ``inlineData`` parts so images can be sent to vision models.
"""
if content is None:
return [{"text": ""}]
if isinstance(content, str):
return [{"text": content}]
parts: list[dict[str, Any]] = []
if isinstance(content, list):
for item in content:
if isinstance(item, str):
parts.append({"text": item})
continue
if not isinstance(item, dict):
continue
item_type = item.get("type")
if item_type == "text":
parts.append({"text": item.get("text", "")})
elif item_type == "image_url":
url = (item.get("image_url") or {}).get("url", "")
match = _DATA_URL_RE.match(url or "")
if match:
parts.append({
"inlineData": {"mimeType": match.group(1), "data": match.group(2)},
})
elif url:
parts.append({"fileData": {"fileUri": url}})
if not parts:
parts.append({"text": ""})
return parts
def _gemini_contents(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
"""Split OpenAI-style messages into Gemini ``system`` text + ``contents``."""
system_parts: list[str] = []
@@ -169,7 +210,8 @@ def _gemini_contents(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str
for msg in messages:
role = msg.get("role")
if role == "system":
system_parts.append(msg.get("content") or "")
content = msg.get("content") or ""
system_parts.append(content if isinstance(content, str) else "")
elif role == "tool":
contents.append({
"role": "user",
@@ -183,7 +225,7 @@ def _gemini_contents(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str
else:
contents.append({
"role": "user" if role == "user" else "model",
"parts": [{"text": msg.get("content") or ""}],
"parts": _content_to_gemini_parts(msg.get("content")),
})
return "\n".join(p for p in system_parts if p).strip(), contents
+32 -1
View File
@@ -2,7 +2,7 @@
import logging
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from backend.ai import (
@@ -25,6 +25,8 @@ from backend.ai import (
ai_translate,
get_default_provider,
)
from backend.auth.middleware import require_auth
from backend.model_capabilities import get_model_capabilities
from backend.schemas import AIStatusResponse
logger = logging.getLogger("obsigate.ai_routes")
@@ -232,3 +234,32 @@ async def api_inline_complete(req: AIRequest):
async def api_to_canvas(req: AIRequest):
"""Convert to Mermaid diagram or outline."""
return await _handle(ai_convert_to_canvas, req)
class ModelCapabilitiesResponse(BaseModel):
"""Capabilities of a single provider/model pair."""
provider: str
model: str
capabilities: dict[str, bool] = Field(
description="Flags: chat, embeddings, rerank, images, video, "
"audio_speech, audio_transcription, vision",
)
@router.get("/model-capabilities", response_model=ModelCapabilitiesResponse)
async def api_model_capabilities(
provider: str = Query(..., description="Provider identifier"),
model: str = Query("", description="Model identifier (optional)"),
current_user=Depends(require_auth),
):
"""Return the curated capability flags for a provider/model pair.
The table is static (see ``backend.model_capabilities``) so the answer is
available offline and never depends on a provider API call.
"""
return {
"provider": provider,
"model": model,
"capabilities": get_model_capabilities(provider, model),
}
+122
View File
@@ -5,9 +5,11 @@ redaction, builds a system prompt with file contents, and caches results
for repeated queries.
"""
import base64
import hashlib
import json
import logging
import mimetypes
import os
import time
from pathlib import Path
@@ -21,6 +23,10 @@ logger = logging.getLogger("obsigate.bookslm")
BOOKSLM_MAX_FILES = int(os.getenv("BOOKSLM_MAX_FILES", "200"))
BOOKSLM_MAX_TOTAL_CHARS = int(os.getenv("BOOKSLM_MAX_TOTAL_CHARS", "200000"))
BOOKSLM_MAX_FILE_CHARS = int(os.getenv("BOOKSLM_MAX_FILE_CHARS", "30000"))
# Maximum size of an image sent to a vision model (bytes, before base64).
BOOKSLM_MAX_IMAGE_BYTES = int(os.getenv("BOOKSLM_MAX_IMAGE_BYTES", "10000000"))
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".avif"}
# ── Cache ──
_cache: dict[str, dict[str, Any]] = {}
@@ -260,6 +266,122 @@ def collect_files_context(
}
def collect_adhoc_context(
vault_path: Path,
files: list[str] | None = None,
directories: list[str] | None = None,
scope: str = "general",
) -> dict[str, Any]:
"""Collect an ad-hoc mix of explicit files and directories.
Backs the assistant ``@`` command: the user can attach extra files and
directories to the current context without changing the base mode. Paths
outside the vault are ignored by the underlying collectors.
Args:
vault_path: Absolute path to the vault root.
files: Relative file paths to include.
directories: Relative directory paths to include (recursive).
scope: Context label exposed to the UI.
Returns:
Same shape as :func:`collect_directory_context`.
"""
vault_resolved = vault_path.resolve()
collected: list[dict[str, Any]] = []
seen: set[str] = set()
total_chars = 0
def _add(entries: list[dict[str, Any]]) -> None:
nonlocal total_chars
for entry in entries:
if len(collected) >= BOOKSLM_MAX_FILES or total_chars >= BOOKSLM_MAX_TOTAL_CHARS:
return
path = entry.get("path")
if not path or path in seen:
continue
seen.add(path)
collected.append(entry)
total_chars += len(entry.get("content", ""))
if files:
_add(collect_files_context(vault_resolved, files, scope=scope)["files"])
for directory in directories or []:
_add(collect_directory_context(vault_resolved, directory)["files"])
return {
"files": collected,
"total_chars": total_chars,
"file_count": len(collected),
"directory_tree": "",
"max_total_chars": BOOKSLM_MAX_TOTAL_CHARS,
"max_files": BOOKSLM_MAX_FILES,
"scope": scope,
}
def merge_contexts(base: dict[str, Any], extra: dict[str, Any]) -> dict[str, Any]:
"""Merge two context payloads, de-duplicating files by path."""
files: list[dict[str, Any]] = []
seen: set[str] = set()
total_chars = 0
for entry in list(base.get("files", [])) + list(extra.get("files", [])):
path = entry.get("path")
if not path or path in seen:
continue
if len(files) >= BOOKSLM_MAX_FILES or total_chars >= BOOKSLM_MAX_TOTAL_CHARS:
break
seen.add(path)
files.append(entry)
total_chars += len(entry.get("content", ""))
tree = base.get("directory_tree", "") or extra.get("directory_tree", "")
scope = extra.get("scope") or base.get("scope", "general")
return {
"files": files,
"total_chars": total_chars,
"file_count": len(files),
"directory_tree": tree,
"max_total_chars": BOOKSLM_MAX_TOTAL_CHARS,
"max_files": BOOKSLM_MAX_FILES,
"scope": scope,
}
def is_image_path(path: str) -> bool:
"""True when the path has a supported image extension."""
return Path(path or "").suffix.lower() in IMAGE_EXTENSIONS
def load_vault_image_data_url(vault_path: Path, rel_path: str) -> str | None:
"""Read a vault image and return it as a ``data:`` URL for vision models.
Returns ``None`` when the path is outside the vault, missing, too large,
or not a supported image.
"""
if not rel_path or not is_image_path(rel_path):
return None
vault_resolved = vault_path.resolve()
try:
target = (vault_resolved / rel_path).resolve()
target.relative_to(vault_resolved)
except (ValueError, OSError):
return None
if not target.is_file():
return None
try:
if target.stat().st_size > BOOKSLM_MAX_IMAGE_BYTES:
logger.warning("Image too large to send: %s", rel_path)
return None
raw = target.read_bytes()
except OSError as exc:
logger.warning("Cannot read image %s: %s", rel_path, exc)
return None
mime = mimetypes.guess_type(str(target))[0] or "image/png"
encoded = base64.b64encode(raw).decode("ascii")
return f"data:{mime};base64,{encoded}"
def empty_context(scope: str = "general") -> dict[str, Any]:
"""Return an empty context payload (used by the General assistant)."""
return {
+146 -18
View File
@@ -15,12 +15,17 @@ from backend.auth.middleware import check_vault_access, require_auth
from backend.bookslm import (
build_general_system_prompt,
build_system_prompt,
collect_adhoc_context,
collect_directory_context,
collect_files_context,
empty_context,
load_vault_image_data_url,
merge_contexts,
)
from backend.indexer import get_vault_data, index
from backend.model_capabilities import model_supports_vision
from backend.schemas import BooksLMContextResponse
from backend.skills import get_skill_prompt
from backend.tools.api import ToolContext, ToolMode
logger = logging.getLogger("obsigate.bookslm_routes")
@@ -40,6 +45,14 @@ class BooksLMContextRequest(BaseModel):
default_factory=list,
description="Relative file paths to use as context (documents mode)",
)
extra_files: list[str] = Field(
default_factory=list,
description="Ad-hoc files added with the '@' command (any mode)",
)
extra_directories: list[str] = Field(
default_factory=list,
description="Ad-hoc directories added with the '@' command (any mode)",
)
class BooksLMChatRequest(BaseModel):
@@ -50,7 +63,24 @@ class BooksLMChatRequest(BaseModel):
default_factory=list,
description="Relative file paths to use as context (documents mode)",
)
extra_files: list[str] = Field(
default_factory=list,
description="Ad-hoc files added with the '@' command (any mode)",
)
extra_directories: list[str] = Field(
default_factory=list,
description="Ad-hoc directories added with the '@' command (any mode)",
)
message: str = Field(description="User message")
images: list[dict[str, Any]] = Field(
default_factory=list,
description="Images for vision models. Each item: {data, mime_type} (pasted) "
"or {path} (vault-relative file).",
)
skill: str | None = Field(
default=None,
description="Skill id selected with the '/' command; its prompt is added to the system prompt.",
)
conversation_history: list[dict[str, str]] = Field(
default_factory=list,
description="Previous conversation turns [{role, content}]",
@@ -93,15 +123,35 @@ def _resolve_vault_path(vault: str | None, current_user):
return vault, Path(vault_data["path"])
def _build_context(mode: str, vault_path: Path | None, directory: str, context_files: list[str]):
"""Collect the context payload for the requested mode."""
def _build_context(
mode: str,
vault_path: Path | None,
directory: str,
context_files: list[str],
extra_files: list[str] | None = None,
extra_directories: list[str] | None = None,
):
"""Collect the context payload for the requested mode.
Ad-hoc files/directories (``@`` command) are merged on top of the base
context. In General mode, attaching ad-hoc files promotes the effective
scope to ``documents`` so the prompt actually includes their content.
"""
if mode == "general":
return empty_context("general")
if mode == "documents":
base = empty_context("general")
elif mode == "documents":
ctx = collect_files_context(vault_path, context_files, scope="documents") # type: ignore[arg-type]
# No open document could be read → degrade gracefully to General.
return ctx if ctx["file_count"] else empty_context("general")
return collect_directory_context(vault_path, directory) # type: ignore[arg-type]
base = ctx if ctx["file_count"] else empty_context("general")
else:
base = collect_directory_context(vault_path, directory) # type: ignore[arg-type]
if (extra_files or extra_directories) and vault_path is not None:
adhoc_scope = "documents" if base.get("scope") == "general" else base.get("scope", "directory")
adhoc = collect_adhoc_context(vault_path, extra_files, extra_directories, scope=adhoc_scope)
if adhoc["file_count"]:
base = merge_contexts(base, adhoc)
return base
def _resolve_system_prompt(req, current_user) -> str:
@@ -114,16 +164,85 @@ def _resolve_system_prompt(req, current_user) -> str:
if mode != "general":
_, vault_path = _resolve_vault_path(req.vault, current_user)
context = _build_context(mode, vault_path, req.directory, req.context_files)
context = _build_context(
mode,
vault_path,
req.directory,
req.context_files,
getattr(req, "extra_files", None),
getattr(req, "extra_directories", None),
)
effective_mode = context.get("scope", mode)
if effective_mode == "general":
return build_general_system_prompt(list(index.keys()))
if effective_mode == "documents":
return build_system_prompt(context, scope="documents")
if context["file_count"] == 0:
prompt = build_general_system_prompt(list(index.keys()))
elif effective_mode == "documents":
prompt = build_system_prompt(context, scope="documents")
elif context["file_count"] == 0:
raise HTTPException(status_code=404, detail="Aucun fichier markdown trouvé dans ce dossier")
return build_system_prompt(context, scope="directory")
else:
prompt = build_system_prompt(context, scope="directory")
skill_id = getattr(req, "skill", None)
if skill_id:
skill_prompt = get_skill_prompt(skill_id, current_user)
if skill_prompt:
prompt += "\n\n## Skill actif\n" + skill_prompt
return prompt
def _resolve_optional_vault_path(req, current_user) -> Path | None:
"""Best-effort vault path resolution (never raises)."""
if not getattr(req, "vault", None):
return None
try:
_, vault_path = _resolve_vault_path(req.vault, current_user)
return vault_path
except HTTPException:
return None
def _build_user_content(req, vault_path: Path | None):
"""Build the user message content, attaching images when present.
Returns a plain string when there is no image, otherwise an OpenAI-style
multimodal content array (which ``ai_chat`` adapts for Gemini).
"""
images = getattr(req, "images", None) or []
if not images:
return req.message
parts: list[dict[str, Any]] = [{"type": "text", "text": req.message}]
for image in images:
if not isinstance(image, dict):
continue
data_url: str | None = None
if image.get("data"):
mime = image.get("mime_type") or "image/png"
data_url = f"data:{mime};base64,{image['data']}"
elif image.get("path") and vault_path is not None:
data_url = load_vault_image_data_url(vault_path, str(image["path"]))
if data_url:
parts.append({"type": "image_url", "image_url": {"url": data_url}})
return parts
def _validate_vision_support(req) -> None:
"""Reject image requests when the selected model cannot analyse images."""
if not (getattr(req, "images", None)):
return
provider = _resolve_provider_name(req.provider)
if not provider:
return
from backend.ai import PROVIDERS
model = req.model or PROVIDERS.get(provider, {}).get("model", "")
if not model_supports_vision(provider, model):
raise HTTPException(
status_code=400,
detail=f"Le modèle '{model or provider}' ne supporte pas l'analyse d'images. "
"Choisissez un modèle compatible vision.",
)
def _resolve_provider_name(requested: str | None) -> str | None:
@@ -157,10 +276,15 @@ async def api_bookslm_context(
"""
mode = _normalize_mode(req.mode)
if mode == "general":
return empty_context("general")
_, vault_path = _resolve_vault_path(req.vault, current_user)
return _build_context(mode, vault_path, req.directory, req.context_files)
if not (req.extra_files or req.extra_directories):
return empty_context("general")
vault_path = _resolve_optional_vault_path(req, current_user)
else:
_, vault_path = _resolve_vault_path(req.vault, current_user)
return _build_context(
mode, vault_path, req.directory, req.context_files,
req.extra_files, req.extra_directories,
)
@router.post(
@@ -178,7 +302,9 @@ async def api_bookslm_chat(
documents or general app knowledge), then streams the provider's answer
as Server-Sent Events.
"""
_validate_vision_support(req)
system_prompt = _resolve_system_prompt(req, current_user)
vault_path = _resolve_optional_vault_path(req, current_user)
messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
for turn in req.conversation_history:
@@ -186,7 +312,7 @@ async def api_bookslm_chat(
content = turn.get("content", "")
if role in ("user", "assistant") and content:
messages.append({"role": role, "content": content})
messages.append({"role": "user", "content": req.message})
messages.append({"role": "user", "content": _build_user_content(req, vault_path)})
async def generate_sse():
try:
@@ -246,7 +372,9 @@ async def api_bookslm_agent(
and the conversation snapshot; the client resumes by echoing them back in
``confirm`` / ``confirm_messages``.
"""
_validate_vision_support(req)
system_prompt = _resolve_system_prompt(req, current_user)
vault_path = _resolve_optional_vault_path(req, current_user)
messages: list[dict] = [{"role": "system", "content": system_prompt}]
for turn in req.conversation_history:
@@ -254,7 +382,7 @@ async def api_bookslm_agent(
content = turn.get("content", "")
if role in ("user", "assistant") and content:
messages.append({"role": role, "content": content})
messages.append({"role": "user", "content": req.message})
messages.append({"role": "user", "content": _build_user_content(req, vault_path)})
ctx = ToolContext(user=current_user, mode=ToolMode.IN_APP)
+10 -2
View File
@@ -854,6 +854,7 @@ from backend.share import (
revoke_share,
update_shares_after_rename,
)
from backend.skills_routes import router as skills_router
from backend.webhooks import (
create_webhook,
delete_webhook,
@@ -865,6 +866,7 @@ from backend.webhooks import (
app.include_router(auth_router)
app.include_router(ai_router)
app.include_router(bookslm_router)
app.include_router(skills_router)
# Admin Dashboard endpoints (system stats, audit logs, backups, stream)
try:
@@ -3571,6 +3573,8 @@ async def api_list_ai_models(provider: str = Query(...), current_user=Depends(re
"""
provider = provider.lower()
from backend.model_capabilities import get_capabilities_for_models
all_providers = ("deepseek", "openrouter", "gemini", "nvidia", "qwencloud", "xiaomi", "mistral")
if provider not in all_providers:
return {"models": [], "error": f"Unknown provider: {provider}", "source": "validation"}
@@ -3580,7 +3584,9 @@ async def api_list_ai_models(provider: str = Query(...), current_user=Depends(re
if not key:
# No key configured — return curated fallback list so the UI can
# still show what WOULD be available once a key is set.
return {"models": _FALLBACK_MODELS.get(provider, []), "source": "fallback",
fallback = _FALLBACK_MODELS.get(provider, [])
return {"models": fallback, "source": "fallback",
"capabilities": get_capabilities_for_models(provider, fallback),
"note": "API key not configured — showing default model list"}
# Build URL
@@ -3626,13 +3632,15 @@ async def api_list_ai_models(provider: str = Query(...), current_user=Depends(re
default = PROVIDERS.get(provider, {}).get("model")
if default and default not in models:
models = [default] + models
return {"models": models, "source": "live", "count": len(models)}
return {"models": models, "source": "live", "count": len(models),
"capabilities": get_capabilities_for_models(provider, models)}
# Empty list from API — fall through to fallback
raise ValueError("empty model list from provider API")
except Exception as e:
# Network error, auth error, parsing error — use curated fallback
fallback = _FALLBACK_MODELS.get(provider, [])
return {"models": fallback, "source": "fallback", "error": str(e)[:200],
"capabilities": get_capabilities_for_models(provider, fallback),
"note": "Could not reach provider API — showing default model list"}
+144
View File
@@ -0,0 +1,144 @@
"""Curated model-capability metadata for the AI assistant.
ObsiGate does not query every provider for the modalities a model supports
(not all of them expose that information, and the network call is not always
reliable). Instead a static, curated table maps known model-name patterns to
capability flags, with per-provider defaults. The UI uses this to show, when a
model is selected, which features it supports (Chat, Embeddings, Rerank,
Images, Video, Audio Speech, Audio Transcriptions, Vision).
The table is intentionally conservative: an unknown model falls back to the
provider default (usually ``chat`` only), so we never claim a capability the
model may not have.
"""
from __future__ import annotations
from typing import Any
# Ordered list of capability keys exposed to the UI. Keep in sync with the
# frontend ``AI_CAPABILITY_KEYS`` and the i18n ``ai.cap_*`` labels.
CAPABILITY_KEYS: tuple[str, ...] = (
"chat",
"embeddings",
"rerank",
"images",
"video",
"audio_speech",
"audio_transcription",
"vision",
)
def _caps(**kwargs: bool) -> dict[str, bool]:
"""Build a full capability dict (missing flags default to False)."""
base = {key: False for key in CAPABILITY_KEYS}
base.update(kwargs)
return base
# Provider defaults, used when no model-name pattern matches.
_PROVIDER_DEFAULTS: dict[str, dict[str, bool]] = {
"deepseek": _caps(chat=True),
"openrouter": _caps(chat=True),
"gemini": _caps(chat=True, vision=True, embeddings=True, audio_speech=True),
"ollama": _caps(chat=True, embeddings=True),
"nvidia": _caps(chat=True),
"qwencloud": _caps(chat=True),
"xiaomi": _caps(chat=True),
"mistral": _caps(chat=True, embeddings=True),
}
# Ordered (substrings, capabilities) rules — the first matching rule wins.
# More specific modalities are listed before the broad vision/chat rule.
_MODEL_RULES: list[tuple[tuple[str, ...], dict[str, bool]]] = [
# Rerankers.
(("rerank", "cross-encoder"), _caps(rerank=True)),
# Embedding models (usually not chat-capable).
(
("text-embedding", "embed", "bge-", "e5-", "nomic-embed", "gte-"),
_caps(embeddings=True),
),
# Speech-to-text / transcription.
(
("whisper", "transcrib", "-asr", "asr-", "speech-to-text"),
_caps(audio_transcription=True),
),
# Text-to-speech.
(
("-tts", "text-to-speech", "voiceclone", "voicedesign"),
_caps(audio_speech=True),
),
# Image generation.
(
("dall-e", "stable-diffusion", "flux", "imagen", "image-gen"),
_caps(images=True),
),
# Video generation.
(("veo-", "sora", "video-gen", "-video"), _caps(video=True)),
# Vision-capable chat models (multimodal input).
(
(
"gpt-4o",
"gpt-4.1",
"gpt-5",
"o3",
"o4",
"claude-3",
"claude-4",
"gemini",
"qwen-vl",
"-vl-",
"vl-",
"vision",
"llava",
"pixtral",
"internvl",
"minicpm-v",
"llama-3.2-vision",
"mimo-vl",
),
_caps(chat=True, vision=True),
),
]
def get_model_capabilities(provider: str, model: str) -> dict[str, bool]:
"""Return the capability flags for a ``provider``/``model`` pair.
Args:
provider: Provider identifier (e.g. ``"deepseek"``). Case-insensitive.
model: Model identifier (e.g. ``"deepseek-chat"``). May be empty, in
which case the provider default is returned.
Returns:
A dict with every key of :data:`CAPABILITY_KEYS` and boolean values.
"""
provider = (provider or "").strip().lower()
name = (model or "").strip().lower()
if name:
for needles, caps in _MODEL_RULES:
if any(needle in name for needle in needles):
return dict(caps)
return dict(_PROVIDER_DEFAULTS.get(provider, _caps(chat=True)))
def get_capabilities_for_models(
provider: str, models: list[str]
) -> dict[str, dict[str, bool]]:
"""Return a ``{model: capabilities}`` map for a list of models."""
return {model: get_model_capabilities(provider, model) for model in models}
def model_supports_vision(provider: str, model: str) -> bool:
"""True when the given model can accept image input."""
return bool(get_model_capabilities(provider, model).get("vision"))
def capabilities_payload(provider: str, model: str) -> dict[str, Any]:
"""Serialize capabilities for API responses."""
return {
"provider": provider,
"model": model,
"capabilities": get_model_capabilities(provider, model),
}
+4
View File
@@ -373,6 +373,10 @@ class AIModelsResponse(BaseModel):
count: int | None = Field(default=None, description="Number of live models")
error: str | None = Field(default=None, description="Provider/network error, if any")
note: str | None = Field(default=None, description="Explanatory note when using fallback")
capabilities: dict[str, dict[str, bool]] = Field(
default_factory=dict,
description="Per-model capability flags (chat, embeddings, vision, …)",
)
class DiagnosticsResponse(BaseModel):
+317
View File
@@ -0,0 +1,317 @@
"""AI assistant skills & slash-commands.
A *skill* is a reusable prompt/workflow the user can trigger from the assistant
composer with ``/``. ObsiGate ships a set of built-in skills; users can create
their own with ``/create-new-skill``. Built-in skills are code constants, while
user skills are persisted per-user in ``data/skills.json``.
The same module also exposes the metadata for the *admin* commands
(``/help``, ``/providers``, ``/provider``, ``/model``, ``/keys``). Those are
executed client-side (they only touch the picker / display info), but listing
them here keeps the ``/`` menu single-sourced.
"""
from __future__ import annotations
import json
import logging
import re
from pathlib import Path
from typing import Any
logger = logging.getLogger("obsigate.skills")
SKILLS_FILE = Path("data/skills.json")
MAX_USER_SKILLS = 100
MAX_PROMPT_CHARS = 8000
_SKILL_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,47}$")
# ── Built-in skills ─────────────────────────────────────────────────────────
# ``prompt`` is appended to the assistant system prompt when the skill is
# selected. Keep prompts concise and language-agnostic: the model answers in
# the user's language.
BUILTIN_SKILLS: list[dict[str, Any]] = [
{
"id": "research",
"label": "Recherche structurée",
"icon": "🔎",
"type": "skill",
"description": "Recherche structurée + recommandation",
"prompt": (
"Applique un mode RECHERCHE STRUCTURÉE. Structure ta réponse en : "
"1) Contexte et question reformulée, 2) Constats appuyés sur le contenu fourni, "
"3) Options/approches avec avantages et limites, 4) Recommandation argumentée. "
"Cite les sources (fichiers) utilisées."
),
},
{
"id": "create-new-skill",
"label": "Créer un skill",
"icon": "🛠️",
"type": "skill",
"special": "create_skill",
"description": "Crée un workflow réutilisable (skill)",
"prompt": "",
},
{
"id": "resume",
"label": "Résumé",
"icon": "📄",
"type": "skill",
"description": "Résumé / synthèse structurée",
"prompt": (
"Produis un RÉSUMÉ structuré du contenu : idées clés, points importants, "
"conclusions. Utilise des titres et des puces concises."
),
},
{
"id": "actions",
"label": "Actions & to-dos",
"icon": "✅",
"type": "skill",
"description": "Extraire les actions & to-dos",
"prompt": (
"Extrais les ACTIONS et TO-DOS du contenu. Rends une liste de tâches markdown "
"`- [ ] ...`, avec responsable et échéance si mentionnés, sinon `(à préciser)`."
),
},
{
"id": "reformuler",
"label": "Reformuler",
"icon": "✍️",
"type": "skill",
"description": "Réécriture clarté / ton",
"prompt": (
"RÉÉCRIS le contenu pour améliorer la clarté et le ton, en préservant le sens. "
"Retourne uniquement le texte reformulé."
),
},
{
"id": "correction",
"label": "Correction",
"icon": "🔤",
"type": "skill",
"description": "Correction grammaire / orthographe / style",
"prompt": (
"CORRIGE la grammaire, l'orthographe et le style. Retourne le texte corrigé, "
"puis une courte liste des corrections notables."
),
},
{
"id": "brainstorm",
"label": "Brainstorm",
"icon": "💡",
"type": "skill",
"description": "Générer des idées, angles, variantes",
"prompt": (
"Mode BRAINSTORM : génère un maximum d'idées, angles et variantes pertinents. "
"Regroupe-les par thème, sans juger, puis signale les plus prometteuses."
),
},
{
"id": "plan",
"label": "Planifier",
"icon": "🧭",
"type": "skill",
"description": "Planifier / structurer un document",
"prompt": (
"PLANIFIE et structure un document : propose un plan détaillé (sections, "
"sous-sections, objectif de chaque partie) et une progression logique."
),
},
{
"id": "ask",
"label": "Q&R",
"icon": "💬",
"type": "skill",
"description": "Q&A sur un contenu référencé",
"prompt": (
"Mode QUESTION/RÉPONSE : réponds précisément à la question en te basant "
"strictement sur le contenu référencé. Cite les passages/fichiers utilisés et "
"dis clairement si l'information est absente."
),
},
{
"id": "meeting-note",
"label": "Note de réunion",
"icon": "📝",
"type": "skill",
"description": "Compte-rendu / note de réunion",
"prompt": (
"Rédige une NOTE DE RÉUNION : participants, ordre du jour, décisions, "
"points d'action (`- [ ] ...`), questions ouvertes et prochaines étapes."
),
},
{
"id": "livrable",
"label": "Livrable",
"icon": "📨",
"type": "skill",
"description": "Email / compte-rendu / message Slack",
"prompt": (
"Rédige un LIVRABLE de communication (email, compte-rendu ou message Slack) "
"clair et prêt à envoyer, adapté au canal et au destinataire indiqués."
),
},
]
# ── Admin commands (handled client-side) ────────────────────────────────────
ADMIN_COMMANDS: list[dict[str, Any]] = [
{
"id": "help",
"label": "Aide",
"icon": "❓",
"type": "admin",
"description": "Liste des commandes",
},
{
"id": "providers",
"label": "Fournisseurs",
"icon": "🔌",
"type": "admin",
"description": "Liste les fournisseurs actifs",
},
{
"id": "provider",
"label": "Changer de fournisseur",
"icon": "🔀",
"type": "admin",
"usage": "/provider <nom>",
"description": "Changer de fournisseur LLM",
},
{
"id": "model",
"label": "Changer de modèle",
"icon": "🧠",
"type": "admin",
"usage": "/model <nom>",
"description": "Changer de modèle LLM",
},
{
"id": "keys",
"label": "Clés API",
"icon": "🔑",
"type": "admin",
"description": "Fournisseurs avec clé API enregistrée",
},
]
def list_builtin_skills() -> list[dict[str, Any]]:
"""Return a copy of the built-in skills."""
return [dict(skill) for skill in BUILTIN_SKILLS]
def list_admin_commands() -> list[dict[str, Any]]:
"""Return a copy of the admin command metadata."""
return [dict(cmd) for cmd in ADMIN_COMMANDS]
def _read_store() -> dict[str, list[dict[str, Any]]]:
if not SKILLS_FILE.exists():
return {}
try:
data = json.loads(SKILLS_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except Exception as exc: # pragma: no cover - corrupted file
logger.warning("Cannot read skills store: %s", exc)
return {}
def _write_store(store: dict[str, list[dict[str, Any]]]) -> None:
SKILLS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = SKILLS_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(store, indent=2, ensure_ascii=False), encoding="utf-8")
tmp.replace(SKILLS_FILE)
def _username(user: dict | None) -> str:
if not user:
return "anonymous"
return str(user.get("username") or "anonymous")
def list_user_skills(user: dict | None) -> list[dict[str, Any]]:
"""Return the persisted custom skills for a user."""
store = _read_store()
skills = store.get(_username(user), [])
return [dict(s) for s in skills if isinstance(s, dict)]
def list_skills(user: dict | None) -> dict[str, list[dict[str, Any]]]:
"""Return built-in skills, admin commands and the user's custom skills."""
return {
"skills": list_builtin_skills() + list_user_skills(user),
"commands": list_admin_commands(),
}
def get_skill_prompt(skill_id: str | None, user: dict | None) -> str | None:
"""Resolve a skill id to its prompt, searching built-ins then user skills."""
if not skill_id:
return None
for skill in BUILTIN_SKILLS:
if skill["id"] == skill_id:
return skill.get("prompt") or None
for skill in list_user_skills(user):
if skill.get("id") == skill_id:
return skill.get("prompt") or None
return None
def create_user_skill(user: dict | None, payload: dict[str, Any]) -> dict[str, Any]:
"""Create and persist a custom skill for a user.
Raises:
ValueError: when the payload is invalid (bad id, duplicate, too many).
"""
skill_id = str(payload.get("id") or "").strip().lower()
label = str(payload.get("label") or "").strip()
prompt = str(payload.get("prompt") or "").strip()
description = str(payload.get("description") or "").strip()
if not _SKILL_ID_RE.match(skill_id):
raise ValueError("Identifiant invalide (a-z, 0-9, '-', '_', max 48)")
if any(s["id"] == skill_id for s in BUILTIN_SKILLS):
raise ValueError(f"L'identifiant '{skill_id}' est réservé")
if not label:
raise ValueError("Le nom du skill est requis")
if not prompt:
raise ValueError("Le prompt du skill est requis")
if len(prompt) > MAX_PROMPT_CHARS:
raise ValueError("Le prompt est trop long")
username = _username(user)
store = _read_store()
user_skills = store.get(username, [])
if any(s.get("id") == skill_id for s in user_skills):
raise ValueError(f"Le skill '{skill_id}' existe déjà")
if len(user_skills) >= MAX_USER_SKILLS:
raise ValueError("Trop de skills personnalisés")
skill = {
"id": skill_id,
"label": label,
"icon": str(payload.get("icon") or "🧩").strip() or "🧩",
"type": "skill",
"custom": True,
"description": description or label,
"prompt": prompt,
}
user_skills.append(skill)
store[username] = user_skills
_write_store(store)
return skill
def delete_user_skill(user: dict | None, skill_id: str) -> bool:
"""Delete a custom skill. Returns True when a skill was removed."""
username = _username(user)
store = _read_store()
user_skills = store.get(username, [])
remaining = [s for s in user_skills if s.get("id") != skill_id]
if len(remaining) == len(user_skills):
return False
store[username] = remaining
_write_store(store)
return True
+98
View File
@@ -0,0 +1,98 @@
"""API routes for AI assistant skills & slash-commands (``/api/ai/skills``)."""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from backend.auth.middleware import require_auth
from backend.skills import (
create_user_skill,
delete_user_skill,
list_skills,
)
logger = logging.getLogger("obsigate.skills_routes")
router = APIRouter(prefix="/api/ai/skills", tags=["AI"])
class SkillModel(BaseModel):
"""A single skill or admin command."""
model_config = {"extra": "allow"}
id: str
label: str
icon: str = "🧩"
type: str = Field(default="skill", description="'skill' or 'admin'")
description: str = ""
prompt: str | None = None
usage: str | None = None
special: str | None = None
custom: bool = False
class SkillsResponse(BaseModel):
"""Response for ``GET /api/ai/skills``."""
skills: list[SkillModel]
commands: list[SkillModel]
class CreateSkillRequest(BaseModel):
"""Body for ``POST /api/ai/skills``."""
id: str = Field(description="Stable identifier (a-z, 0-9, '-', '_')")
label: str = Field(description="Display name")
prompt: str = Field(description="Instruction injected into the system prompt")
description: str = ""
icon: str = "🧩"
class DeleteSkillResponse(BaseModel):
"""Response for ``DELETE /api/ai/skills/{skill_id}``."""
status: str = "deleted"
id: str
@router.get("", response_model=SkillsResponse)
async def api_list_skills(current_user=Depends(require_auth)):
"""List built-in skills, admin commands and the user's custom skills."""
data = list_skills(current_user)
return data
@router.post("", response_model=SkillModel)
async def api_create_skill(body: CreateSkillRequest, current_user=Depends(require_auth)):
"""Create a custom skill for the current user."""
try:
skill = create_user_skill(current_user, body.model_dump())
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return skill
@router.delete("/{skill_id}", response_model=DeleteSkillResponse)
async def api_delete_skill(skill_id: str, current_user=Depends(require_auth)):
"""Delete a custom skill owned by the current user."""
removed = delete_user_skill(current_user, skill_id)
if not removed:
raise HTTPException(status_code=404, detail="Skill introuvable")
return {"status": "deleted", "id": skill_id}
# Expose the resolved prompt of a skill (used by tests / clients that only
# need the instruction text without fetching the whole list).
@router.get("/{skill_id}/prompt")
async def api_skill_prompt(skill_id: str, current_user=Depends(require_auth)) -> dict[str, Any]:
"""Return the prompt text for a given skill id."""
from backend.skills import get_skill_prompt
prompt = get_skill_prompt(skill_id, current_user)
if prompt is None:
raise HTTPException(status_code=404, detail="Skill introuvable")
return {"id": skill_id, "prompt": prompt}
+24
View File
@@ -297,6 +297,30 @@ Pour répondre au besoin de cibler un fournisseur/modèle sans dépendre uniquem
---
## 6bis. Skills, commandes, vision & capacités (#81)
- **Skills & commandes `/`** — `backend/skills.py` définit les skills intégrés (id, icône, description,
prompt) et les métadonnées des commandes admin. Les skills utilisateur sont persistés par
utilisateur dans `data/skills.json`. Endpoints : `GET/POST /api/ai/skills`,
`DELETE /api/ai/skills/{id}`. Le champ `skill` d'une requête chat/agent injecte le prompt du skill
dans le system prompt (`_resolve_system_prompt`). Les commandes admin (`/help`, `/providers`,
`/provider`, `/model`, `/keys`) sont exécutées côté client.
- **Contexte ad-hoc `@`** — champs `extra_files` / `extra_directories` sur les requêtes
context/chat/agent ; collecte et fusion par `collect_adhoc_context()` + `merge_contexts()`
(`backend/bookslm.py`).
- **Vision** — les messages peuvent porter un contenu multimodal (tableau OpenAI
`text` + `image_url`). `backend/ai_chat.py` convertit les data URLs en `inlineData` Gemini
(`_content_to_gemini_parts`) ; l'OpenAI-compatible passe le tableau tel quel. Les images viennent
d'un copier-coller (base64) ou d'un fichier de vault (data URL chargée par
`load_vault_image_data_url`). Garde-fou : `_validate_vision_support` rejette (400) une requête
d'image si le modèle n'est pas vision.
- **Capacités** — table statique curée `backend/model_capabilities.py` (8 flags : chat, embeddings,
rerank, images, video, audio_speech, audio_transcription, vision). Exposée par
`GET /api/ai/model-capabilities` et par le champ `capabilities` de `GET /api/config/ai-models` ;
affichée dans le picker de l'assistant et le panneau de configuration.
---
## 7. Plan par phases
| Phase | Contenu | Livrable |
+2 -1
View File
@@ -94,6 +94,7 @@
| 62 | Collaboration temps réel — Édition simultanée (Yjs/CRDT, WebSocket, awareness) | 2.3.0 | [features/collaboration.md](./features/collaboration.md) |
| 79 | Assistant IA — Outils (function calling) & serveur MCP | 2.3.0 | [features/ai-tools-mcp.md](./features/ai-tools-mcp.md) |
| 80 | Assistant IA — Rendu Markdown, liens fichiers/paths & sessions | 2.3.0 | [features/ai-assistant-ux.md](./features/ai-assistant-ux.md) |
| 81 | Assistant IA — Commandes (`@`, `/`), skills, images & capacités des modèles | 2.3.0 | [features/ai-assistant-commands.md](./features/ai-assistant-commands.md) |
| 69 | Éditeur mobile natif — Interface tactile optimisée | 2.3.0 | [features/mobile-editor.md](./features/mobile-editor.md) |
| 70 | Recherche sémantique — Embeddings vectoriels (hybride TF-IDF + RRF) | 2.3.0 | [features/semantic-search.md](./features/semantic-search.md) |
| 77 | Application Desktop native — Tauri | 🔵 en cours | [features/desktop-tauri.md](./features/desktop-tauri.md) |
@@ -104,7 +105,7 @@
| Priorité | Items | Effort total estimé |
|---|---|---|
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–80 | ~103 jours réalisés |
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–81 | ~107 jours réalisés |
| 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour |
| ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours |
| **Total restant** | **2 items + finitions** | **~7-10 jours** |
+68
View File
@@ -0,0 +1,68 @@
# #81 — Assistant IA — Commandes (`@`, `/`), skills, analyse d'images & capacités des modèles
> **Statut :** ✅ Livré
> **Effort :** 4-6 jours | **Impact :** 🟡
> **Références :** [Roadmap](../ROADMAP.md) · [Assistant IA #80](./ai-assistant-ux.md) · [Outils IA #79](./ai-tools-mcp.md) · [Changelog](../../CHANGELOG.md)
- **Description :** cinq améliorations de l'assistant IA (`frontend/js/bookslm.js`, `frontend/js/ai.js`) :
1. **Panneau redimensionnable** à la souris, largeur persistée.
2. **Commande `@`** pour ajouter des contextes ad-hoc (fichiers et répertoires) au contexte courant.
3. **Commande `/`** pour choisir un **skill** (workflow réutilisable) ou une **commande admin**.
4. **Analyse d'images** : coller une image dans la zone de saisie ou référencer une image d'un
répertoire (`@image.png`), avec garde-fou de compatibilité du modèle.
5. **Capacités des modèles** affichées à la sélection (Chat, Embeddings, Rerank, Images, Video,
Audio Speech, Audio Transcriptions, Vision capable).
## A. Panneau redimensionnable — ✅ livré
- [x] Poignée `.bookslm-resize-handle` sur le bord gauche, glisser pour ajuster (320–1000 px).
- [x] Largeur persistée dans `localStorage['obsigate-bookslm-width']` ; désactivée en plein écran/mobile.
## B. Contexte ad-hoc `@` — ✅ livré
- [x] Détection de `@query` au curseur → menu `.bookslm-mention-menu` alimenté par
`/api/tree-search` (avec repli sur `/api/vault/{vault}/files` quand la requête est vide).
- [x] Sélection : fichier → chip de contexte ; répertoire → chip de contexte ; image → pièce jointe.
- [x] Chips retirables (`.bookslm-attachments`), rechargement du contexte (`/context`).
- [x] Backend : `extra_files` / `extra_directories` dans les requêtes context/chat/agent, fusion via
`collect_adhoc_context()` + `merge_contexts()` (`backend/bookslm.py`). En mode General, l'ajout
de fichiers promeut le scope en `documents`.
## C. Commandes `/` & skills — ✅ livré
- [x] Menu `.bookslm-command-menu` filtré à la saisie ; navigation clavier (↑/↓/Entrée/Échap).
- [x] **Skills intégrés** (`backend/skills.py`) : `/research`, `/create-new-skill`, `/resume`,
`/actions`, `/reformuler`, `/correction`, `/brainstorm`, `/plan`, `/ask`, `/meeting-note`,
`/livrable`. Le prompt du skill est ajouté au system prompt (`skill` dans la requête chat).
- [x] **Skills utilisateur persistés** (`data/skills.json`, par utilisateur) créés via
`/create-new-skill` (modale) → `POST /api/ai/skills`, listés par `GET /api/ai/skills`,
supprimables par `DELETE /api/ai/skills/{id}`.
- [x] **Commandes admin** exécutées localement (sans LLM) : `/help`, `/providers`, `/provider <nom>`,
`/model <nom>`, `/keys`.
## D. Analyse d'images — ✅ livré
- [x] Coller une image dans la zone de saisie (`paste`) ou bouton « joindre une image ».
- [x] Référencer une image d'un répertoire via `@image.png` (chargée côté serveur en data URL).
- [x] Backend multimodal : `ai_chat._content_to_gemini_parts` (data URL → `inlineData`), messages
OpenAI-compatibles passés tels quels ; `_build_user_content` dans `bookslm_routes.py`.
- [x] Garde-fou : requête d'image rejetée (400) si le modèle ne supporte pas la vision ; côté
frontend, toast + blocage de l'envoi. Les images forcent l'endpoint `/chat` (pas l'agent).
## E. Capacités des modèles — ✅ livré
- [x] Table statique curée `backend/model_capabilities.py` (règles par motif de nom + défauts par
fournisseur) exposant 8 flags.
- [x] Endpoint `GET /api/ai/model-capabilities?provider=&model=` (auth) + `capabilities` par modèle
dans `GET /api/config/ai-models`.
- [x] Affichage ☑/□ dans le picker de l'assistant (`frontend/js/ai.js`) et dans le panneau de
configuration (`#cfg-ai-default-model-caps`, `frontend/js/config.js`).
## F. Tests & documentation — ✅ livré
- [x] Backend : `tests/test_model_capabilities.py`, `tests/test_skills.py`, `tests/test_ai_vision.py`,
extension `tests/test_ai_models.py` (capabilities).
- [x] Frontend : `tests/frontend/ai.test.mjs` (redimensionnement, `@`, `/`, payload, endpoint,
vision, capacités, commandes admin).
- [x] i18n FR/EN (`ai.cap_*`, `ai.commands`, `ai.attach_image`, `config.ai_capabilities`, …).
- [x] CHANGELOG + Roadmap + guide utilisateur (aide in-app).
## G. Points d'attention
- La table de capacités est **statique** : un modèle inconnu retombe sur le défaut du fournisseur
(souvent `chat` uniquement), afin de ne jamais annoncer une capacité non vérifiée.
- Les skills utilisateur sont stockés par utilisateur ; `/create-new-skill` ne peut pas écraser un
skill intégré ni réutiliser un identifiant existant.
+31
View File
@@ -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>
+65
View File
@@ -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
View File
@@ -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
View File
@@ -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)) {
+34
View File
@@ -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",
+34
View File
@@ -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",
+62
View File
@@ -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
+181
View File
@@ -607,6 +607,187 @@ async function main() {
localStorage.clear();
});
// ── 29. Resizable panel bounds and persistence (#81) ──
await test("panel width is clamped and persisted", async () => {
localStorage.clear();
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
assert.equal(b._clampPanelWidth(100), 320, "min width enforced");
assert.equal(b._clampPanelWidth(5000), 1000, "max width enforced");
localStorage.setItem("obsigate-bookslm-width", "640");
b._applyPanelWidth();
assert.equal(b._panel.style.width, "640px", "persisted width applied");
assert.ok(b._panel.querySelector(".bookslm-resize-handle"), "resize handle present");
b._panel.remove();
localStorage.clear();
});
// ── 30. Ad-hoc context chips (#81) ──
await test("ad-hoc files and directories render as chips", async () => {
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
b._addAdhocFile("notes/a.md");
b._addAdhocDir("projets/");
const chips = b._panel.querySelectorAll(".bookslm-attachments .bookslm-chip");
assert.equal(chips.length, 2, "one chip per ad-hoc entry");
assert.ok(b._adhocDirs[0].path === "projets", "trailing slash stripped");
b._removeAdhocFile("notes/a.md");
assert.equal(b._panel.querySelectorAll(".bookslm-attachments .bookslm-chip").length, 1);
b._panel.remove();
});
// ── 31. Chat payload carries extras, images and skill (#81) ──
await test("send payload includes extra context, images and skill", async () => {
localStorage.clear();
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
b._mode = MODE.GENERAL;
b._vault = "V";
b._messages = [];
b._adhocFiles = [{ path: "a.md" }];
b._adhocDirs = [{ path: "sub" }];
b._images = [{ path: "pics/x.png", name: "x.png" }];
b._activeSkill = "research";
b._panel.querySelector("textarea").value = "Analyse";
let posted = null;
globalThis.fetch = async (url, opts) => {
if (String(url).includes("/api/ai/bookslm/chat")) posted = JSON.parse(opts.body);
return { ok: true, status: 200, body: { getReader: () => ({ read: async () => ({ done: true }) }) } };
};
await b._sendMessage();
assert.ok(posted, "chat request posted");
assert.deepEqual(posted.extra_files, ["a.md"]);
assert.deepEqual(posted.extra_directories, ["sub"]);
assert.deepEqual(posted.images, [{ path: "pics/x.png" }]);
assert.equal(posted.skill, "research");
b._panel.remove();
localStorage.clear();
});
// ── 32. Images force the plain chat endpoint (not the agent) (#81) ──
await test("images route to /chat even in agent mode", async () => {
const b = new BooksLM();
b._agentMode = true;
let url = "";
globalThis.fetch = async (u) => {
url = String(u);
return { ok: true, status: 200, json: async () => ({}) };
};
await b._postChat({ images: [{ data: "AAA", mime_type: "image/png" }] });
assert.ok(url.includes("/api/ai/bookslm/chat"), "image request uses /chat");
await b._postChat({ images: [] });
assert.ok(url.includes("/api/ai/bookslm/agent"), "text request uses /agent in agent mode");
});
// ── 33. Vision gate (#81) ──
await test("_modelSupportsVision reflects selected model capabilities", async () => {
localStorage.setItem("obsigate_ai_picker", JSON.stringify({
provider: "deepseek", model: "deepseek-chat", capabilities: { chat: true, vision: false },
}));
const b = new BooksLM();
assert.equal(await b._modelSupportsVision(), false);
localStorage.setItem("obsigate_ai_picker", JSON.stringify({
provider: "qwencloud", model: "qwen-vl-max", capabilities: { chat: true, vision: true },
}));
assert.equal(await b._modelSupportsVision(), true);
localStorage.clear();
});
// ── 34. `/` command menu and selection (#81) ──
await test("slash command menu lists skills and selects one", async () => {
localStorage.clear();
globalThis.fetch = async (url) => {
if (String(url).includes("/api/ai/skills")) {
return {
ok: true, status: 200,
json: async () => ({
skills: [{ id: "research", label: "Recherche", icon: "🔎", type: "skill", description: "d" }],
commands: [{ id: "help", label: "Aide", icon: "❓", type: "admin", description: "h" }],
}),
};
}
return { ok: true, status: 200, json: async () => ({}) };
};
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
const ta = b._panel.querySelector("textarea");
ta.value = "/res";
ta.selectionStart = ta.selectionEnd = 4;
await b._showCommandMenu("res");
const menu = b._panel.querySelector(".bookslm-command-menu");
assert.ok(!menu.classList.contains("hidden"), "command menu shown");
assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 1);
b._selectCommand({ id: "research", label: "Recherche", type: "skill" });
assert.equal(b._activeSkill, "research");
assert.ok(b._panel.querySelector(".bookslm-chip-skill"), "skill chip rendered");
b._panel.remove();
});
// ── 35. `@` mention menu adds ad-hoc context / images (#81) ──
await test("mention menu adds files, directories and images", async () => {
localStorage.clear();
globalThis.fetch = async (url) => {
if (String(url).includes("/api/tree-search")) {
return {
ok: true, status: 200,
json: async () => ({ results: [
{ path: "notes/a.md", type: "file" },
{ path: "notes", type: "dir" },
] }),
};
}
return { ok: true, status: 200, json: async () => ({}) };
};
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
b._vault = "V";
await b._showMentionMenu("notes");
const menu = b._panel.querySelector(".bookslm-mention-menu");
assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 2);
b._selectMention({ id: "notes/a.md", type: "file" });
assert.ok(b._adhocFiles.some((f) => f.path === "notes/a.md"));
b._selectMention({ id: "notes", type: "dir" });
assert.ok(b._adhocDirs.some((d) => d.path === "notes"));
b._selectMention({ id: "pics/x.png", type: "file" });
assert.ok(b._images.some((img) => img.path === "pics/x.png"));
b._panel.remove();
});
// ── 36. Capability checklist rendering (#81) ──
await test("capability list renders checked and unchecked flags", () => {
const caps = {
chat: true, embeddings: false, rerank: false, images: false,
video: false, audio_speech: false, audio_transcription: false, vision: true,
};
const box = ai.renderCapabilityList(caps);
assert.equal(box.querySelectorAll(".ai-cap-item").length, 8);
assert.equal(box.querySelectorAll(".ai-cap-item.on").length, 2);
assert.ok(box.textContent.includes("☑"));
assert.ok(box.textContent.includes("□"));
});
// ── 37. Admin commands handled locally (#81) ──
await test("admin commands are parsed without an LLM call", async () => {
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
b._messages = [];
let fetched = false;
globalThis.fetch = async () => { fetched = true; return { ok: true, status: 200, json: async () => ({}) }; };
const handled = b._maybeRunAdminWithArg("/model deepseek-chat");
assert.equal(handled, true);
assert.equal(fetched, false, "no LLM request for admin command");
assert.ok(b._messages.length === 1, "assistant note pushed");
b._panel.remove();
});
// ── Summary ──
console.log(`\n${passCount}/${testCount} tests passed`);
if (passCount !== testCount) {
+31
View File
@@ -256,6 +256,37 @@ class TestListModelsEndpoint:
assert hdrs["api-key"] == "fake-xiaomi-key"
assert "authorization" not in hdrs, f"Authorization leaked: {hdrs!r}"
def test_models_include_capabilities(self, admin_client, monkeypatch):
"""The model list must expose per-model capability flags (#81)."""
from backend import ai as aimod
monkeypatch.setattr(aimod, "get_ai_key", lambda name: None)
token = _login_admin(admin_client)
resp = admin_client.get(
"/api/config/ai-models",
params={"provider": "qwencloud"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
caps = data.get("capabilities") or {}
assert "qwen-vl-max" in caps
assert caps["qwen-vl-max"]["vision"] is True
def test_model_capabilities_endpoint(self, admin_client):
"""GET /api/ai/model-capabilities returns the curated flags."""
token = _login_admin(admin_client)
resp = admin_client.get(
"/api/ai/model-capabilities",
params={"provider": "deepseek", "model": "deepseek-chat"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["provider"] == "deepseek"
assert data["capabilities"]["chat"] is True
assert data["capabilities"]["vision"] is False
def test_xiaomi_test_endpoint_uses_real_url(self, admin_client, monkeypatch):
"""The /api/config/ai-keys/test endpoint must also use api.xiaomimimo.com.
+113
View File
@@ -0,0 +1,113 @@
"""Tests for multimodal (vision) support and ad-hoc context (#81)."""
from __future__ import annotations
import base64
from backend.ai_chat import _content_to_gemini_parts, _gemini_contents
from backend.bookslm import (
collect_adhoc_context,
is_image_path,
load_vault_image_data_url,
merge_contexts,
)
class TestGeminiParts:
def test_plain_string(self):
assert _content_to_gemini_parts("hello") == [{"text": "hello"}]
def test_none_becomes_empty_text(self):
assert _content_to_gemini_parts(None) == [{"text": ""}]
def test_text_and_image_data_url(self):
raw = base64.b64encode(b"\x89PNG").decode()
parts = _content_to_gemini_parts([
{"type": "text", "text": "décris"},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{raw}"}},
])
assert parts[0] == {"text": "décris"}
assert parts[1] == {"inlineData": {"mimeType": "image/png", "data": raw}}
def test_remote_image_url_becomes_file_data(self):
parts = _content_to_gemini_parts([
{"type": "image_url", "image_url": {"url": "https://x.test/a.png"}},
])
assert parts == [{"fileData": {"fileUri": "https://x.test/a.png"}}]
def test_gemini_contents_handles_multimodal_user_message(self):
raw = base64.b64encode(b"img").decode()
system, contents = _gemini_contents([
{"role": "system", "content": "sys"},
{"role": "user", "content": [
{"type": "text", "text": "hi"},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{raw}"}},
]},
])
assert system == "sys"
assert contents[0]["role"] == "user"
assert contents[0]["parts"][1]["inlineData"]["mimeType"] == "image/jpeg"
class TestImageHelpers:
def test_is_image_path(self):
assert is_image_path("a/b.png")
assert is_image_path("photo.JPEG")
assert not is_image_path("note.md")
def test_load_vault_image_data_url(self, tmp_path):
vault = tmp_path / "vault"
vault.mkdir()
(vault / "pic.png").write_bytes(b"PNGDATA")
url = load_vault_image_data_url(vault, "pic.png")
assert url is not None
assert url.startswith("data:image/png;base64,")
assert base64.b64decode(url.split(",", 1)[1]) == b"PNGDATA"
def test_load_rejects_path_traversal(self, tmp_path):
vault = tmp_path / "vault"
vault.mkdir()
outside = tmp_path / "outside.png"
outside.write_bytes(b"x")
assert load_vault_image_data_url(vault, "../outside.png") is None
def test_load_rejects_non_image(self, tmp_path):
vault = tmp_path / "vault"
vault.mkdir()
(vault / "note.md").write_text("hi", encoding="utf-8")
assert load_vault_image_data_url(vault, "note.md") is None
class TestAdhocContext:
def test_files_and_directories(self, tmp_path):
vault = tmp_path / "vault"
(vault / "sub").mkdir(parents=True)
(vault / "a.md").write_text("# A", encoding="utf-8")
(vault / "sub" / "b.md").write_text("# B", encoding="utf-8")
ctx = collect_adhoc_context(vault, files=["a.md"], directories=["sub"])
paths = {f["path"] for f in ctx["files"]}
assert paths == {"a.md", "sub/b.md"}
def test_deduplicates(self, tmp_path):
vault = tmp_path / "vault"
vault.mkdir()
(vault / "a.md").write_text("# A", encoding="utf-8")
ctx = collect_adhoc_context(vault, files=["a.md"], directories=["."])
assert ctx["file_count"] == 1
def test_merge_contexts(self):
base = {
"files": [{"path": "a.md", "content": "aaa"}],
"file_count": 1, "total_chars": 3, "directory_tree": "tree", "scope": "directory",
}
extra = {
"files": [
{"path": "a.md", "content": "aaa"},
{"path": "b.md", "content": "bb"},
],
"file_count": 2, "total_chars": 5, "directory_tree": "", "scope": "directory",
}
merged = merge_contexts(base, extra)
assert [f["path"] for f in merged["files"]] == ["a.md", "b.md"]
assert merged["total_chars"] == 5
+86
View File
@@ -0,0 +1,86 @@
"""Tests for the curated model-capability table (#81)."""
from __future__ import annotations
from backend.model_capabilities import (
CAPABILITY_KEYS,
get_capabilities_for_models,
get_model_capabilities,
model_supports_vision,
)
class TestCapabilityShape:
def test_every_result_has_all_keys(self):
for provider, model in [
("deepseek", "deepseek-chat"),
("gemini", "gemini-2.0-flash"),
("qwencloud", "qwen-vl-max"),
("openrouter", "unknown-model"),
]:
caps = get_model_capabilities(provider, model)
assert set(caps.keys()) == set(CAPABILITY_KEYS)
assert all(isinstance(v, bool) for v in caps.values())
class TestVisionDetection:
def test_vision_models_detected(self):
assert model_supports_vision("qwencloud", "qwen-vl-max")
assert model_supports_vision("openrouter", "openai/gpt-4o")
assert model_supports_vision("gemini", "gemini-2.0-flash")
assert model_supports_vision("mistral", "pixtral-large-latest")
def test_text_models_not_vision(self):
assert not model_supports_vision("deepseek", "deepseek-chat")
assert not model_supports_vision("xiaomi", "mimo-v2.5-pro")
def test_vision_implies_chat(self):
caps = get_model_capabilities("qwencloud", "qwen-vl-plus")
assert caps["vision"] is True
assert caps["chat"] is True
class TestSpecialModalities:
def test_embeddings(self):
caps = get_model_capabilities("openai", "text-embedding-3-small")
assert caps["embeddings"] is True
assert caps["chat"] is False
def test_rerank(self):
caps = get_model_capabilities("nvidia", "nvidia/llama-3.2-nv-rerankqa-1b-v2")
assert caps["rerank"] is True
def test_audio_transcription(self):
caps = get_model_capabilities("xiaomi", "mimo-v2.5-asr")
assert caps["audio_transcription"] is True
def test_audio_speech(self):
caps = get_model_capabilities("xiaomi", "mimo-v2.5-tts")
assert caps["audio_speech"] is True
def test_image_generation(self):
caps = get_model_capabilities("openai", "dall-e-3")
assert caps["images"] is True
class TestProviderDefaults:
def test_unknown_model_uses_provider_default(self):
caps = get_model_capabilities("deepseek", "some-new-model")
assert caps["chat"] is True
assert caps["vision"] is False
def test_unknown_provider_defaults_to_chat(self):
caps = get_model_capabilities("nope", "mystery")
assert caps["chat"] is True
def test_empty_model_uses_provider_default(self):
assert get_model_capabilities("gemini", "")["vision"] is True
class TestBatch:
def test_capabilities_for_models_map(self):
result = get_capabilities_for_models(
"qwencloud", ["qwen-max", "qwen-vl-max"]
)
assert result["qwen-max"]["vision"] is False
assert result["qwen-vl-max"]["vision"] is True
+227
View File
@@ -0,0 +1,227 @@
"""Tests for the AI assistant skills & slash-commands (#81)."""
from __future__ import annotations
import json
import os
from pathlib import Path
import pytest
import backend.skills as skills_mod
from backend.skills import (
BUILTIN_SKILLS,
create_user_skill,
delete_user_skill,
get_skill_prompt,
list_skills,
list_user_skills,
)
@pytest.fixture
def skill_store(tmp_path, monkeypatch):
"""Point the skills store at an isolated temp file."""
store = tmp_path / "skills.json"
monkeypatch.setattr(skills_mod, "SKILLS_FILE", store)
return store
USER = {"username": "alice"}
class TestBuiltinSkills:
def test_expected_skills_present(self):
ids = {s["id"] for s in BUILTIN_SKILLS}
for expected in [
"research", "create-new-skill", "resume", "actions", "reformuler",
"correction", "brainstorm", "plan", "ask", "meeting-note", "livrable",
]:
assert expected in ids
def test_every_skill_has_label_and_description(self):
for skill in BUILTIN_SKILLS:
assert skill["label"]
assert skill["description"]
def test_builtin_prompt_resolves(self, skill_store):
prompt = get_skill_prompt("research", USER)
assert prompt and "RECHERCHE" in prompt
def test_unknown_skill_returns_none(self, skill_store):
assert get_skill_prompt("does-not-exist", USER) is None
def test_list_includes_admin_commands(self, skill_store):
data = list_skills(USER)
command_ids = {c["id"] for c in data["commands"]}
assert {"help", "providers", "provider", "model", "keys"} <= command_ids
class TestUserSkills:
def test_create_and_list(self, skill_store):
skill = create_user_skill(USER, {
"id": "my-skill", "label": "Mon skill", "prompt": "Fais X",
})
assert skill["id"] == "my-skill"
assert skill["custom"] is True
stored = list_user_skills(USER)
assert [s["id"] for s in stored] == ["my-skill"]
assert skill_store.exists()
def test_create_rejects_reserved_id(self, skill_store):
with pytest.raises(ValueError):
create_user_skill(USER, {"id": "research", "label": "x", "prompt": "y"})
def test_create_rejects_duplicate(self, skill_store):
create_user_skill(USER, {"id": "dup", "label": "x", "prompt": "y"})
with pytest.raises(ValueError):
create_user_skill(USER, {"id": "dup", "label": "x", "prompt": "y"})
def test_create_rejects_bad_id(self, skill_store):
with pytest.raises(ValueError):
create_user_skill(USER, {"id": "Bad ID!", "label": "x", "prompt": "y"})
def test_create_requires_prompt(self, skill_store):
with pytest.raises(ValueError):
create_user_skill(USER, {"id": "noprompt", "label": "x", "prompt": ""})
def test_user_skill_prompt_resolves(self, skill_store):
create_user_skill(USER, {"id": "custom", "label": "C", "prompt": "Instruction Z"})
assert get_skill_prompt("custom", USER) == "Instruction Z"
def test_skills_are_per_user(self, skill_store):
create_user_skill(USER, {"id": "only-alice", "label": "A", "prompt": "p"})
assert list_user_skills({"username": "bob"}) == []
def test_delete(self, skill_store):
create_user_skill(USER, {"id": "temp", "label": "T", "prompt": "p"})
assert delete_user_skill(USER, "temp") is True
assert list_user_skills(USER) == []
assert delete_user_skill(USER, "temp") is False
def test_corrupted_store_is_ignored(self, skill_store):
skill_store.write_text("{not json", encoding="utf-8")
assert list_user_skills(USER) == []
# Creating still works (store is rewritten).
create_user_skill(USER, {"id": "recover", "label": "R", "prompt": "p"})
data = json.loads(skill_store.read_text(encoding="utf-8"))
assert data["alice"][0]["id"] == "recover"
# ── Endpoint tests ───────────────────────────────────────────────────────
@pytest.fixture
def skills_client(tmp_path, monkeypatch):
"""TestClient with auth enabled and an isolated data dir."""
import asyncio
import shutil
import tempfile
from fastapi.testclient import TestClient
tmp = Path(tempfile.mkdtemp())
data_dir = tmp / "data"
data_dir.mkdir()
from backend.auth.password import hash_password
users = {
"version": 1,
"users": {
"testuser": {
"id": "testuser-1",
"username": "testuser",
"display_name": "Test User",
"password_hash": hash_password("TestPass123!"),
"role": "admin",
"vaults": ["*"],
"active": True,
"created_at": "2026-01-01T00:00:00",
}
},
}
(data_dir / "users.json").write_text(json.dumps(users), encoding="utf-8")
src_secret = Path("data/secret.key")
if src_secret.exists():
shutil.copy2(str(src_secret), str(data_dir / "secret.key"))
orig_cwd = os.getcwd()
os.chdir(str(tmp))
monkeypatch.setattr(skills_mod, "SKILLS_FILE", Path("data/skills.json"))
os.environ["OBSIGATE_AUTH_ENABLED"] = "true"
os.environ["OBSIGATE_ADMIN_USER"] = "testuser"
os.environ["OBSIGATE_ADMIN_PASSWORD"] = "TestPass123!"
os.environ["OBSIGATE_WATCHER_ENABLED"] = "false"
os.environ["VAULT_1_NAME"] = "TestVault"
os.environ["VAULT_1_PATH"] = str(tmp / "vault")
import backend.main
backend.main._load_config = lambda: {"watcher_enabled": False}
from backend.indexer import index
for key in list(index.keys()):
del index[key]
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
client = TestClient(backend.main.app)
yield client
client.close()
os.chdir(orig_cwd)
shutil.rmtree(str(tmp), ignore_errors=True)
for k in ["OBSIGATE_AUTH_ENABLED", "OBSIGATE_ADMIN_USER", "OBSIGATE_ADMIN_PASSWORD",
"OBSIGATE_WATCHER_ENABLED", "VAULT_1_NAME", "VAULT_1_PATH"]:
os.environ.pop(k, None)
def _token(client):
resp = client.post("/api/auth/login", json={"username": "testuser", "password": "TestPass123!"})
return resp.json()["access_token"]
class TestSkillsEndpoints:
def test_list_requires_auth(self, skills_client):
assert skills_client.get("/api/ai/skills").status_code == 401
def test_list_returns_builtins_and_commands(self, skills_client):
token = _token(skills_client)
resp = skills_client.get("/api/ai/skills", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
data = resp.json()
ids = {s["id"] for s in data["skills"]}
assert "research" in ids
assert any(c["id"] == "help" for c in data["commands"])
def test_create_and_delete_roundtrip(self, skills_client):
token = _token(skills_client)
headers = {"Authorization": f"Bearer {token}"}
resp = skills_client.post(
"/api/ai/skills",
json={"id": "custom", "label": "Custom", "prompt": "Do it"},
headers=headers,
)
assert resp.status_code == 200, resp.text
assert resp.json()["id"] == "custom"
listed = skills_client.get("/api/ai/skills", headers=headers).json()
assert "custom" in {s["id"] for s in listed["skills"]}
deleted = skills_client.delete("/api/ai/skills/custom", headers=headers)
assert deleted.status_code == 200
def test_create_invalid_id_returns_400(self, skills_client):
token = _token(skills_client)
resp = skills_client.post(
"/api/ai/skills",
json={"id": "Bad!", "label": "x", "prompt": "y"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 400
def test_delete_unknown_returns_404(self, skills_client):
token = _token(skills_client)
resp = skills_client.delete(
"/api/ai/skills/unknown", headers={"Authorization": f"Bearer {token}"}
)
assert resp.status_code == 404