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é ### 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` - **#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 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) ; `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 json
import logging import logging
import re
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
@@ -162,6 +163,46 @@ def _gemini_tools(tools: list[dict[str, Any]] | None) -> list[dict[str, Any]] |
return [{"functionDeclarations": declarations}] 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]]]: def _gemini_contents(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
"""Split OpenAI-style messages into Gemini ``system`` text + ``contents``.""" """Split OpenAI-style messages into Gemini ``system`` text + ``contents``."""
system_parts: list[str] = [] 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: for msg in messages:
role = msg.get("role") role = msg.get("role")
if role == "system": 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": elif role == "tool":
contents.append({ contents.append({
"role": "user", "role": "user",
@@ -183,7 +225,7 @@ def _gemini_contents(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str
else: else:
contents.append({ contents.append({
"role": "user" if role == "user" else "model", "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 return "\n".join(p for p in system_parts if p).strip(), contents
+32 -1
View File
@@ -2,7 +2,7 @@
import logging import logging
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from backend.ai import ( from backend.ai import (
@@ -25,6 +25,8 @@ from backend.ai import (
ai_translate, ai_translate,
get_default_provider, get_default_provider,
) )
from backend.auth.middleware import require_auth
from backend.model_capabilities import get_model_capabilities
from backend.schemas import AIStatusResponse from backend.schemas import AIStatusResponse
logger = logging.getLogger("obsigate.ai_routes") logger = logging.getLogger("obsigate.ai_routes")
@@ -232,3 +234,32 @@ async def api_inline_complete(req: AIRequest):
async def api_to_canvas(req: AIRequest): async def api_to_canvas(req: AIRequest):
"""Convert to Mermaid diagram or outline.""" """Convert to Mermaid diagram or outline."""
return await _handle(ai_convert_to_canvas, req) 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. for repeated queries.
""" """
import base64
import hashlib import hashlib
import json import json
import logging import logging
import mimetypes
import os import os
import time import time
from pathlib import Path 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_FILES = int(os.getenv("BOOKSLM_MAX_FILES", "200"))
BOOKSLM_MAX_TOTAL_CHARS = int(os.getenv("BOOKSLM_MAX_TOTAL_CHARS", "200000")) BOOKSLM_MAX_TOTAL_CHARS = int(os.getenv("BOOKSLM_MAX_TOTAL_CHARS", "200000"))
BOOKSLM_MAX_FILE_CHARS = int(os.getenv("BOOKSLM_MAX_FILE_CHARS", "30000")) 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 ──
_cache: dict[str, dict[str, Any]] = {} _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]: def empty_context(scope: str = "general") -> dict[str, Any]:
"""Return an empty context payload (used by the General assistant).""" """Return an empty context payload (used by the General assistant)."""
return { return {
+146 -18
View File
@@ -15,12 +15,17 @@ from backend.auth.middleware import check_vault_access, require_auth
from backend.bookslm import ( from backend.bookslm import (
build_general_system_prompt, build_general_system_prompt,
build_system_prompt, build_system_prompt,
collect_adhoc_context,
collect_directory_context, collect_directory_context,
collect_files_context, collect_files_context,
empty_context, empty_context,
load_vault_image_data_url,
merge_contexts,
) )
from backend.indexer import get_vault_data, index from backend.indexer import get_vault_data, index
from backend.model_capabilities import model_supports_vision
from backend.schemas import BooksLMContextResponse from backend.schemas import BooksLMContextResponse
from backend.skills import get_skill_prompt
from backend.tools.api import ToolContext, ToolMode from backend.tools.api import ToolContext, ToolMode
logger = logging.getLogger("obsigate.bookslm_routes") logger = logging.getLogger("obsigate.bookslm_routes")
@@ -40,6 +45,14 @@ class BooksLMContextRequest(BaseModel):
default_factory=list, default_factory=list,
description="Relative file paths to use as context (documents mode)", 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): class BooksLMChatRequest(BaseModel):
@@ -50,7 +63,24 @@ class BooksLMChatRequest(BaseModel):
default_factory=list, default_factory=list,
description="Relative file paths to use as context (documents mode)", 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") 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( conversation_history: list[dict[str, str]] = Field(
default_factory=list, default_factory=list,
description="Previous conversation turns [{role, content}]", 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"]) return vault, Path(vault_data["path"])
def _build_context(mode: str, vault_path: Path | None, directory: str, context_files: list[str]): def _build_context(
"""Collect the context payload for the requested mode.""" 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": if mode == "general":
return empty_context("general") base = empty_context("general")
if mode == "documents": elif mode == "documents":
ctx = collect_files_context(vault_path, context_files, scope="documents") # type: ignore[arg-type] ctx = collect_files_context(vault_path, context_files, scope="documents") # type: ignore[arg-type]
# No open document could be read → degrade gracefully to General. # No open document could be read → degrade gracefully to General.
return ctx if ctx["file_count"] else empty_context("general") base = ctx if ctx["file_count"] else empty_context("general")
return collect_directory_context(vault_path, directory) # type: ignore[arg-type] 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: def _resolve_system_prompt(req, current_user) -> str:
@@ -114,16 +164,85 @@ def _resolve_system_prompt(req, current_user) -> str:
if mode != "general": if mode != "general":
_, vault_path = _resolve_vault_path(req.vault, current_user) _, 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) effective_mode = context.get("scope", mode)
if effective_mode == "general": if effective_mode == "general":
return build_general_system_prompt(list(index.keys())) prompt = build_general_system_prompt(list(index.keys()))
if effective_mode == "documents": elif effective_mode == "documents":
return build_system_prompt(context, scope="documents") prompt = build_system_prompt(context, scope="documents")
if context["file_count"] == 0: elif context["file_count"] == 0:
raise HTTPException(status_code=404, detail="Aucun fichier markdown trouvé dans ce dossier") 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: def _resolve_provider_name(requested: str | None) -> str | None:
@@ -157,10 +276,15 @@ async def api_bookslm_context(
""" """
mode = _normalize_mode(req.mode) mode = _normalize_mode(req.mode)
if mode == "general": if mode == "general":
return empty_context("general") if not (req.extra_files or req.extra_directories):
return empty_context("general")
_, vault_path = _resolve_vault_path(req.vault, current_user) vault_path = _resolve_optional_vault_path(req, current_user)
return _build_context(mode, vault_path, req.directory, req.context_files) 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( @router.post(
@@ -178,7 +302,9 @@ async def api_bookslm_chat(
documents or general app knowledge), then streams the provider's answer documents or general app knowledge), then streams the provider's answer
as Server-Sent Events. as Server-Sent Events.
""" """
_validate_vision_support(req)
system_prompt = _resolve_system_prompt(req, current_user) 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}] messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
for turn in req.conversation_history: for turn in req.conversation_history:
@@ -186,7 +312,7 @@ async def api_bookslm_chat(
content = turn.get("content", "") content = turn.get("content", "")
if role in ("user", "assistant") and content: if role in ("user", "assistant") and content:
messages.append({"role": role, "content": 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(): async def generate_sse():
try: try:
@@ -246,7 +372,9 @@ async def api_bookslm_agent(
and the conversation snapshot; the client resumes by echoing them back in and the conversation snapshot; the client resumes by echoing them back in
``confirm`` / ``confirm_messages``. ``confirm`` / ``confirm_messages``.
""" """
_validate_vision_support(req)
system_prompt = _resolve_system_prompt(req, current_user) 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}] messages: list[dict] = [{"role": "system", "content": system_prompt}]
for turn in req.conversation_history: for turn in req.conversation_history:
@@ -254,7 +382,7 @@ async def api_bookslm_agent(
content = turn.get("content", "") content = turn.get("content", "")
if role in ("user", "assistant") and content: if role in ("user", "assistant") and content:
messages.append({"role": role, "content": 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) ctx = ToolContext(user=current_user, mode=ToolMode.IN_APP)
+10 -2
View File
@@ -854,6 +854,7 @@ from backend.share import (
revoke_share, revoke_share,
update_shares_after_rename, update_shares_after_rename,
) )
from backend.skills_routes import router as skills_router
from backend.webhooks import ( from backend.webhooks import (
create_webhook, create_webhook,
delete_webhook, delete_webhook,
@@ -865,6 +866,7 @@ from backend.webhooks import (
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(ai_router) app.include_router(ai_router)
app.include_router(bookslm_router) app.include_router(bookslm_router)
app.include_router(skills_router)
# Admin Dashboard endpoints (system stats, audit logs, backups, stream) # Admin Dashboard endpoints (system stats, audit logs, backups, stream)
try: try:
@@ -3571,6 +3573,8 @@ async def api_list_ai_models(provider: str = Query(...), current_user=Depends(re
""" """
provider = provider.lower() provider = provider.lower()
from backend.model_capabilities import get_capabilities_for_models
all_providers = ("deepseek", "openrouter", "gemini", "nvidia", "qwencloud", "xiaomi", "mistral") all_providers = ("deepseek", "openrouter", "gemini", "nvidia", "qwencloud", "xiaomi", "mistral")
if provider not in all_providers: if provider not in all_providers:
return {"models": [], "error": f"Unknown provider: {provider}", "source": "validation"} 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: if not key:
# No key configured — return curated fallback list so the UI can # No key configured — return curated fallback list so the UI can
# still show what WOULD be available once a key is set. # 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"} "note": "API key not configured — showing default model list"}
# Build URL # 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") default = PROVIDERS.get(provider, {}).get("model")
if default and default not in models: if default and default not in models:
models = [default] + 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 # Empty list from API — fall through to fallback
raise ValueError("empty model list from provider API") raise ValueError("empty model list from provider API")
except Exception as e: except Exception as e:
# Network error, auth error, parsing error — use curated fallback # Network error, auth error, parsing error — use curated fallback
fallback = _FALLBACK_MODELS.get(provider, []) fallback = _FALLBACK_MODELS.get(provider, [])
return {"models": fallback, "source": "fallback", "error": str(e)[:200], 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"} "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") count: int | None = Field(default=None, description="Number of live models")
error: str | None = Field(default=None, description="Provider/network error, if any") error: str | None = Field(default=None, description="Provider/network error, if any")
note: str | None = Field(default=None, description="Explanatory note when using fallback") 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): 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 ## 7. Plan par phases
| Phase | Contenu | Livrable | | 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) | | 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) | | 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) | | 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) | | 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) | | 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) | | 77 | Application Desktop native — Tauri | 🔵 en cours | [features/desktop-tauri.md](./features/desktop-tauri.md) |
@@ -104,7 +105,7 @@
| Priorité | Items | Effort total estimé | | 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 | | 🔵 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 | | ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours |
| **Total restant** | **2 items + finitions** | **~7-10 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> <option value="">--</option>
</select> </select>
</div> </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"> <div class="config-row">
<label <label
class="config-label" class="config-label"
@@ -4259,6 +4271,25 @@
chercher) ; les actions de modification demandent une chercher) ; les actions de modification demandent une
confirmation avec aperçu des changements. confirmation avec aperçu des changements.
</li> </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> </ul>
</section> </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. // State is stored in localStorage so the user choice persists across sections.
const PICKER_STORAGE_KEY = 'obsigate_ai_picker'; 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() { function _readPicker() {
try { try {
return JSON.parse(localStorage.getItem(PICKER_STORAGE_KEY) || '{}'); return JSON.parse(localStorage.getItem(PICKER_STORAGE_KEY) || '{}');
@@ -45,6 +51,32 @@ function _writePicker(state) {
} catch { /* */ } } 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 * 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 * (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'); placeholderOpt.textContent = t('ai.model_default');
modelSelect.appendChild(placeholderOpt); 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) { async function _loadModels(provider) {
modelSelect.innerHTML = ''; modelSelect.innerHTML = '';
const ph = document.createElement('option'); const ph = document.createElement('option');
@@ -148,6 +198,13 @@ async function _buildPickerUI() {
if (m === pickerState.model && provider === pickerState.provider) opt.selected = true; if (m === pickerState.model && provider === pickerState.provider) opt.selected = true;
modelSelect.appendChild(opt); 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) { } catch (err) {
modelSelect.innerHTML = ''; modelSelect.innerHTML = '';
const opt = document.createElement('option'); const opt = document.createElement('option');
@@ -161,7 +218,9 @@ async function _buildPickerUI() {
pickerState.provider = providerSelect.value || null; pickerState.provider = providerSelect.value || null;
// Reset model when provider changes // Reset model when provider changes
pickerState.model = null; pickerState.model = null;
delete pickerState.capabilities;
_writePicker(pickerState); _writePicker(pickerState);
capsHost.innerHTML = '';
if (pickerState.provider) { if (pickerState.provider) {
_loadModels(pickerState.provider); _loadModels(pickerState.provider);
} else { } else {
@@ -175,7 +234,9 @@ async function _buildPickerUI() {
modelSelect.addEventListener('change', () => { modelSelect.addEventListener('change', () => {
pickerState.model = modelSelect.value || null; pickerState.model = modelSelect.value || null;
delete pickerState.capabilities;
_writePicker(pickerState); _writePicker(pickerState);
_updateCaps();
}); });
// Load models for the initial provider selection // Load models for the initial provider selection
@@ -185,6 +246,7 @@ async function _buildPickerUI() {
wrap.appendChild(providerSelect); wrap.appendChild(providerSelect);
wrap.appendChild(modelSelect); wrap.appendChild(modelSelect);
wrap.appendChild(capsHost);
return wrap; return wrap;
} }
@@ -712,6 +774,9 @@ export {
_readPicker, _readPicker,
_writePicker, _writePicker,
PICKER_STORAGE_KEY, PICKER_STORAGE_KEY,
AI_CAPABILITY_KEYS,
getModelCapabilities,
renderCapabilityList,
_buildPickerUI as buildAIPickerUI, _buildPickerUI as buildAIPickerUI,
_promptRewriteInstruction, _promptRewriteInstruction,
}; };
+666 -6
View File
@@ -5,7 +5,7 @@
// • documents — the file(s) currently open in tabs/panes // • documents — the file(s) currently open in tabs/panes
// • general — no document open: app help + file/directory creation actions // • general — no document open: app help + file/directory creation actions
import { t } from './i18n.js'; import { t } from './i18n.js';
import { buildAIPickerUI } from './ai.js'; import { buildAIPickerUI, getModelCapabilities } from './ai.js';
import { safeCreateIcons } from './utils.js'; import { safeCreateIcons } from './utils.js';
import { api, AuthManager } from './auth.js'; import { api, AuthManager } from './auth.js';
import { showToast } from './ui.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; 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. // 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; 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 * 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 // Agent mode: routes chat through /api/ai/bookslm/agent so the model can
// call read/search tools and propose mutations (confirmation cards). // call read/search tools and propose mutations (confirmation cards).
this._agentMode = this._readAgentMode(); 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() { _readAgentMode() {
@@ -147,12 +163,17 @@ class BooksLM {
this._effectiveScope = mode; this._effectiveScope = mode;
this._messages = []; this._messages = [];
this._contextFiles = []; this._contextFiles = [];
this._adhocFiles = [];
this._adhocDirs = [];
this._images = [];
this._activeSkill = null;
this._isLoading = true; this._isLoading = true;
this._loadHistory(); this._loadHistory();
if (!this._panel) { if (!this._panel) {
this._panel = this._render(); this._panel = this._render();
document.body.appendChild(this._panel); document.body.appendChild(this._panel);
this._applyPanelWidth();
if (typeof safeCreateIcons === 'function') safeCreateIcons(); if (typeof safeCreateIcons === 'function') safeCreateIcons();
} }
@@ -163,6 +184,7 @@ class BooksLM {
this._updateStatus(); this._updateStatus();
this._renderMessages(); this._renderMessages();
this._showSuggestions(); this._showSuggestions();
this._renderAttachments();
this._closeHistoryMenu(); this._closeHistoryMenu();
this._renderHistoryMenu(); this._renderHistoryMenu();
@@ -331,7 +353,8 @@ class BooksLM {
// ── Context ───────────────────────────────────────────────────────── // ── Context ─────────────────────────────────────────────────────────
async _loadContext() { 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._effectiveScope = MODE.GENERAL;
this._contextFiles = []; this._contextFiles = [];
this._updateStatus({ files: [], total_chars: 0, file_count: 0, scope: MODE.GENERAL, max_total_chars: 200000 }); this._updateStatus({ files: [], total_chars: 0, file_count: 0, scope: MODE.GENERAL, max_total_chars: 200000 });
@@ -341,9 +364,11 @@ class BooksLM {
const payload = { const payload = {
mode: this._mode, mode: this._mode,
vault: this._vault, vault: this._vault || this._resolveVault(),
directory: this._directory, directory: this._directory,
context_files: this._mode === MODE.DOCUMENTS ? this._documents.map((d) => d.path) : [], 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 { try {
const data = await api('/api/ai/bookslm/context', { const data = await api('/api/ai/bookslm/context', {
@@ -396,6 +421,7 @@ class BooksLM {
const panel = document.createElement('div'); const panel = document.createElement('div');
panel.className = 'bookslm-panel'; panel.className = 'bookslm-panel';
panel.innerHTML = ` 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"> <div class="bookslm-header">
<button class="bookslm-btn-toggle" title="${t('bookslm.toggle_sidebar')}" aria-label="${t('bookslm.toggle_sidebar')}"> <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> <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-status"></div>
<div class="bookslm-suggestions"></div> <div class="bookslm-suggestions"></div>
<div class="bookslm-messages"></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"> <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> <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> <button class="bookslm-btn-send" title="${t('bookslm.send')}">${t('bookslm.send')}</button>
</div> </div>
@@ -460,11 +493,14 @@ class BooksLM {
else this._openFileLink(path); 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) => { panel.addEventListener('click', (e) => {
if (!e.target.closest('.bookslm-history-menu') && !e.target.closest('.bookslm-btn-history')) { if (!e.target.closest('.bookslm-history-menu') && !e.target.closest('.bookslm-btn-history')) {
this._closeHistoryMenu(); this._closeHistoryMenu();
} }
if (!e.target.closest('.bookslm-command-menu') && !e.target.closest('.bookslm-mention-menu')) {
this._hideMenus();
}
}); });
panel.querySelector('.bookslm-btn-fullscreen').addEventListener('click', () => { panel.querySelector('.bookslm-btn-fullscreen').addEventListener('click', () => {
this._isFullscreen = !this._isFullscreen; this._isFullscreen = !this._isFullscreen;
@@ -478,12 +514,31 @@ class BooksLM {
panel.querySelector('.bookslm-btn-send').addEventListener('click', () => this._sendMessage()); 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'); const textarea = panel.querySelector('textarea');
textarea.addEventListener('input', () => { textarea.addEventListener('input', () => {
textarea.style.height = 'auto'; textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 140) + 'px'; textarea.style.height = Math.min(textarea.scrollHeight, 140) + 'px';
this._onComposerInput();
}); });
textarea.addEventListener('paste', (e) => this._onPaste(e));
textarea.addEventListener('keydown', (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.key !== 'Enter') return;
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
// Ctrl/Cmd+Enter → insert a newline at the caret. // Ctrl/Cmd+Enter → insert a newline at the caret.
@@ -504,6 +559,580 @@ class BooksLM {
return panel; 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() { _updateHeader() {
if (!this._panel) return; if (!this._panel) return;
const meta = this._contextMeta(); const meta = this._contextMeta();
@@ -884,6 +1513,26 @@ class BooksLM {
const text = (textarea.value || '').trim(); const text = (textarea.value || '').trim();
if (!text || this._isLoading) return; 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.value = '';
textarea.style.height = 'auto'; textarea.style.height = 'auto';
@@ -908,9 +1557,15 @@ class BooksLM {
const payload = { const payload = {
mode: this._mode, mode: this._mode,
vault: this._vault, vault: this._vault || this._resolveVault(),
directory: this._directory, directory: this._directory,
context_files: this._mode === MODE.DOCUMENTS ? this._documents.map((d) => d.path) : [], 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, message: text,
conversation_history: history, conversation_history: history,
provider, provider,
@@ -966,7 +1621,12 @@ class BooksLM {
/** POST to /chat or /agent depending on the agent-mode toggle. */ /** POST to /chat or /agent depending on the agent-mode toggle. */
_postChat(payload) { _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() || {}) }; const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) };
return fetch(endpoint, { return fetch(endpoint, {
method: 'POST', method: 'POST',
+23 -1
View File
@@ -6,6 +6,7 @@ import { syncVaultSelectors, setSelectedVaultContext, refreshSidebarForContext,
import { escapeHtml, safeCreateIcons } from './utils.js'; import { escapeHtml, safeCreateIcons } from './utils.js';
import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js'; import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js';
import { t, setLocale, getLocale } from './i18n.js'; import { t, setLocale, getLocale } from './i18n.js';
import { getModelCapabilities, renderCapabilityList } from './ai.js';
let _recentTimestampTimer = null; let _recentTimestampTimer = null;
let _recentFilesCache = []; let _recentFilesCache = [];
@@ -625,6 +626,13 @@ function initConfigModal() {
await _populateAIModelOptions(aiDefaultProviderSel.value, models[aiDefaultProviderSel.value] || ""); 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 // Load existing keys/defaults on modal open
document.getElementById("config-open-btn").addEventListener("click", loadAIKeys); document.getElementById("config-open-btn").addEventListener("click", loadAIKeys);
document.getElementById("config-open-btn").addEventListener("click", loadAIDefaults); 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"); const modelSel = document.getElementById("cfg-ai-default-model");
if (!modelSel) return; if (!modelSel) return;
modelSel.innerHTML = '<option value="">--</option>'; modelSel.innerHTML = '<option value="">--</option>';
if (!provider) return; if (!provider) {
_renderConfigModelCaps(null, null);
return;
}
try { try {
const m = await api("/api/config/ai-models?provider=" + encodeURIComponent(provider)); const m = await api("/api/config/ai-models?provider=" + encodeURIComponent(provider));
const list = (m && m.models) || []; const list = (m && m.models) || [];
@@ -1451,9 +1462,20 @@ async function _populateAIModelOptions(provider, selected) {
modelSel.innerHTML += '<option value="' + selected + '">' + selected + '</option>'; modelSel.innerHTML += '<option value="' + selected + '">' + selected + '</option>';
} }
modelSel.value = selected || ""; modelSel.value = selected || "";
_renderConfigModelCaps(provider, modelSel.value);
} catch(e) { console.warn("Failed to load default models for", provider, e); } } 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() { async function saveAIKeys() {
const keys = {}; const keys = {};
for (const [id, name] of Object.entries(AI_KEY_MAP)) { for (const [id, name] of Object.entries(AI_KEY_MAP)) {
+34
View File
@@ -128,6 +128,35 @@
"ai.model_offline": "offline", "ai.model_offline": "offline",
"ai.model_load_error": "Load error", "ai.model_load_error": "Load error",
"ai.model_settings": "Provider & model", "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.quota_exceeded": "AI: quota exceeded or payment required",
"ai.rewrite": "💬 Rewrite", "ai.rewrite": "💬 Rewrite",
"ai.rewrite_done": "AI: text rewritten", "ai.rewrite_done": "AI: text rewritten",
@@ -317,6 +346,7 @@
"config.add_webhook": "Add", "config.add_webhook": "Add",
"config.ai_deepseek_label": "DeepSeek API Key", "config.ai_deepseek_label": "DeepSeek API Key",
"config.ai_default_model": "Default model", "config.ai_default_model": "Default model",
"config.ai_capabilities": "Model capabilities",
"config.ai_default_provider": "Default provider", "config.ai_default_provider": "Default provider",
"config.ai_gemini_label": "Gemini API Key", "config.ai_gemini_label": "Gemini API Key",
"config.ai_keys": "AI API keys", "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_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_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_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.toolbar_section": "AI toolbar",
"help.tree_section": "Vault tree", "help.tree_section": "Vault tree",
"help.use_cases": "Use cases", "help.use_cases": "Use cases",
+34
View File
@@ -128,6 +128,35 @@
"ai.model_offline": "hors ligne", "ai.model_offline": "hors ligne",
"ai.model_load_error": "Erreur de chargement", "ai.model_load_error": "Erreur de chargement",
"ai.model_settings": "Fournisseur & modèle", "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.quota_exceeded": "AI: quota dépassé ou paiement requis",
"ai.rewrite": "💬 Réécrire", "ai.rewrite": "💬 Réécrire",
"ai.rewrite_done": "AI: texte réécrit", "ai.rewrite_done": "AI: texte réécrit",
@@ -317,6 +346,7 @@
"config.add_webhook": "Ajouter", "config.add_webhook": "Ajouter",
"config.ai_deepseek_label": "DeepSeek API Key", "config.ai_deepseek_label": "DeepSeek API Key",
"config.ai_default_model": "Modèle par défaut", "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_default_provider": "Fournisseur par défaut",
"config.ai_gemini_label": "Gemini API Key", "config.ai_gemini_label": "Gemini API Key",
"config.ai_keys": "Clés API IA", "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_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_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_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.toolbar_section": "Barre d'outils AI",
"help.tree_section": "Arborescence des vaults", "help.tree_section": "Arborescence des vaults",
"help.use_cases": "Cas d'usage", "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; } 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-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; } .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) { @media (max-width: 768px) {
.bookslm-resize-handle { display: none; }
.bookslm-panel { .bookslm-panel {
width: 100%; width: 100%;
/* Clear the fixed bottom toolbar so the input box and the send button /* 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(); 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 ── // ── Summary ──
console.log(`\n${passCount}/${testCount} tests passed`); console.log(`\n${passCount}/${testCount} tests passed`);
if (passCount !== testCount) { if (passCount !== testCount) {
+31
View File
@@ -256,6 +256,37 @@ class TestListModelsEndpoint:
assert hdrs["api-key"] == "fake-xiaomi-key" assert hdrs["api-key"] == "fake-xiaomi-key"
assert "authorization" not in hdrs, f"Authorization leaked: {hdrs!r}" 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): 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. """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