feat(ai): commandes @/ & skills, analyse d'images et capacites des modeles (#81)
This commit is contained in:
@@ -14,6 +14,23 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#81 Assistant IA — Commandes `@`/`/`, skills, images & capacités des modèles** — l'assistant
|
||||
devient plus flexible et multimodal. **Panneau redimensionnable** à la souris (largeur 320–1000 px
|
||||
persistée). **Commande `@`** pour attacher des contextes ad-hoc (fichiers, répertoires) au contexte
|
||||
courant. **Commande `/`** pour lancer des **skills** réutilisables (`/research`, `/resume`,
|
||||
`/actions`, `/reformuler`, `/correction`, `/brainstorm`, `/plan`, `/ask`, `/meeting-note`,
|
||||
`/livrable`) ou des commandes admin (`/help`, `/providers`, `/provider`, `/model`, `/keys`) ;
|
||||
`/create-new-skill` permet de créer des skills personnalisés persistés par utilisateur
|
||||
(`data/skills.json`). **Analyse d'images** : coller une image dans la zone de saisie ou référencer
|
||||
une image d'un répertoire (`@image.png`), avec garde-fou de compatibilité vision. **Capacités des
|
||||
modèles** (Chat, Embeddings, Rerank, Images, Video, Audio Speech, Audio Transcriptions, Vision)
|
||||
affichées à la sélection dans l'assistant et le panneau de configuration. Backend : table curée
|
||||
`backend/model_capabilities.py`, endpoints `GET /api/ai/model-capabilities` et `GET /api/ai/skills`,
|
||||
support multimodal dans `backend/ai_chat.py` et `backend/bookslm_routes.py`. Tests :
|
||||
`tests/test_model_capabilities.py` (16), `tests/test_skills.py` (16), `tests/test_ai_vision.py` (12)
|
||||
+ extension `tests/test_ai_models.py` et `tests/frontend/ai.test.mjs` (+9). Détail :
|
||||
[docs/features/ai-assistant-commands.md](./docs/features/ai-assistant-commands.md).
|
||||
|
||||
- **#77 Desktop — Signature des mises à jour Tauri** — la paire de clés `minisign`
|
||||
de l'updater est générée et sa clé publique est embarquée dans
|
||||
`desktop/tauri.conf.json` (`plugins.updater.pubkey`, remplace le placeholder) ;
|
||||
|
||||
+44
-2
@@ -17,6 +17,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
@@ -162,6 +163,46 @@ def _gemini_tools(tools: list[dict[str, Any]] | None) -> list[dict[str, Any]] |
|
||||
return [{"functionDeclarations": declarations}]
|
||||
|
||||
|
||||
_DATA_URL_RE = re.compile(r"^data:([^;,]+);base64,(.*)$", re.DOTALL)
|
||||
|
||||
|
||||
def _content_to_gemini_parts(content: Any) -> list[dict[str, Any]]:
|
||||
"""Convert OpenAI-style message content to Gemini ``parts``.
|
||||
|
||||
Accepts either a plain string or a multimodal content array
|
||||
(``[{"type": "text", ...}, {"type": "image_url", ...}]``). Data URLs are
|
||||
turned into ``inlineData`` parts so images can be sent to vision models.
|
||||
"""
|
||||
if content is None:
|
||||
return [{"text": ""}]
|
||||
if isinstance(content, str):
|
||||
return [{"text": content}]
|
||||
|
||||
parts: list[dict[str, Any]] = []
|
||||
if isinstance(content, list):
|
||||
for item in content:
|
||||
if isinstance(item, str):
|
||||
parts.append({"text": item})
|
||||
continue
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
item_type = item.get("type")
|
||||
if item_type == "text":
|
||||
parts.append({"text": item.get("text", "")})
|
||||
elif item_type == "image_url":
|
||||
url = (item.get("image_url") or {}).get("url", "")
|
||||
match = _DATA_URL_RE.match(url or "")
|
||||
if match:
|
||||
parts.append({
|
||||
"inlineData": {"mimeType": match.group(1), "data": match.group(2)},
|
||||
})
|
||||
elif url:
|
||||
parts.append({"fileData": {"fileUri": url}})
|
||||
if not parts:
|
||||
parts.append({"text": ""})
|
||||
return parts
|
||||
|
||||
|
||||
def _gemini_contents(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
|
||||
"""Split OpenAI-style messages into Gemini ``system`` text + ``contents``."""
|
||||
system_parts: list[str] = []
|
||||
@@ -169,7 +210,8 @@ def _gemini_contents(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str
|
||||
for msg in messages:
|
||||
role = msg.get("role")
|
||||
if role == "system":
|
||||
system_parts.append(msg.get("content") or "")
|
||||
content = msg.get("content") or ""
|
||||
system_parts.append(content if isinstance(content, str) else "")
|
||||
elif role == "tool":
|
||||
contents.append({
|
||||
"role": "user",
|
||||
@@ -183,7 +225,7 @@ def _gemini_contents(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str
|
||||
else:
|
||||
contents.append({
|
||||
"role": "user" if role == "user" else "model",
|
||||
"parts": [{"text": msg.get("content") or ""}],
|
||||
"parts": _content_to_gemini_parts(msg.get("content")),
|
||||
})
|
||||
return "\n".join(p for p in system_parts if p).strip(), contents
|
||||
|
||||
|
||||
+32
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.ai import (
|
||||
@@ -25,6 +25,8 @@ from backend.ai import (
|
||||
ai_translate,
|
||||
get_default_provider,
|
||||
)
|
||||
from backend.auth.middleware import require_auth
|
||||
from backend.model_capabilities import get_model_capabilities
|
||||
from backend.schemas import AIStatusResponse
|
||||
|
||||
logger = logging.getLogger("obsigate.ai_routes")
|
||||
@@ -232,3 +234,32 @@ async def api_inline_complete(req: AIRequest):
|
||||
async def api_to_canvas(req: AIRequest):
|
||||
"""Convert to Mermaid diagram or outline."""
|
||||
return await _handle(ai_convert_to_canvas, req)
|
||||
|
||||
|
||||
class ModelCapabilitiesResponse(BaseModel):
|
||||
"""Capabilities of a single provider/model pair."""
|
||||
|
||||
provider: str
|
||||
model: str
|
||||
capabilities: dict[str, bool] = Field(
|
||||
description="Flags: chat, embeddings, rerank, images, video, "
|
||||
"audio_speech, audio_transcription, vision",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/model-capabilities", response_model=ModelCapabilitiesResponse)
|
||||
async def api_model_capabilities(
|
||||
provider: str = Query(..., description="Provider identifier"),
|
||||
model: str = Query("", description="Model identifier (optional)"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return the curated capability flags for a provider/model pair.
|
||||
|
||||
The table is static (see ``backend.model_capabilities``) so the answer is
|
||||
available offline and never depends on a provider API call.
|
||||
"""
|
||||
return {
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"capabilities": get_model_capabilities(provider, model),
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ redaction, builds a system prompt with file contents, and caches results
|
||||
for repeated queries.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -21,6 +23,10 @@ logger = logging.getLogger("obsigate.bookslm")
|
||||
BOOKSLM_MAX_FILES = int(os.getenv("BOOKSLM_MAX_FILES", "200"))
|
||||
BOOKSLM_MAX_TOTAL_CHARS = int(os.getenv("BOOKSLM_MAX_TOTAL_CHARS", "200000"))
|
||||
BOOKSLM_MAX_FILE_CHARS = int(os.getenv("BOOKSLM_MAX_FILE_CHARS", "30000"))
|
||||
# Maximum size of an image sent to a vision model (bytes, before base64).
|
||||
BOOKSLM_MAX_IMAGE_BYTES = int(os.getenv("BOOKSLM_MAX_IMAGE_BYTES", "10000000"))
|
||||
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".avif"}
|
||||
|
||||
# ── Cache ──
|
||||
_cache: dict[str, dict[str, Any]] = {}
|
||||
@@ -260,6 +266,122 @@ def collect_files_context(
|
||||
}
|
||||
|
||||
|
||||
def collect_adhoc_context(
|
||||
vault_path: Path,
|
||||
files: list[str] | None = None,
|
||||
directories: list[str] | None = None,
|
||||
scope: str = "general",
|
||||
) -> dict[str, Any]:
|
||||
"""Collect an ad-hoc mix of explicit files and directories.
|
||||
|
||||
Backs the assistant ``@`` command: the user can attach extra files and
|
||||
directories to the current context without changing the base mode. Paths
|
||||
outside the vault are ignored by the underlying collectors.
|
||||
|
||||
Args:
|
||||
vault_path: Absolute path to the vault root.
|
||||
files: Relative file paths to include.
|
||||
directories: Relative directory paths to include (recursive).
|
||||
scope: Context label exposed to the UI.
|
||||
|
||||
Returns:
|
||||
Same shape as :func:`collect_directory_context`.
|
||||
"""
|
||||
vault_resolved = vault_path.resolve()
|
||||
collected: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
total_chars = 0
|
||||
|
||||
def _add(entries: list[dict[str, Any]]) -> None:
|
||||
nonlocal total_chars
|
||||
for entry in entries:
|
||||
if len(collected) >= BOOKSLM_MAX_FILES or total_chars >= BOOKSLM_MAX_TOTAL_CHARS:
|
||||
return
|
||||
path = entry.get("path")
|
||||
if not path or path in seen:
|
||||
continue
|
||||
seen.add(path)
|
||||
collected.append(entry)
|
||||
total_chars += len(entry.get("content", ""))
|
||||
|
||||
if files:
|
||||
_add(collect_files_context(vault_resolved, files, scope=scope)["files"])
|
||||
for directory in directories or []:
|
||||
_add(collect_directory_context(vault_resolved, directory)["files"])
|
||||
|
||||
return {
|
||||
"files": collected,
|
||||
"total_chars": total_chars,
|
||||
"file_count": len(collected),
|
||||
"directory_tree": "",
|
||||
"max_total_chars": BOOKSLM_MAX_TOTAL_CHARS,
|
||||
"max_files": BOOKSLM_MAX_FILES,
|
||||
"scope": scope,
|
||||
}
|
||||
|
||||
|
||||
def merge_contexts(base: dict[str, Any], extra: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Merge two context payloads, de-duplicating files by path."""
|
||||
files: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
total_chars = 0
|
||||
for entry in list(base.get("files", [])) + list(extra.get("files", [])):
|
||||
path = entry.get("path")
|
||||
if not path or path in seen:
|
||||
continue
|
||||
if len(files) >= BOOKSLM_MAX_FILES or total_chars >= BOOKSLM_MAX_TOTAL_CHARS:
|
||||
break
|
||||
seen.add(path)
|
||||
files.append(entry)
|
||||
total_chars += len(entry.get("content", ""))
|
||||
|
||||
tree = base.get("directory_tree", "") or extra.get("directory_tree", "")
|
||||
scope = extra.get("scope") or base.get("scope", "general")
|
||||
return {
|
||||
"files": files,
|
||||
"total_chars": total_chars,
|
||||
"file_count": len(files),
|
||||
"directory_tree": tree,
|
||||
"max_total_chars": BOOKSLM_MAX_TOTAL_CHARS,
|
||||
"max_files": BOOKSLM_MAX_FILES,
|
||||
"scope": scope,
|
||||
}
|
||||
|
||||
|
||||
def is_image_path(path: str) -> bool:
|
||||
"""True when the path has a supported image extension."""
|
||||
return Path(path or "").suffix.lower() in IMAGE_EXTENSIONS
|
||||
|
||||
|
||||
def load_vault_image_data_url(vault_path: Path, rel_path: str) -> str | None:
|
||||
"""Read a vault image and return it as a ``data:`` URL for vision models.
|
||||
|
||||
Returns ``None`` when the path is outside the vault, missing, too large,
|
||||
or not a supported image.
|
||||
"""
|
||||
if not rel_path or not is_image_path(rel_path):
|
||||
return None
|
||||
vault_resolved = vault_path.resolve()
|
||||
try:
|
||||
target = (vault_resolved / rel_path).resolve()
|
||||
target.relative_to(vault_resolved)
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
if not target.is_file():
|
||||
return None
|
||||
try:
|
||||
if target.stat().st_size > BOOKSLM_MAX_IMAGE_BYTES:
|
||||
logger.warning("Image too large to send: %s", rel_path)
|
||||
return None
|
||||
raw = target.read_bytes()
|
||||
except OSError as exc:
|
||||
logger.warning("Cannot read image %s: %s", rel_path, exc)
|
||||
return None
|
||||
mime = mimetypes.guess_type(str(target))[0] or "image/png"
|
||||
encoded = base64.b64encode(raw).decode("ascii")
|
||||
return f"data:{mime};base64,{encoded}"
|
||||
|
||||
|
||||
def empty_context(scope: str = "general") -> dict[str, Any]:
|
||||
"""Return an empty context payload (used by the General assistant)."""
|
||||
return {
|
||||
|
||||
+146
-18
@@ -15,12 +15,17 @@ from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.bookslm import (
|
||||
build_general_system_prompt,
|
||||
build_system_prompt,
|
||||
collect_adhoc_context,
|
||||
collect_directory_context,
|
||||
collect_files_context,
|
||||
empty_context,
|
||||
load_vault_image_data_url,
|
||||
merge_contexts,
|
||||
)
|
||||
from backend.indexer import get_vault_data, index
|
||||
from backend.model_capabilities import model_supports_vision
|
||||
from backend.schemas import BooksLMContextResponse
|
||||
from backend.skills import get_skill_prompt
|
||||
from backend.tools.api import ToolContext, ToolMode
|
||||
|
||||
logger = logging.getLogger("obsigate.bookslm_routes")
|
||||
@@ -40,6 +45,14 @@ class BooksLMContextRequest(BaseModel):
|
||||
default_factory=list,
|
||||
description="Relative file paths to use as context (documents mode)",
|
||||
)
|
||||
extra_files: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Ad-hoc files added with the '@' command (any mode)",
|
||||
)
|
||||
extra_directories: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Ad-hoc directories added with the '@' command (any mode)",
|
||||
)
|
||||
|
||||
|
||||
class BooksLMChatRequest(BaseModel):
|
||||
@@ -50,7 +63,24 @@ class BooksLMChatRequest(BaseModel):
|
||||
default_factory=list,
|
||||
description="Relative file paths to use as context (documents mode)",
|
||||
)
|
||||
extra_files: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Ad-hoc files added with the '@' command (any mode)",
|
||||
)
|
||||
extra_directories: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Ad-hoc directories added with the '@' command (any mode)",
|
||||
)
|
||||
message: str = Field(description="User message")
|
||||
images: list[dict[str, Any]] = Field(
|
||||
default_factory=list,
|
||||
description="Images for vision models. Each item: {data, mime_type} (pasted) "
|
||||
"or {path} (vault-relative file).",
|
||||
)
|
||||
skill: str | None = Field(
|
||||
default=None,
|
||||
description="Skill id selected with the '/' command; its prompt is added to the system prompt.",
|
||||
)
|
||||
conversation_history: list[dict[str, str]] = Field(
|
||||
default_factory=list,
|
||||
description="Previous conversation turns [{role, content}]",
|
||||
@@ -93,15 +123,35 @@ def _resolve_vault_path(vault: str | None, current_user):
|
||||
return vault, Path(vault_data["path"])
|
||||
|
||||
|
||||
def _build_context(mode: str, vault_path: Path | None, directory: str, context_files: list[str]):
|
||||
"""Collect the context payload for the requested mode."""
|
||||
def _build_context(
|
||||
mode: str,
|
||||
vault_path: Path | None,
|
||||
directory: str,
|
||||
context_files: list[str],
|
||||
extra_files: list[str] | None = None,
|
||||
extra_directories: list[str] | None = None,
|
||||
):
|
||||
"""Collect the context payload for the requested mode.
|
||||
|
||||
Ad-hoc files/directories (``@`` command) are merged on top of the base
|
||||
context. In General mode, attaching ad-hoc files promotes the effective
|
||||
scope to ``documents`` so the prompt actually includes their content.
|
||||
"""
|
||||
if mode == "general":
|
||||
return empty_context("general")
|
||||
if mode == "documents":
|
||||
base = empty_context("general")
|
||||
elif mode == "documents":
|
||||
ctx = collect_files_context(vault_path, context_files, scope="documents") # type: ignore[arg-type]
|
||||
# No open document could be read → degrade gracefully to General.
|
||||
return ctx if ctx["file_count"] else empty_context("general")
|
||||
return collect_directory_context(vault_path, directory) # type: ignore[arg-type]
|
||||
base = ctx if ctx["file_count"] else empty_context("general")
|
||||
else:
|
||||
base = collect_directory_context(vault_path, directory) # type: ignore[arg-type]
|
||||
|
||||
if (extra_files or extra_directories) and vault_path is not None:
|
||||
adhoc_scope = "documents" if base.get("scope") == "general" else base.get("scope", "directory")
|
||||
adhoc = collect_adhoc_context(vault_path, extra_files, extra_directories, scope=adhoc_scope)
|
||||
if adhoc["file_count"]:
|
||||
base = merge_contexts(base, adhoc)
|
||||
return base
|
||||
|
||||
|
||||
def _resolve_system_prompt(req, current_user) -> str:
|
||||
@@ -114,16 +164,85 @@ def _resolve_system_prompt(req, current_user) -> str:
|
||||
if mode != "general":
|
||||
_, vault_path = _resolve_vault_path(req.vault, current_user)
|
||||
|
||||
context = _build_context(mode, vault_path, req.directory, req.context_files)
|
||||
context = _build_context(
|
||||
mode,
|
||||
vault_path,
|
||||
req.directory,
|
||||
req.context_files,
|
||||
getattr(req, "extra_files", None),
|
||||
getattr(req, "extra_directories", None),
|
||||
)
|
||||
effective_mode = context.get("scope", mode)
|
||||
|
||||
if effective_mode == "general":
|
||||
return build_general_system_prompt(list(index.keys()))
|
||||
if effective_mode == "documents":
|
||||
return build_system_prompt(context, scope="documents")
|
||||
if context["file_count"] == 0:
|
||||
prompt = build_general_system_prompt(list(index.keys()))
|
||||
elif effective_mode == "documents":
|
||||
prompt = build_system_prompt(context, scope="documents")
|
||||
elif context["file_count"] == 0:
|
||||
raise HTTPException(status_code=404, detail="Aucun fichier markdown trouvé dans ce dossier")
|
||||
return build_system_prompt(context, scope="directory")
|
||||
else:
|
||||
prompt = build_system_prompt(context, scope="directory")
|
||||
|
||||
skill_id = getattr(req, "skill", None)
|
||||
if skill_id:
|
||||
skill_prompt = get_skill_prompt(skill_id, current_user)
|
||||
if skill_prompt:
|
||||
prompt += "\n\n## Skill actif\n" + skill_prompt
|
||||
return prompt
|
||||
|
||||
|
||||
def _resolve_optional_vault_path(req, current_user) -> Path | None:
|
||||
"""Best-effort vault path resolution (never raises)."""
|
||||
if not getattr(req, "vault", None):
|
||||
return None
|
||||
try:
|
||||
_, vault_path = _resolve_vault_path(req.vault, current_user)
|
||||
return vault_path
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
|
||||
def _build_user_content(req, vault_path: Path | None):
|
||||
"""Build the user message content, attaching images when present.
|
||||
|
||||
Returns a plain string when there is no image, otherwise an OpenAI-style
|
||||
multimodal content array (which ``ai_chat`` adapts for Gemini).
|
||||
"""
|
||||
images = getattr(req, "images", None) or []
|
||||
if not images:
|
||||
return req.message
|
||||
|
||||
parts: list[dict[str, Any]] = [{"type": "text", "text": req.message}]
|
||||
for image in images:
|
||||
if not isinstance(image, dict):
|
||||
continue
|
||||
data_url: str | None = None
|
||||
if image.get("data"):
|
||||
mime = image.get("mime_type") or "image/png"
|
||||
data_url = f"data:{mime};base64,{image['data']}"
|
||||
elif image.get("path") and vault_path is not None:
|
||||
data_url = load_vault_image_data_url(vault_path, str(image["path"]))
|
||||
if data_url:
|
||||
parts.append({"type": "image_url", "image_url": {"url": data_url}})
|
||||
return parts
|
||||
|
||||
|
||||
def _validate_vision_support(req) -> None:
|
||||
"""Reject image requests when the selected model cannot analyse images."""
|
||||
if not (getattr(req, "images", None)):
|
||||
return
|
||||
provider = _resolve_provider_name(req.provider)
|
||||
if not provider:
|
||||
return
|
||||
from backend.ai import PROVIDERS
|
||||
|
||||
model = req.model or PROVIDERS.get(provider, {}).get("model", "")
|
||||
if not model_supports_vision(provider, model):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Le modèle '{model or provider}' ne supporte pas l'analyse d'images. "
|
||||
"Choisissez un modèle compatible vision.",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_provider_name(requested: str | None) -> str | None:
|
||||
@@ -157,10 +276,15 @@ async def api_bookslm_context(
|
||||
"""
|
||||
mode = _normalize_mode(req.mode)
|
||||
if mode == "general":
|
||||
return empty_context("general")
|
||||
|
||||
_, vault_path = _resolve_vault_path(req.vault, current_user)
|
||||
return _build_context(mode, vault_path, req.directory, req.context_files)
|
||||
if not (req.extra_files or req.extra_directories):
|
||||
return empty_context("general")
|
||||
vault_path = _resolve_optional_vault_path(req, current_user)
|
||||
else:
|
||||
_, vault_path = _resolve_vault_path(req.vault, current_user)
|
||||
return _build_context(
|
||||
mode, vault_path, req.directory, req.context_files,
|
||||
req.extra_files, req.extra_directories,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -178,7 +302,9 @@ async def api_bookslm_chat(
|
||||
documents or general app knowledge), then streams the provider's answer
|
||||
as Server-Sent Events.
|
||||
"""
|
||||
_validate_vision_support(req)
|
||||
system_prompt = _resolve_system_prompt(req, current_user)
|
||||
vault_path = _resolve_optional_vault_path(req, current_user)
|
||||
|
||||
messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
|
||||
for turn in req.conversation_history:
|
||||
@@ -186,7 +312,7 @@ async def api_bookslm_chat(
|
||||
content = turn.get("content", "")
|
||||
if role in ("user", "assistant") and content:
|
||||
messages.append({"role": role, "content": content})
|
||||
messages.append({"role": "user", "content": req.message})
|
||||
messages.append({"role": "user", "content": _build_user_content(req, vault_path)})
|
||||
|
||||
async def generate_sse():
|
||||
try:
|
||||
@@ -246,7 +372,9 @@ async def api_bookslm_agent(
|
||||
and the conversation snapshot; the client resumes by echoing them back in
|
||||
``confirm`` / ``confirm_messages``.
|
||||
"""
|
||||
_validate_vision_support(req)
|
||||
system_prompt = _resolve_system_prompt(req, current_user)
|
||||
vault_path = _resolve_optional_vault_path(req, current_user)
|
||||
|
||||
messages: list[dict] = [{"role": "system", "content": system_prompt}]
|
||||
for turn in req.conversation_history:
|
||||
@@ -254,7 +382,7 @@ async def api_bookslm_agent(
|
||||
content = turn.get("content", "")
|
||||
if role in ("user", "assistant") and content:
|
||||
messages.append({"role": role, "content": content})
|
||||
messages.append({"role": "user", "content": req.message})
|
||||
messages.append({"role": "user", "content": _build_user_content(req, vault_path)})
|
||||
|
||||
ctx = ToolContext(user=current_user, mode=ToolMode.IN_APP)
|
||||
|
||||
|
||||
+10
-2
@@ -854,6 +854,7 @@ from backend.share import (
|
||||
revoke_share,
|
||||
update_shares_after_rename,
|
||||
)
|
||||
from backend.skills_routes import router as skills_router
|
||||
from backend.webhooks import (
|
||||
create_webhook,
|
||||
delete_webhook,
|
||||
@@ -865,6 +866,7 @@ from backend.webhooks import (
|
||||
app.include_router(auth_router)
|
||||
app.include_router(ai_router)
|
||||
app.include_router(bookslm_router)
|
||||
app.include_router(skills_router)
|
||||
|
||||
# Admin Dashboard endpoints (system stats, audit logs, backups, stream)
|
||||
try:
|
||||
@@ -3571,6 +3573,8 @@ async def api_list_ai_models(provider: str = Query(...), current_user=Depends(re
|
||||
"""
|
||||
provider = provider.lower()
|
||||
|
||||
from backend.model_capabilities import get_capabilities_for_models
|
||||
|
||||
all_providers = ("deepseek", "openrouter", "gemini", "nvidia", "qwencloud", "xiaomi", "mistral")
|
||||
if provider not in all_providers:
|
||||
return {"models": [], "error": f"Unknown provider: {provider}", "source": "validation"}
|
||||
@@ -3580,7 +3584,9 @@ async def api_list_ai_models(provider: str = Query(...), current_user=Depends(re
|
||||
if not key:
|
||||
# No key configured — return curated fallback list so the UI can
|
||||
# still show what WOULD be available once a key is set.
|
||||
return {"models": _FALLBACK_MODELS.get(provider, []), "source": "fallback",
|
||||
fallback = _FALLBACK_MODELS.get(provider, [])
|
||||
return {"models": fallback, "source": "fallback",
|
||||
"capabilities": get_capabilities_for_models(provider, fallback),
|
||||
"note": "API key not configured — showing default model list"}
|
||||
|
||||
# Build URL
|
||||
@@ -3626,13 +3632,15 @@ async def api_list_ai_models(provider: str = Query(...), current_user=Depends(re
|
||||
default = PROVIDERS.get(provider, {}).get("model")
|
||||
if default and default not in models:
|
||||
models = [default] + models
|
||||
return {"models": models, "source": "live", "count": len(models)}
|
||||
return {"models": models, "source": "live", "count": len(models),
|
||||
"capabilities": get_capabilities_for_models(provider, models)}
|
||||
# Empty list from API — fall through to fallback
|
||||
raise ValueError("empty model list from provider API")
|
||||
except Exception as e:
|
||||
# Network error, auth error, parsing error — use curated fallback
|
||||
fallback = _FALLBACK_MODELS.get(provider, [])
|
||||
return {"models": fallback, "source": "fallback", "error": str(e)[:200],
|
||||
"capabilities": get_capabilities_for_models(provider, fallback),
|
||||
"note": "Could not reach provider API — showing default model list"}
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -373,6 +373,10 @@ class AIModelsResponse(BaseModel):
|
||||
count: int | None = Field(default=None, description="Number of live models")
|
||||
error: str | None = Field(default=None, description="Provider/network error, if any")
|
||||
note: str | None = Field(default=None, description="Explanatory note when using fallback")
|
||||
capabilities: dict[str, dict[str, bool]] = Field(
|
||||
default_factory=dict,
|
||||
description="Per-model capability flags (chat, embeddings, vision, …)",
|
||||
)
|
||||
|
||||
|
||||
class DiagnosticsResponse(BaseModel):
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -297,6 +297,30 @@ Pour répondre au besoin de cibler un fournisseur/modèle sans dépendre uniquem
|
||||
|
||||
---
|
||||
|
||||
## 6bis. Skills, commandes, vision & capacités (#81)
|
||||
|
||||
- **Skills & commandes `/`** — `backend/skills.py` définit les skills intégrés (id, icône, description,
|
||||
prompt) et les métadonnées des commandes admin. Les skills utilisateur sont persistés par
|
||||
utilisateur dans `data/skills.json`. Endpoints : `GET/POST /api/ai/skills`,
|
||||
`DELETE /api/ai/skills/{id}`. Le champ `skill` d'une requête chat/agent injecte le prompt du skill
|
||||
dans le system prompt (`_resolve_system_prompt`). Les commandes admin (`/help`, `/providers`,
|
||||
`/provider`, `/model`, `/keys`) sont exécutées côté client.
|
||||
- **Contexte ad-hoc `@`** — champs `extra_files` / `extra_directories` sur les requêtes
|
||||
context/chat/agent ; collecte et fusion par `collect_adhoc_context()` + `merge_contexts()`
|
||||
(`backend/bookslm.py`).
|
||||
- **Vision** — les messages peuvent porter un contenu multimodal (tableau OpenAI
|
||||
`text` + `image_url`). `backend/ai_chat.py` convertit les data URLs en `inlineData` Gemini
|
||||
(`_content_to_gemini_parts`) ; l'OpenAI-compatible passe le tableau tel quel. Les images viennent
|
||||
d'un copier-coller (base64) ou d'un fichier de vault (data URL chargée par
|
||||
`load_vault_image_data_url`). Garde-fou : `_validate_vision_support` rejette (400) une requête
|
||||
d'image si le modèle n'est pas vision.
|
||||
- **Capacités** — table statique curée `backend/model_capabilities.py` (8 flags : chat, embeddings,
|
||||
rerank, images, video, audio_speech, audio_transcription, vision). Exposée par
|
||||
`GET /api/ai/model-capabilities` et par le champ `capabilities` de `GET /api/config/ai-models` ;
|
||||
affichée dans le picker de l'assistant et le panneau de configuration.
|
||||
|
||||
---
|
||||
|
||||
## 7. Plan par phases
|
||||
|
||||
| Phase | Contenu | Livrable |
|
||||
|
||||
+2
-1
@@ -94,6 +94,7 @@
|
||||
| 62 | Collaboration temps réel — Édition simultanée (Yjs/CRDT, WebSocket, awareness) | 2.3.0 | [features/collaboration.md](./features/collaboration.md) |
|
||||
| 79 | Assistant IA — Outils (function calling) & serveur MCP | 2.3.0 | [features/ai-tools-mcp.md](./features/ai-tools-mcp.md) |
|
||||
| 80 | Assistant IA — Rendu Markdown, liens fichiers/paths & sessions | 2.3.0 | [features/ai-assistant-ux.md](./features/ai-assistant-ux.md) |
|
||||
| 81 | Assistant IA — Commandes (`@`, `/`), skills, images & capacités des modèles | 2.3.0 | [features/ai-assistant-commands.md](./features/ai-assistant-commands.md) |
|
||||
| 69 | Éditeur mobile natif — Interface tactile optimisée | 2.3.0 | [features/mobile-editor.md](./features/mobile-editor.md) |
|
||||
| 70 | Recherche sémantique — Embeddings vectoriels (hybride TF-IDF + RRF) | 2.3.0 | [features/semantic-search.md](./features/semantic-search.md) |
|
||||
| 77 | Application Desktop native — Tauri | 🔵 en cours | [features/desktop-tauri.md](./features/desktop-tauri.md) |
|
||||
@@ -104,7 +105,7 @@
|
||||
|
||||
| Priorité | Items | Effort total estimé |
|
||||
|---|---|---|
|
||||
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–80 | ~103 jours réalisés |
|
||||
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–81 | ~107 jours réalisés |
|
||||
| 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour |
|
||||
| ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours |
|
||||
| **Total restant** | **2 items + finitions** | **~7-10 jours** |
|
||||
|
||||
@@ -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.
|
||||
@@ -2015,6 +2015,18 @@
|
||||
<option value="">--</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-row" style="align-items: flex-start">
|
||||
<label
|
||||
class="config-label"
|
||||
data-i18n="config.ai_capabilities"
|
||||
>Capacites du modele</label
|
||||
>
|
||||
<div
|
||||
id="cfg-ai-default-model-caps"
|
||||
class="ai-picker-caps"
|
||||
style="flex: 1"
|
||||
></div>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label
|
||||
class="config-label"
|
||||
@@ -4259,6 +4271,25 @@
|
||||
chercher) ; les actions de modification demandent une
|
||||
confirmation avec aperçu des changements.
|
||||
</li>
|
||||
<li data-i18n="help.assistant_resize">
|
||||
Le bord gauche du panneau est redimensionnable ; la largeur est
|
||||
mémorisée.
|
||||
</li>
|
||||
<li data-i18n="help.assistant_at">
|
||||
Tapez <code>@</code> pour joindre un fichier ou un répertoire au
|
||||
contexte, ou pour attacher une image d'un répertoire.
|
||||
</li>
|
||||
<li data-i18n="help.assistant_slash">
|
||||
Tapez <code>/</code> pour lancer un skill (recherche, résumé,
|
||||
correction, plan…) ou une commande admin
|
||||
(<code>/help</code>, <code>/providers</code>,
|
||||
<code>/model</code>, <code>/keys</code>).
|
||||
</li>
|
||||
<li data-i18n="help.assistant_images">
|
||||
Collez une image dans la zone de saisie (ou joignez-la) pour
|
||||
l'analyser avec un modèle compatible vision ; les capacités du
|
||||
modèle sélectionné sont affichées.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -33,6 +33,12 @@ async function aiAction(endpoint, text, extra = {}) {
|
||||
// State is stored in localStorage so the user choice persists across sections.
|
||||
const PICKER_STORAGE_KEY = 'obsigate_ai_picker';
|
||||
|
||||
// Capability flags exposed by GET /api/ai/model-capabilities.
|
||||
const AI_CAPABILITY_KEYS = [
|
||||
'chat', 'embeddings', 'rerank', 'images',
|
||||
'video', 'audio_speech', 'audio_transcription', 'vision',
|
||||
];
|
||||
|
||||
function _readPicker() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(PICKER_STORAGE_KEY) || '{}');
|
||||
@@ -45,6 +51,32 @@ function _writePicker(state) {
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
/** Fetch the curated capability flags for a provider/model pair. */
|
||||
async function getModelCapabilities(provider, model) {
|
||||
if (!provider) return null;
|
||||
try {
|
||||
const data = await api(
|
||||
`/api/ai/model-capabilities?provider=${encodeURIComponent(provider)}&model=${encodeURIComponent(model || '')}`
|
||||
);
|
||||
return data.capabilities || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Render the capability checklist (☑/□) for a model. */
|
||||
function renderCapabilityList(caps) {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'ai-picker-caps';
|
||||
if (!caps) return box;
|
||||
AI_CAPABILITY_KEYS.forEach((key) => {
|
||||
const item = document.createElement('span');
|
||||
const on = !!caps[key];
|
||||
item.className = 'ai-cap-item' + (on ? ' on' : '');
|
||||
item.textContent = `${on ? '☑' : '□'} ${t(`ai.cap_${key}`)}`;
|
||||
box.appendChild(item);
|
||||
});
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a provider+model picker that appears in every section's AI toolbar
|
||||
* (Forge editor, BooksLM, etc.). The picker reads /api/ai/status to discover
|
||||
@@ -128,6 +160,24 @@ async function _buildPickerUI() {
|
||||
placeholderOpt.textContent = t('ai.model_default');
|
||||
modelSelect.appendChild(placeholderOpt);
|
||||
|
||||
// Capability checklist, refreshed whenever the selected model changes.
|
||||
const capsHost = document.createElement('div');
|
||||
capsHost.className = 'ai-picker-caps-host';
|
||||
|
||||
async function _updateCaps() {
|
||||
capsHost.innerHTML = '';
|
||||
if (!pickerState.provider || !pickerState.model) {
|
||||
delete pickerState.capabilities;
|
||||
_writePicker(pickerState);
|
||||
return;
|
||||
}
|
||||
const caps = await getModelCapabilities(pickerState.provider, pickerState.model);
|
||||
if (!caps) return;
|
||||
pickerState.capabilities = caps;
|
||||
_writePicker(pickerState);
|
||||
capsHost.appendChild(renderCapabilityList(caps));
|
||||
}
|
||||
|
||||
async function _loadModels(provider) {
|
||||
modelSelect.innerHTML = '';
|
||||
const ph = document.createElement('option');
|
||||
@@ -148,6 +198,13 @@ async function _buildPickerUI() {
|
||||
if (m === pickerState.model && provider === pickerState.provider) opt.selected = true;
|
||||
modelSelect.appendChild(opt);
|
||||
});
|
||||
// If the provider default model is known, reflect it so capabilities
|
||||
// are shown even when the user keeps the "default" placeholder.
|
||||
if (!pickerState.model && data.models && data.models.length) {
|
||||
pickerState.model = data.models[0];
|
||||
_writePicker(pickerState);
|
||||
}
|
||||
await _updateCaps();
|
||||
} catch (err) {
|
||||
modelSelect.innerHTML = '';
|
||||
const opt = document.createElement('option');
|
||||
@@ -161,7 +218,9 @@ async function _buildPickerUI() {
|
||||
pickerState.provider = providerSelect.value || null;
|
||||
// Reset model when provider changes
|
||||
pickerState.model = null;
|
||||
delete pickerState.capabilities;
|
||||
_writePicker(pickerState);
|
||||
capsHost.innerHTML = '';
|
||||
if (pickerState.provider) {
|
||||
_loadModels(pickerState.provider);
|
||||
} else {
|
||||
@@ -175,7 +234,9 @@ async function _buildPickerUI() {
|
||||
|
||||
modelSelect.addEventListener('change', () => {
|
||||
pickerState.model = modelSelect.value || null;
|
||||
delete pickerState.capabilities;
|
||||
_writePicker(pickerState);
|
||||
_updateCaps();
|
||||
});
|
||||
|
||||
// Load models for the initial provider selection
|
||||
@@ -185,6 +246,7 @@ async function _buildPickerUI() {
|
||||
|
||||
wrap.appendChild(providerSelect);
|
||||
wrap.appendChild(modelSelect);
|
||||
wrap.appendChild(capsHost);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -712,6 +774,9 @@ export {
|
||||
_readPicker,
|
||||
_writePicker,
|
||||
PICKER_STORAGE_KEY,
|
||||
AI_CAPABILITY_KEYS,
|
||||
getModelCapabilities,
|
||||
renderCapabilityList,
|
||||
_buildPickerUI as buildAIPickerUI,
|
||||
_promptRewriteInstruction,
|
||||
};
|
||||
|
||||
+666
-6
@@ -5,7 +5,7 @@
|
||||
// • documents — the file(s) currently open in tabs/panes
|
||||
// • general — no document open: app help + file/directory creation actions
|
||||
import { t } from './i18n.js';
|
||||
import { buildAIPickerUI } from './ai.js';
|
||||
import { buildAIPickerUI, getModelCapabilities } from './ai.js';
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
import { api, AuthManager } from './auth.js';
|
||||
import { showToast } from './ui.js';
|
||||
@@ -23,6 +23,12 @@ export const MODE = Object.freeze({
|
||||
const FILE_EXT_RE = /\.(?:md|markdown|mdx|txt|pdf|excalidraw|canvas|png|jpe?g|gif|svg|webp|bmp|avif|csv|tsv|json|ya?ml|toml|ini|cfg|conf|html?|css|scss|sass|less|jsx?|tsx?|vue|svelte|py|rb|go|rs|java|kt|c|cc|cpp|h|hpp|cs|php|sh|bash|zsh|ps1|bat|sql|xml|log|tex|org|rtf|docx?|xlsx?|pptx?)$/i;
|
||||
// A path-like token: optional `dir/` segments followed by a final segment.
|
||||
const PATH_WITH_DIR_RE = /(^|[\s(>"'])((?:[\w.-]+\/)+[\w.-]+\.[A-Za-z0-9]{1,8})/g;
|
||||
// Image files that can be attached to a vision-capable model.
|
||||
const IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|svg|webp|bmp|avif|ico)$/i;
|
||||
// Resizable panel bounds (px).
|
||||
const PANEL_MIN_WIDTH = 320;
|
||||
const PANEL_MAX_WIDTH = 1000;
|
||||
const PANEL_WIDTH_KEY = 'obsigate-bookslm-width';
|
||||
|
||||
/**
|
||||
* Gather the files currently open across the singleton TabManager and every
|
||||
@@ -77,6 +83,16 @@ class BooksLM {
|
||||
// Agent mode: routes chat through /api/ai/bookslm/agent so the model can
|
||||
// call read/search tools and propose mutations (confirmation cards).
|
||||
this._agentMode = this._readAgentMode();
|
||||
// Ad-hoc context added with `@` (files/directories) and images attached
|
||||
// by paste or by mentioning an image file.
|
||||
this._adhocFiles = [];
|
||||
this._adhocDirs = [];
|
||||
this._images = [];
|
||||
this._activeSkill = null;
|
||||
this._skills = [];
|
||||
this._menuItems = [];
|
||||
this._menuIndex = 0;
|
||||
this._menuKind = null;
|
||||
}
|
||||
|
||||
_readAgentMode() {
|
||||
@@ -147,12 +163,17 @@ class BooksLM {
|
||||
this._effectiveScope = mode;
|
||||
this._messages = [];
|
||||
this._contextFiles = [];
|
||||
this._adhocFiles = [];
|
||||
this._adhocDirs = [];
|
||||
this._images = [];
|
||||
this._activeSkill = null;
|
||||
this._isLoading = true;
|
||||
this._loadHistory();
|
||||
|
||||
if (!this._panel) {
|
||||
this._panel = this._render();
|
||||
document.body.appendChild(this._panel);
|
||||
this._applyPanelWidth();
|
||||
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||||
}
|
||||
|
||||
@@ -163,6 +184,7 @@ class BooksLM {
|
||||
this._updateStatus();
|
||||
this._renderMessages();
|
||||
this._showSuggestions();
|
||||
this._renderAttachments();
|
||||
this._closeHistoryMenu();
|
||||
this._renderHistoryMenu();
|
||||
|
||||
@@ -331,7 +353,8 @@ class BooksLM {
|
||||
// ── Context ─────────────────────────────────────────────────────────
|
||||
|
||||
async _loadContext() {
|
||||
if (this._mode === MODE.GENERAL) {
|
||||
const hasAdhoc = this._adhocFiles.length > 0 || this._adhocDirs.length > 0;
|
||||
if (this._mode === MODE.GENERAL && !hasAdhoc) {
|
||||
this._effectiveScope = MODE.GENERAL;
|
||||
this._contextFiles = [];
|
||||
this._updateStatus({ files: [], total_chars: 0, file_count: 0, scope: MODE.GENERAL, max_total_chars: 200000 });
|
||||
@@ -341,9 +364,11 @@ class BooksLM {
|
||||
|
||||
const payload = {
|
||||
mode: this._mode,
|
||||
vault: this._vault,
|
||||
vault: this._vault || this._resolveVault(),
|
||||
directory: this._directory,
|
||||
context_files: this._mode === MODE.DOCUMENTS ? this._documents.map((d) => d.path) : [],
|
||||
extra_files: this._adhocFiles.map((f) => f.path),
|
||||
extra_directories: this._adhocDirs.map((d) => d.path),
|
||||
};
|
||||
try {
|
||||
const data = await api('/api/ai/bookslm/context', {
|
||||
@@ -396,6 +421,7 @@ class BooksLM {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'bookslm-panel';
|
||||
panel.innerHTML = `
|
||||
<div class="bookslm-resize-handle" role="separator" aria-orientation="vertical" title="${t('ai.resize_panel')}" aria-label="${t('ai.resize_panel')}"></div>
|
||||
<div class="bookslm-header">
|
||||
<button class="bookslm-btn-toggle" title="${t('bookslm.toggle_sidebar')}" aria-label="${t('bookslm.toggle_sidebar')}">
|
||||
<i data-lucide="panel-right-close" style="width:16px;height:16px"></i>
|
||||
@@ -422,7 +448,14 @@ class BooksLM {
|
||||
<div class="bookslm-status"></div>
|
||||
<div class="bookslm-suggestions"></div>
|
||||
<div class="bookslm-messages"></div>
|
||||
<div class="bookslm-attachments"></div>
|
||||
<div class="bookslm-menu-layer">
|
||||
<div class="bookslm-command-menu hidden" role="listbox" aria-label="${t('ai.commands')}"></div>
|
||||
<div class="bookslm-mention-menu hidden" role="listbox" aria-label="${t('ai.add_context')}"></div>
|
||||
</div>
|
||||
<div class="bookslm-input-area">
|
||||
<button class="bookslm-btn-attach" title="${t('ai.attach_image')}" aria-label="${t('ai.attach_image')}"><i data-lucide="image-plus" style="width:18px;height:18px"></i></button>
|
||||
<input type="file" class="bookslm-file-input" accept="image/*" multiple hidden>
|
||||
<textarea placeholder="${t('bookslm.placeholder')}" rows="1" aria-label="${t('bookslm.placeholder')}"></textarea>
|
||||
<button class="bookslm-btn-send" title="${t('bookslm.send')}">${t('bookslm.send')}</button>
|
||||
</div>
|
||||
@@ -460,11 +493,14 @@ class BooksLM {
|
||||
else this._openFileLink(path);
|
||||
});
|
||||
|
||||
// Close the session menu when clicking elsewhere in the panel.
|
||||
// Close the session / command menus when clicking elsewhere in the panel.
|
||||
panel.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.bookslm-history-menu') && !e.target.closest('.bookslm-btn-history')) {
|
||||
this._closeHistoryMenu();
|
||||
}
|
||||
if (!e.target.closest('.bookslm-command-menu') && !e.target.closest('.bookslm-mention-menu')) {
|
||||
this._hideMenus();
|
||||
}
|
||||
});
|
||||
panel.querySelector('.bookslm-btn-fullscreen').addEventListener('click', () => {
|
||||
this._isFullscreen = !this._isFullscreen;
|
||||
@@ -478,12 +514,31 @@ class BooksLM {
|
||||
|
||||
panel.querySelector('.bookslm-btn-send').addEventListener('click', () => this._sendMessage());
|
||||
|
||||
// Resizable panel (drag the left edge).
|
||||
const resizeHandle = panel.querySelector('.bookslm-resize-handle');
|
||||
if (resizeHandle) resizeHandle.addEventListener('mousedown', (e) => this._startResize(e));
|
||||
|
||||
// Image attachment button (pick image files from disk).
|
||||
const attachBtn = panel.querySelector('.bookslm-btn-attach');
|
||||
const fileInput = panel.querySelector('.bookslm-file-input');
|
||||
if (attachBtn && fileInput) {
|
||||
attachBtn.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', () => {
|
||||
this._addImageFiles(fileInput.files);
|
||||
fileInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
const textarea = panel.querySelector('textarea');
|
||||
textarea.addEventListener('input', () => {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 140) + 'px';
|
||||
this._onComposerInput();
|
||||
});
|
||||
textarea.addEventListener('paste', (e) => this._onPaste(e));
|
||||
textarea.addEventListener('keydown', (e) => {
|
||||
// Menu navigation (mention/command) takes precedence over submit.
|
||||
if (this._handleMenuKey(e)) return;
|
||||
if (e.key !== 'Enter') return;
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
// Ctrl/Cmd+Enter → insert a newline at the caret.
|
||||
@@ -504,6 +559,580 @@ class BooksLM {
|
||||
return panel;
|
||||
}
|
||||
|
||||
// ── Panel resize ────────────────────────────────────────────────────
|
||||
|
||||
_applyPanelWidth() {
|
||||
if (!this._panel) return;
|
||||
let width = 0;
|
||||
try { width = parseInt(localStorage.getItem(PANEL_WIDTH_KEY) || '', 10) || 0; } catch { /* */ }
|
||||
if (width) this._panel.style.width = `${this._clampPanelWidth(width)}px`;
|
||||
}
|
||||
|
||||
_clampPanelWidth(width) {
|
||||
return Math.max(PANEL_MIN_WIDTH, Math.min(PANEL_MAX_WIDTH, width));
|
||||
}
|
||||
|
||||
_startResize(e) {
|
||||
if (!this._panel || this._isFullscreen) return;
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startWidth = this._panel.getBoundingClientRect().width;
|
||||
const onMove = (ev) => {
|
||||
// Panel is anchored to the right edge: dragging left widens it.
|
||||
const next = this._clampPanelWidth(startWidth + (startX - ev.clientX));
|
||||
this._panel.style.width = `${next}px`;
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
document.body.classList.remove('bookslm-resizing');
|
||||
try {
|
||||
localStorage.setItem(PANEL_WIDTH_KEY, String(Math.round(this._panel.getBoundingClientRect().width)));
|
||||
} catch { /* */ }
|
||||
};
|
||||
document.body.classList.add('bookslm-resizing');
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}
|
||||
|
||||
// ── Attachments: ad-hoc context chips & images ──────────────────────
|
||||
|
||||
_renderAttachments() {
|
||||
if (!this._panel) return;
|
||||
const host = this._panel.querySelector('.bookslm-attachments');
|
||||
if (!host) return;
|
||||
host.innerHTML = '';
|
||||
const hasAny = this._adhocFiles.length || this._adhocDirs.length || this._images.length || this._activeSkill;
|
||||
host.classList.toggle('empty', !hasAny);
|
||||
if (!hasAny) return;
|
||||
|
||||
if (this._activeSkill) {
|
||||
const skill = this._skillById(this._activeSkill);
|
||||
host.appendChild(this._chip(
|
||||
`${skill ? skill.icon : '✨'} ${skill ? skill.label : this._activeSkill}`,
|
||||
'skill',
|
||||
() => { this._activeSkill = null; this._renderAttachments(); },
|
||||
));
|
||||
}
|
||||
for (const file of this._adhocFiles) {
|
||||
host.appendChild(this._chip(`📄 ${file.path}`, 'file', () => this._removeAdhocFile(file.path)));
|
||||
}
|
||||
for (const dir of this._adhocDirs) {
|
||||
host.appendChild(this._chip(`📁 ${dir.path}`, 'dir', () => this._removeAdhocDir(dir.path)));
|
||||
}
|
||||
this._images.forEach((img, index) => {
|
||||
const chip = this._chip(`🖼 ${img.name || t('ai.image')}`, 'image', () => {
|
||||
this._images.splice(index, 1);
|
||||
this._renderAttachments();
|
||||
});
|
||||
if (img.previewUrl) {
|
||||
const thumb = document.createElement('img');
|
||||
thumb.className = 'bookslm-chip-thumb';
|
||||
thumb.src = img.previewUrl;
|
||||
chip.insertBefore(thumb, chip.firstChild);
|
||||
}
|
||||
host.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
_chip(label, kind, onRemove) {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = `bookslm-chip bookslm-chip-${kind}`;
|
||||
const text = document.createElement('span');
|
||||
text.className = 'bookslm-chip-label';
|
||||
text.textContent = label;
|
||||
text.title = label;
|
||||
chip.appendChild(text);
|
||||
const close = document.createElement('button');
|
||||
close.type = 'button';
|
||||
close.className = 'bookslm-chip-remove';
|
||||
close.setAttribute('aria-label', t('ai.remove'));
|
||||
close.textContent = '×';
|
||||
close.addEventListener('click', onRemove);
|
||||
chip.appendChild(close);
|
||||
return chip;
|
||||
}
|
||||
|
||||
_addAdhocFile(path) {
|
||||
if (!path || this._adhocFiles.some((f) => f.path === path)) return;
|
||||
this._adhocFiles.push({ path });
|
||||
this._renderAttachments();
|
||||
this._loadContext();
|
||||
}
|
||||
|
||||
_addAdhocDir(path) {
|
||||
const clean = path.replace(/\/+$/, '');
|
||||
if (!clean || this._adhocDirs.some((d) => d.path === clean)) return;
|
||||
this._adhocDirs.push({ path: clean });
|
||||
this._renderAttachments();
|
||||
this._loadContext();
|
||||
}
|
||||
|
||||
_removeAdhocFile(path) {
|
||||
this._adhocFiles = this._adhocFiles.filter((f) => f.path !== path);
|
||||
this._renderAttachments();
|
||||
this._loadContext();
|
||||
}
|
||||
|
||||
_removeAdhocDir(path) {
|
||||
this._adhocDirs = this._adhocDirs.filter((d) => d.path !== path);
|
||||
this._renderAttachments();
|
||||
this._loadContext();
|
||||
}
|
||||
|
||||
_addImageFile(file) {
|
||||
if (!file || !file.type || !file.type.startsWith('image/')) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = String(reader.result || '');
|
||||
const match = result.match(/^data:([^;,]+);base64,(.*)$/);
|
||||
if (!match) return;
|
||||
this._images.push({
|
||||
name: file.name || t('ai.image'),
|
||||
mime_type: match[1],
|
||||
data: match[2],
|
||||
previewUrl: result,
|
||||
});
|
||||
this._renderAttachments();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
_addImageFiles(files) {
|
||||
if (!files) return;
|
||||
Array.from(files).forEach((file) => this._addImageFile(file));
|
||||
}
|
||||
|
||||
_onPaste(e) {
|
||||
const items = e.clipboardData && e.clipboardData.items;
|
||||
if (!items) return;
|
||||
const images = [];
|
||||
for (const item of items) {
|
||||
if (item.kind === 'file' && item.type && item.type.startsWith('image/')) {
|
||||
const file = item.getAsFile();
|
||||
if (file) images.push(file);
|
||||
}
|
||||
}
|
||||
if (images.length) {
|
||||
e.preventDefault();
|
||||
images.forEach((file) => this._addImageFile(file));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Composer: `/` commands and `@` mentions ─────────────────────────
|
||||
|
||||
_onComposerInput() {
|
||||
if (!this._panel) return;
|
||||
const textarea = this._panel.querySelector('textarea');
|
||||
if (!textarea) return;
|
||||
const pos = textarea.selectionStart;
|
||||
const before = textarea.value.slice(0, pos);
|
||||
const command = before.match(/(?:^|\s)\/([a-z0-9-]*)$/i);
|
||||
if (command) {
|
||||
this._showCommandMenu(command[1].toLowerCase());
|
||||
return;
|
||||
}
|
||||
const mention = before.match(/(?:^|\s)@([\w./-]*)$/);
|
||||
if (mention) {
|
||||
this._showMentionMenu(mention[1]);
|
||||
return;
|
||||
}
|
||||
this._hideMenus();
|
||||
}
|
||||
|
||||
async _ensureSkills() {
|
||||
if (this._skills.length) return this._skills;
|
||||
try {
|
||||
const data = await api('/api/ai/skills');
|
||||
this._skills = [...(data.skills || []), ...(data.commands || [])];
|
||||
} catch (e) {
|
||||
console.warn('AI assistant: skills load failed', e);
|
||||
this._skills = [];
|
||||
}
|
||||
return this._skills;
|
||||
}
|
||||
|
||||
_skillById(id) {
|
||||
return this._skills.find((s) => s.id === id) || null;
|
||||
}
|
||||
|
||||
async _showCommandMenu(query) {
|
||||
if (!this._panel) return;
|
||||
const skills = await this._ensureSkills();
|
||||
const q = (query || '').toLowerCase();
|
||||
const matches = skills.filter((s) =>
|
||||
!q || s.id.includes(q) || (s.label || '').toLowerCase().includes(q));
|
||||
this._renderMenu('command', matches, (skill) => this._selectCommand(skill));
|
||||
}
|
||||
|
||||
_selectCommand(skill) {
|
||||
const textarea = this._panel && this._panel.querySelector('textarea');
|
||||
this._hideMenus();
|
||||
if (skill.type === 'admin') {
|
||||
this._runAdminCommand(skill.id);
|
||||
if (textarea) {
|
||||
textarea.value = textarea.value.replace(/(^|\s)\/[a-z0-9-]*$/i, '$1').trim();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (skill.special === 'create_skill') {
|
||||
if (textarea) textarea.value = textarea.value.replace(/(^|\s)\/[a-z0-9-]*$/i, '$1').trim();
|
||||
this._promptCreateSkill();
|
||||
return;
|
||||
}
|
||||
this._activeSkill = skill.id;
|
||||
if (textarea) {
|
||||
textarea.value = textarea.value.replace(/(^|\s)\/[a-z0-9-]*$/i, '$1').trim();
|
||||
textarea.focus();
|
||||
}
|
||||
this._renderAttachments();
|
||||
}
|
||||
|
||||
async _showMentionMenu(query) {
|
||||
if (!this._panel) return;
|
||||
const vault = this._resolveVault();
|
||||
if (!vault) {
|
||||
this._hideMenus();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let results = [];
|
||||
if (query) {
|
||||
const data = await api(
|
||||
`/api/tree-search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vault)}`
|
||||
);
|
||||
results = (data.results || []).slice(0, 20);
|
||||
} else {
|
||||
const data = await api(
|
||||
`/api/vault/${encodeURIComponent(vault)}/files?limit=20&recursive=true`
|
||||
);
|
||||
results = (data.files || []).slice(0, 20).map((f) => ({ path: f.path, type: 'file' }));
|
||||
}
|
||||
const items = results.map((r) => ({
|
||||
id: r.path || r,
|
||||
label: r.path || r,
|
||||
type: r.type || (this._classifyPath(r.path || r) === 'dir' ? 'dir' : 'file'),
|
||||
}));
|
||||
this._renderMenu('mention', items, (item) => this._selectMention(item));
|
||||
} catch (e) {
|
||||
console.warn('AI assistant: mention search failed', e);
|
||||
this._hideMenus();
|
||||
}
|
||||
}
|
||||
|
||||
_selectMention(item) {
|
||||
const textarea = this._panel && this._panel.querySelector('textarea');
|
||||
this._hideMenus();
|
||||
if (textarea) {
|
||||
textarea.value = textarea.value.replace(/(^|\s)@[\w./-]*$/, '$1').trim();
|
||||
textarea.focus();
|
||||
}
|
||||
const path = item.id;
|
||||
if (IMAGE_EXT_RE.test(path)) {
|
||||
this._addImagePath(path);
|
||||
} else if (item.type === 'dir' || this._classifyPath(path) === 'dir') {
|
||||
this._addAdhocDir(path);
|
||||
} else {
|
||||
this._addAdhocFile(path);
|
||||
}
|
||||
}
|
||||
|
||||
_addImagePath(path) {
|
||||
if (!path || this._images.some((img) => img.path === path)) return;
|
||||
this._images.push({ path, name: path.split('/').pop() });
|
||||
this._renderAttachments();
|
||||
}
|
||||
|
||||
_renderMenu(kind, items, onPick) {
|
||||
if (!this._panel) return;
|
||||
const menu = this._panel.querySelector(`.bookslm-${kind}-menu`);
|
||||
if (!menu) return;
|
||||
menu.innerHTML = '';
|
||||
this._menuKind = kind;
|
||||
this._menuItems = items;
|
||||
this._menuIndex = 0;
|
||||
if (!items.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'bookslm-menu-empty';
|
||||
empty.textContent = t('ai.no_results');
|
||||
menu.appendChild(empty);
|
||||
menu.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
items.forEach((item, index) => {
|
||||
const el = document.createElement('button');
|
||||
el.type = 'button';
|
||||
el.className = 'bookslm-menu-item' + (index === 0 ? ' active' : '');
|
||||
el.dataset.index = String(index);
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'bookslm-menu-icon';
|
||||
icon.textContent = item.icon || (item.type === 'dir' ? '📁' : item.type === 'admin' ? '⚙️' : '📄');
|
||||
const label = document.createElement('span');
|
||||
label.className = 'bookslm-menu-label';
|
||||
label.textContent = item.label || item.id;
|
||||
el.appendChild(icon);
|
||||
el.appendChild(label);
|
||||
if (item.usage) {
|
||||
const usage = document.createElement('span');
|
||||
usage.className = 'bookslm-menu-usage';
|
||||
usage.textContent = item.usage;
|
||||
el.appendChild(usage);
|
||||
} else if (item.description) {
|
||||
const desc = document.createElement('span');
|
||||
desc.className = 'bookslm-menu-desc';
|
||||
desc.textContent = item.description;
|
||||
el.appendChild(desc);
|
||||
}
|
||||
el.addEventListener('mouseenter', () => this._highlightMenu(index));
|
||||
el.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
onPick(item);
|
||||
});
|
||||
menu.appendChild(el);
|
||||
});
|
||||
// Show the other menu hidden.
|
||||
this._panel.querySelectorAll('.bookslm-command-menu, .bookslm-mention-menu').forEach((m) => {
|
||||
if (m !== menu) m.classList.add('hidden');
|
||||
});
|
||||
menu.classList.remove('hidden');
|
||||
}
|
||||
|
||||
_highlightMenu(index) {
|
||||
if (!this._panel || !this._menuKind) return;
|
||||
const menu = this._panel.querySelector(`.bookslm-${this._menuKind}-menu`);
|
||||
if (!menu) return;
|
||||
this._menuIndex = index;
|
||||
menu.querySelectorAll('.bookslm-menu-item').forEach((el, i) => {
|
||||
el.classList.toggle('active', i === index);
|
||||
});
|
||||
}
|
||||
|
||||
_handleMenuKey(e) {
|
||||
if (!this._panel || !this._menuKind) return false;
|
||||
const menu = this._panel.querySelector(`.bookslm-${this._menuKind}-menu`);
|
||||
if (!menu || menu.classList.contains('hidden')) return false;
|
||||
const items = this._menuItems;
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this._hideMenus();
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (!items.length) return true;
|
||||
const delta = e.key === 'ArrowDown' ? 1 : -1;
|
||||
const next = (this._menuIndex + delta + items.length) % items.length;
|
||||
this._highlightMenu(next);
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
const item = items[this._menuIndex];
|
||||
if (!item) return true;
|
||||
const menuEl = menu.querySelector('.bookslm-menu-item.active');
|
||||
if (menuEl) menuEl.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
_hideMenus() {
|
||||
if (!this._panel) return;
|
||||
this._panel.querySelectorAll('.bookslm-command-menu, .bookslm-mention-menu').forEach((m) => {
|
||||
m.classList.add('hidden');
|
||||
});
|
||||
this._menuKind = null;
|
||||
this._menuItems = [];
|
||||
this._menuIndex = 0;
|
||||
}
|
||||
|
||||
// ── Admin commands (`/help`, `/providers`, …) ───────────────────────
|
||||
|
||||
async _runAdminCommand(id) {
|
||||
switch (id) {
|
||||
case 'help': return this._cmdHelp();
|
||||
case 'providers': return this._cmdProviders();
|
||||
case 'keys': return this._cmdKeys();
|
||||
case 'provider': return this._cmdSwitchProvider();
|
||||
case 'model': return this._cmdSwitchModel();
|
||||
default: return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
_pushAssistantNote(markdown) {
|
||||
this._messages.push({ role: 'assistant', content: markdown, sources: [], toolCalls: [], confirmation: null });
|
||||
this._renderMessages();
|
||||
this._saveHistory();
|
||||
}
|
||||
|
||||
async _cmdHelp() {
|
||||
const skills = await this._ensureSkills();
|
||||
const lines = [`## ${t('ai.commands')}`, ''];
|
||||
for (const s of skills) {
|
||||
lines.push(`- ${s.icon || ''} \`/${s.id}\` — ${s.description || s.label}`);
|
||||
}
|
||||
this._pushAssistantNote(lines.join('\n'));
|
||||
}
|
||||
|
||||
async _cmdProviders() {
|
||||
let providers = {};
|
||||
try {
|
||||
const status = await api('/api/ai/status');
|
||||
providers = status.providers || {};
|
||||
} catch { /* */ }
|
||||
const lines = [`## ${t('ai.providers')}`, ''];
|
||||
const names = Object.keys(providers);
|
||||
if (!names.length) lines.push(t('ai.none'));
|
||||
for (const name of names) {
|
||||
const info = providers[name] || {};
|
||||
lines.push(`- ${info.available ? '✅' : '❌'} **${name}** — ${info.model || '—'}`);
|
||||
}
|
||||
this._pushAssistantNote(lines.join('\n'));
|
||||
}
|
||||
|
||||
async _cmdKeys() {
|
||||
const lines = [`## ${t('ai.keys')}`, ''];
|
||||
try {
|
||||
const keys = await api('/api/config/ai-keys');
|
||||
for (const [env, value] of Object.entries(keys)) {
|
||||
lines.push(`- ${value ? '✅' : '❌'} **${env.replace('_API_KEY', '').toLowerCase()}**`);
|
||||
}
|
||||
} catch {
|
||||
const status = await api('/api/ai/status').catch(() => ({ providers: {} }));
|
||||
for (const [name, info] of Object.entries(status.providers || {})) {
|
||||
lines.push(`- ${info.available ? '✅' : '❌'} **${name}**`);
|
||||
}
|
||||
}
|
||||
this._pushAssistantNote(lines.join('\n'));
|
||||
}
|
||||
|
||||
_cmdSwitchProvider() {
|
||||
const names = Array.from(this._panel.querySelectorAll('.ai-picker-select option'))
|
||||
.map((o) => o.value).filter(Boolean);
|
||||
const list = names.join(', ');
|
||||
this._pushAssistantNote(
|
||||
`### ${t('ai.provider')}\n\n${t('ai.usage_provider', { list })}\n\n\`/provider <nom>\``,
|
||||
);
|
||||
}
|
||||
|
||||
_cmdSwitchModel() {
|
||||
const names = Array.from(this._panel.querySelectorAll('.ai-picker-model option'))
|
||||
.map((o) => o.value).filter(Boolean);
|
||||
const list = names.length ? names.join(', ') : '—';
|
||||
this._pushAssistantNote(
|
||||
`### ${t('ai.model')}\n\n${t('ai.usage_model', { list })}\n\n\`/model <nom>\``,
|
||||
);
|
||||
}
|
||||
|
||||
/** Apply a provider/model selection from an admin command text. */
|
||||
_applyPickerSelection(provider, model) {
|
||||
if (!this._panel) return;
|
||||
const providerSelect = this._panel.querySelector('.ai-picker-select');
|
||||
const modelSelect = this._panel.querySelector('.ai-picker-model');
|
||||
let state = {};
|
||||
try { state = JSON.parse(localStorage.getItem('obsigate_ai_picker') || '{}'); } catch { /* */ }
|
||||
if (provider && providerSelect) {
|
||||
state.provider = provider;
|
||||
state.model = model || null;
|
||||
delete state.capabilities;
|
||||
providerSelect.value = provider;
|
||||
providerSelect.dispatchEvent(new Event('change'));
|
||||
if (model) {
|
||||
const applyModel = () => {
|
||||
if (modelSelect && Array.from(modelSelect.options).some((o) => o.value === model)) {
|
||||
modelSelect.value = model;
|
||||
modelSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
};
|
||||
setTimeout(applyModel, 400);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (model && modelSelect) {
|
||||
state.model = model;
|
||||
delete state.capabilities;
|
||||
modelSelect.value = model;
|
||||
modelSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle `/provider X` and `/model X` typed directly (with an argument). */
|
||||
_maybeRunAdminWithArg(text) {
|
||||
const provider = text.match(/^\/provider\s+(\S+)\s*$/i);
|
||||
if (provider) {
|
||||
this._applyPickerSelection(provider[1], null);
|
||||
this._pushAssistantNote(`✅ ${t('ai.provider_changed', { name: provider[1] })}`);
|
||||
return true;
|
||||
}
|
||||
const model = text.match(/^\/model\s+(\S+)\s*$/i);
|
||||
if (model) {
|
||||
this._applyPickerSelection(null, model[1]);
|
||||
this._pushAssistantNote(`✅ ${t('ai.model_changed', { name: model[1] })}`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Custom skill creation (`/create-new-skill`) ─────────────────────
|
||||
|
||||
_promptCreateSkill() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'bookslm-modal-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="bookslm-modal" role="dialog" aria-modal="true" aria-label="${t('ai.create_skill')}">
|
||||
<h3 class="bookslm-modal-title">${t('ai.create_skill')}</h3>
|
||||
<label class="bookslm-modal-field">${t('ai.skill_id')}
|
||||
<input class="bookslm-skill-id" placeholder="mon-skill">
|
||||
</label>
|
||||
<label class="bookslm-modal-field">${t('ai.skill_label')}
|
||||
<input class="bookslm-skill-label" placeholder="${t('ai.skill_label')}">
|
||||
</label>
|
||||
<label class="bookslm-modal-field">${t('ai.skill_prompt')}
|
||||
<textarea class="bookslm-skill-prompt" rows="4" placeholder="${t('ai.skill_prompt_placeholder')}"></textarea>
|
||||
</label>
|
||||
<div class="bookslm-modal-error"></div>
|
||||
<div class="bookslm-modal-actions">
|
||||
<button type="button" class="bookslm-modal-cancel">${t('button.cancel')}</button>
|
||||
<button type="button" class="bookslm-modal-ok primary">${t('common.save')}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
const close = () => overlay.remove();
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
overlay.querySelector('.bookslm-modal-cancel').addEventListener('click', close);
|
||||
overlay.querySelector('.bookslm-modal-ok').addEventListener('click', async () => {
|
||||
const id = overlay.querySelector('.bookslm-skill-id').value.trim().toLowerCase();
|
||||
const label = overlay.querySelector('.bookslm-skill-label').value.trim();
|
||||
const prompt = overlay.querySelector('.bookslm-skill-prompt').value.trim();
|
||||
const err = overlay.querySelector('.bookslm-modal-error');
|
||||
try {
|
||||
await api('/api/ai/skills', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, label, prompt }),
|
||||
});
|
||||
this._skills = [];
|
||||
close();
|
||||
showToast(t('ai.skill_created', { name: label || id }), 'success');
|
||||
} catch (e) {
|
||||
err.textContent = e.message || String(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Vision support ──────────────────────────────────────────────────
|
||||
|
||||
async _modelSupportsVision() {
|
||||
let state = {};
|
||||
try { state = JSON.parse(localStorage.getItem('obsigate_ai_picker') || '{}'); } catch { /* */ }
|
||||
const provider = state.provider || null;
|
||||
const model = state.model || '';
|
||||
if (!provider) return true; // provider default resolution is server-side
|
||||
let caps = state.capabilities;
|
||||
if (!caps) caps = await getModelCapabilities(provider, model);
|
||||
return !caps || caps.vision !== false;
|
||||
}
|
||||
|
||||
_updateHeader() {
|
||||
if (!this._panel) return;
|
||||
const meta = this._contextMeta();
|
||||
@@ -884,6 +1513,26 @@ class BooksLM {
|
||||
const text = (textarea.value || '').trim();
|
||||
if (!text || this._isLoading) return;
|
||||
|
||||
// Admin slash-commands are handled locally (no LLM round-trip).
|
||||
if (/^\/(?:help|providers|keys|provider|model)(?:\s|$)/i.test(text)) {
|
||||
this._hideMenus();
|
||||
textarea.value = '';
|
||||
textarea.style.height = 'auto';
|
||||
if (!this._maybeRunAdminWithArg(text)) {
|
||||
this._runAdminCommand(text.slice(1).split(/\s+/)[0].toLowerCase());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Vision gate: block sending images to a non-vision model.
|
||||
if (this._images.length) {
|
||||
const visionOk = await this._modelSupportsVision();
|
||||
if (!visionOk) {
|
||||
showToast(t('ai.vision_unsupported'), 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
textarea.value = '';
|
||||
textarea.style.height = 'auto';
|
||||
|
||||
@@ -908,9 +1557,15 @@ class BooksLM {
|
||||
|
||||
const payload = {
|
||||
mode: this._mode,
|
||||
vault: this._vault,
|
||||
vault: this._vault || this._resolveVault(),
|
||||
directory: this._directory,
|
||||
context_files: this._mode === MODE.DOCUMENTS ? this._documents.map((d) => d.path) : [],
|
||||
extra_files: this._adhocFiles.map((f) => f.path),
|
||||
extra_directories: this._adhocDirs.map((d) => d.path),
|
||||
images: this._images.map((img) => (
|
||||
img.path ? { path: img.path } : { data: img.data, mime_type: img.mime_type }
|
||||
)),
|
||||
skill: this._activeSkill,
|
||||
message: text,
|
||||
conversation_history: history,
|
||||
provider,
|
||||
@@ -966,7 +1621,12 @@ class BooksLM {
|
||||
|
||||
/** POST to /chat or /agent depending on the agent-mode toggle. */
|
||||
_postChat(payload) {
|
||||
const endpoint = this._agentMode ? '/api/ai/bookslm/agent' : '/api/ai/bookslm/chat';
|
||||
// Images require the plain multimodal chat endpoint (the agent loop is
|
||||
// text/tool oriented).
|
||||
const hasImages = Array.isArray(payload.images) && payload.images.length > 0;
|
||||
const endpoint = (this._agentMode && !hasImages)
|
||||
? '/api/ai/bookslm/agent'
|
||||
: '/api/ai/bookslm/chat';
|
||||
const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) };
|
||||
return fetch(endpoint, {
|
||||
method: 'POST',
|
||||
|
||||
+23
-1
@@ -6,6 +6,7 @@ import { syncVaultSelectors, setSelectedVaultContext, refreshSidebarForContext,
|
||||
import { escapeHtml, safeCreateIcons } from './utils.js';
|
||||
import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js';
|
||||
import { t, setLocale, getLocale } from './i18n.js';
|
||||
import { getModelCapabilities, renderCapabilityList } from './ai.js';
|
||||
|
||||
let _recentTimestampTimer = null;
|
||||
let _recentFilesCache = [];
|
||||
@@ -625,6 +626,13 @@ function initConfigModal() {
|
||||
await _populateAIModelOptions(aiDefaultProviderSel.value, models[aiDefaultProviderSel.value] || "");
|
||||
});
|
||||
}
|
||||
const aiDefaultModelSel = document.getElementById("cfg-ai-default-model");
|
||||
if (aiDefaultModelSel) {
|
||||
aiDefaultModelSel.addEventListener("change", () => {
|
||||
const providerSel = document.getElementById("cfg-ai-default-provider");
|
||||
_renderConfigModelCaps(providerSel ? providerSel.value : null, aiDefaultModelSel.value);
|
||||
});
|
||||
}
|
||||
// Load existing keys/defaults on modal open
|
||||
document.getElementById("config-open-btn").addEventListener("click", loadAIKeys);
|
||||
document.getElementById("config-open-btn").addEventListener("click", loadAIDefaults);
|
||||
@@ -1440,7 +1448,10 @@ async function _populateAIModelOptions(provider, selected) {
|
||||
const modelSel = document.getElementById("cfg-ai-default-model");
|
||||
if (!modelSel) return;
|
||||
modelSel.innerHTML = '<option value="">--</option>';
|
||||
if (!provider) return;
|
||||
if (!provider) {
|
||||
_renderConfigModelCaps(null, null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const m = await api("/api/config/ai-models?provider=" + encodeURIComponent(provider));
|
||||
const list = (m && m.models) || [];
|
||||
@@ -1451,9 +1462,20 @@ async function _populateAIModelOptions(provider, selected) {
|
||||
modelSel.innerHTML += '<option value="' + selected + '">' + selected + '</option>';
|
||||
}
|
||||
modelSel.value = selected || "";
|
||||
_renderConfigModelCaps(provider, modelSel.value);
|
||||
} catch(e) { console.warn("Failed to load default models for", provider, e); }
|
||||
}
|
||||
|
||||
async function _renderConfigModelCaps(provider, model) {
|
||||
const host = document.getElementById("cfg-ai-default-model-caps");
|
||||
if (!host) return;
|
||||
host.innerHTML = "";
|
||||
if (!provider || !model) return;
|
||||
const caps = await getModelCapabilities(provider, model);
|
||||
if (!caps) return;
|
||||
host.appendChild(renderCapabilityList(caps));
|
||||
}
|
||||
|
||||
async function saveAIKeys() {
|
||||
const keys = {};
|
||||
for (const [id, name] of Object.entries(AI_KEY_MAP)) {
|
||||
|
||||
@@ -128,6 +128,35 @@
|
||||
"ai.model_offline": "offline",
|
||||
"ai.model_load_error": "Load error",
|
||||
"ai.model_settings": "Provider & model",
|
||||
"ai.add_context": "Add context",
|
||||
"ai.attach_image": "Attach an image",
|
||||
"ai.cap_audio_speech": "Audio Speech",
|
||||
"ai.cap_audio_transcription": "Audio Transcriptions",
|
||||
"ai.cap_chat": "Chat",
|
||||
"ai.cap_embeddings": "Embeddings",
|
||||
"ai.cap_images": "Images",
|
||||
"ai.cap_rerank": "Rerank",
|
||||
"ai.cap_video": "Video",
|
||||
"ai.cap_vision": "Vision capable",
|
||||
"ai.commands": "Commands",
|
||||
"ai.create_skill": "Create a skill",
|
||||
"ai.image": "image",
|
||||
"ai.keys": "Stored API keys",
|
||||
"ai.model_changed": "Model changed: {name}",
|
||||
"ai.no_results": "No results",
|
||||
"ai.none": "None",
|
||||
"ai.provider_changed": "Provider changed: {name}",
|
||||
"ai.providers": "Active providers",
|
||||
"ai.remove": "Remove",
|
||||
"ai.resize_panel": "Resize panel",
|
||||
"ai.skill_created": "Skill \"{name}\" created",
|
||||
"ai.skill_id": "Identifier",
|
||||
"ai.skill_label": "Name",
|
||||
"ai.skill_prompt": "Prompt / instructions",
|
||||
"ai.skill_prompt_placeholder": "Describe the expected behaviour of the skill…",
|
||||
"ai.usage_model": "Available models: {list}",
|
||||
"ai.usage_provider": "Available providers: {list}",
|
||||
"ai.vision_unsupported": "The selected model does not support image analysis",
|
||||
"ai.quota_exceeded": "AI: quota exceeded or payment required",
|
||||
"ai.rewrite": "💬 Rewrite",
|
||||
"ai.rewrite_done": "AI: text rewritten",
|
||||
@@ -317,6 +346,7 @@
|
||||
"config.add_webhook": "Add",
|
||||
"config.ai_deepseek_label": "DeepSeek API Key",
|
||||
"config.ai_default_model": "Default model",
|
||||
"config.ai_capabilities": "Model capabilities",
|
||||
"config.ai_default_provider": "Default provider",
|
||||
"config.ai_gemini_label": "Gemini API Key",
|
||||
"config.ai_keys": "AI API keys",
|
||||
@@ -1236,6 +1266,10 @@
|
||||
"help.assistant_links": "Cited files and paths are links: click a file to open it, a folder to reveal it in the tree.",
|
||||
"help.assistant_sessions": "The header history icon lists past sessions (reopen or delete); “+” starts a new conversation.",
|
||||
"help.assistant_agent": "The \"agent mode\" button enables tools (read, list, search); modifying actions require confirmation with a change preview.",
|
||||
"help.assistant_resize": "The left edge of the panel is resizable; the width is remembered.",
|
||||
"help.assistant_at": "Type @ to attach a file or directory to the context, or to attach an image from a directory.",
|
||||
"help.assistant_slash": "Type / to run a skill (research, summary, correction, plan…) or an admin command (/help, /providers, /model, /keys).",
|
||||
"help.assistant_images": "Paste an image into the composer (or attach one) to analyse it with a vision-capable model; the selected model's capabilities are displayed.",
|
||||
"help.toolbar_section": "AI toolbar",
|
||||
"help.tree_section": "Vault tree",
|
||||
"help.use_cases": "Use cases",
|
||||
|
||||
@@ -128,6 +128,35 @@
|
||||
"ai.model_offline": "hors ligne",
|
||||
"ai.model_load_error": "Erreur de chargement",
|
||||
"ai.model_settings": "Fournisseur & modèle",
|
||||
"ai.add_context": "Ajouter un contexte",
|
||||
"ai.attach_image": "Joindre une image",
|
||||
"ai.cap_audio_speech": "Audio Speech",
|
||||
"ai.cap_audio_transcription": "Audio Transcriptions",
|
||||
"ai.cap_chat": "Chat",
|
||||
"ai.cap_embeddings": "Embeddings",
|
||||
"ai.cap_images": "Images",
|
||||
"ai.cap_rerank": "Rerank",
|
||||
"ai.cap_video": "Video",
|
||||
"ai.cap_vision": "Vision capable",
|
||||
"ai.commands": "Commandes",
|
||||
"ai.create_skill": "Créer un skill",
|
||||
"ai.image": "image",
|
||||
"ai.keys": "Clés API enregistrées",
|
||||
"ai.model_changed": "Modèle changé : {name}",
|
||||
"ai.no_results": "Aucun résultat",
|
||||
"ai.none": "Aucun",
|
||||
"ai.provider_changed": "Fournisseur changé : {name}",
|
||||
"ai.providers": "Fournisseurs actifs",
|
||||
"ai.remove": "Retirer",
|
||||
"ai.resize_panel": "Redimensionner le panneau",
|
||||
"ai.skill_created": "Skill « {name} » créé",
|
||||
"ai.skill_id": "Identifiant",
|
||||
"ai.skill_label": "Nom",
|
||||
"ai.skill_prompt": "Prompt / instructions",
|
||||
"ai.skill_prompt_placeholder": "Décris le comportement attendu du skill…",
|
||||
"ai.usage_model": "Modèles disponibles : {list}",
|
||||
"ai.usage_provider": "Fournisseurs disponibles : {list}",
|
||||
"ai.vision_unsupported": "Le modèle sélectionné ne supporte pas l'analyse d'images",
|
||||
"ai.quota_exceeded": "AI: quota dépassé ou paiement requis",
|
||||
"ai.rewrite": "💬 Réécrire",
|
||||
"ai.rewrite_done": "AI: texte réécrit",
|
||||
@@ -317,6 +346,7 @@
|
||||
"config.add_webhook": "Ajouter",
|
||||
"config.ai_deepseek_label": "DeepSeek API Key",
|
||||
"config.ai_default_model": "Modèle par défaut",
|
||||
"config.ai_capabilities": "Capacités du modèle",
|
||||
"config.ai_default_provider": "Fournisseur par défaut",
|
||||
"config.ai_gemini_label": "Gemini API Key",
|
||||
"config.ai_keys": "Clés API IA",
|
||||
@@ -1236,6 +1266,10 @@
|
||||
"help.assistant_links": "Les fichiers et chemins cités sont des liens : cliquez sur un fichier pour l'ouvrir, sur un dossier pour le révéler dans l'arborescence.",
|
||||
"help.assistant_sessions": "L'icône historique de l'en-tête liste les sessions passées (recharger ou supprimer) ; « + » démarre une nouvelle conversation.",
|
||||
"help.assistant_agent": "Le bouton « mode agent » active les outils (lire, lister, chercher) ; les actions de modification demandent une confirmation avec aperçu des changements.",
|
||||
"help.assistant_resize": "Le bord gauche du panneau est redimensionnable ; la largeur est mémorisée.",
|
||||
"help.assistant_at": "Tapez @ pour joindre un fichier ou un répertoire au contexte, ou pour attacher une image d'un répertoire.",
|
||||
"help.assistant_slash": "Tapez / pour lancer un skill (recherche, résumé, correction, plan…) ou une commande admin (/help, /providers, /model, /keys).",
|
||||
"help.assistant_images": "Collez une image dans la zone de saisie (ou joignez-la) pour l'analyser avec un modèle compatible vision ; les capacités du modèle sélectionné sont affichées.",
|
||||
"help.toolbar_section": "Barre d'outils AI",
|
||||
"help.tree_section": "Arborescence des vaults",
|
||||
"help.use_cases": "Cas d'usage",
|
||||
|
||||
@@ -9307,7 +9307,69 @@ body.popup-mode .content-area {
|
||||
padding: 0 8px; border-radius: 6px; display: flex; align-items: center; }
|
||||
.bookslm-history-delete:hover { color: #f87171; background: rgba(248,113,113,0.12); }
|
||||
.bookslm-history-empty { padding: 10px; font-size: 12px; color: var(--text-secondary); text-align: center; }
|
||||
/* Resizable left edge. */
|
||||
.bookslm-resize-handle { position: absolute; left: 0; top: 0; bottom: 0; width: 6px;
|
||||
cursor: ew-resize; z-index: 30; background: transparent; }
|
||||
.bookslm-resize-handle:hover { background: var(--accent); opacity: 0.4; }
|
||||
.bookslm-panel.fullscreen .bookslm-resize-handle { display: none; }
|
||||
body.bookslm-resizing { cursor: ew-resize; user-select: none; }
|
||||
/* Attachment chips (ad-hoc context, skill, images). */
|
||||
.bookslm-attachments { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 12px 0; }
|
||||
.bookslm-attachments.empty { display: none; }
|
||||
.bookslm-chip { display: inline-flex; align-items: center; gap: 4px; max-width: 100%;
|
||||
padding: 3px 6px 3px 8px; border: 1px solid var(--border); border-radius: 14px;
|
||||
background: var(--surface2); font-size: 11px; color: var(--text-primary); }
|
||||
.bookslm-chip-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 220px; }
|
||||
.bookslm-chip-remove { background: none; border: none; cursor: pointer; color: var(--text-secondary);
|
||||
font-size: 14px; line-height: 1; padding: 0 2px; border-radius: 50%; }
|
||||
.bookslm-chip-remove:hover { color: #f87171; }
|
||||
.bookslm-chip-thumb { width: 18px; height: 18px; object-fit: cover; border-radius: 4px; }
|
||||
.bookslm-chip-skill { border-color: var(--accent); }
|
||||
/* Composer menus (`/` commands and `@` mentions). */
|
||||
.bookslm-menu-layer { position: relative; }
|
||||
.bookslm-command-menu, .bookslm-mention-menu { position: absolute; left: 12px; right: 12px; bottom: 4px;
|
||||
max-height: 260px; overflow-y: auto; background: var(--bg-primary); border: 1px solid var(--border);
|
||||
border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,0.35); padding: 4px; z-index: 40; }
|
||||
.bookslm-command-menu.hidden, .bookslm-mention-menu.hidden { display: none; }
|
||||
.bookslm-menu-item { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left;
|
||||
background: none; border: none; cursor: pointer; padding: 7px 9px; border-radius: 7px;
|
||||
color: var(--text-primary); font-size: 13px; }
|
||||
.bookslm-menu-item:hover, .bookslm-menu-item.active { background: var(--surface2); }
|
||||
.bookslm-menu-icon { flex-shrink: 0; }
|
||||
.bookslm-menu-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bookslm-menu-desc, .bookslm-menu-usage { margin-left: auto; font-size: 11px; color: var(--text-secondary);
|
||||
white-space: nowrap; }
|
||||
.bookslm-menu-empty { padding: 10px; font-size: 12px; color: var(--text-secondary); text-align: center; }
|
||||
/* Image attach button. */
|
||||
.bookslm-btn-attach { display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
background: none; border: 1px solid var(--border); color: var(--text-secondary); cursor: pointer;
|
||||
border-radius: 8px; padding: 7px; }
|
||||
.bookslm-btn-attach:hover { color: var(--accent); border-color: var(--accent); }
|
||||
.bookslm-file-input { display: none; }
|
||||
/* Create-skill modal. */
|
||||
.bookslm-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 2000;
|
||||
display: flex; align-items: center; justify-content: center; padding: 16px; }
|
||||
.bookslm-modal { background: var(--bg-primary); border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 18px; width: min(520px, 100%); box-shadow: 0 12px 40px rgba(0,0,0,0.45); }
|
||||
.bookslm-modal-title { margin: 0 0 12px; font-size: 15px; }
|
||||
.bookslm-modal-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px;
|
||||
color: var(--text-secondary); margin-bottom: 10px; }
|
||||
.bookslm-modal-field input, .bookslm-modal-field textarea { padding: 8px 10px; border-radius: 8px;
|
||||
border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-primary);
|
||||
font-size: 13px; font-family: inherit; }
|
||||
.bookslm-modal-error { color: #f87171; font-size: 12px; min-height: 14px; margin-bottom: 6px; }
|
||||
.bookslm-modal-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.bookslm-modal-actions button { padding: 7px 14px; border-radius: 8px; border: 1px solid var(--border);
|
||||
background: var(--surface); color: var(--text-primary); cursor: pointer; font-size: 13px; }
|
||||
.bookslm-modal-actions button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
/* Model capability checklist in the picker. */
|
||||
.ai-picker-caps-host { flex-basis: 100%; }
|
||||
.ai-picker-caps { display: flex; flex-wrap: wrap; gap: 4px 10px; margin-top: 4px; font-size: 10px;
|
||||
color: var(--text-secondary); }
|
||||
.ai-cap-item { white-space: nowrap; opacity: 0.65; }
|
||||
.ai-cap-item.on { opacity: 1; color: var(--accent); }
|
||||
@media (max-width: 768px) {
|
||||
.bookslm-resize-handle { display: none; }
|
||||
.bookslm-panel {
|
||||
width: 100%;
|
||||
/* Clear the fixed bottom toolbar so the input box and the send button
|
||||
|
||||
@@ -607,6 +607,187 @@ async function main() {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
// ── 29. Resizable panel bounds and persistence (#81) ──
|
||||
await test("panel width is clamped and persisted", async () => {
|
||||
localStorage.clear();
|
||||
const b = new BooksLM();
|
||||
b._panel = b._render();
|
||||
document.body.appendChild(b._panel);
|
||||
assert.equal(b._clampPanelWidth(100), 320, "min width enforced");
|
||||
assert.equal(b._clampPanelWidth(5000), 1000, "max width enforced");
|
||||
localStorage.setItem("obsigate-bookslm-width", "640");
|
||||
b._applyPanelWidth();
|
||||
assert.equal(b._panel.style.width, "640px", "persisted width applied");
|
||||
assert.ok(b._panel.querySelector(".bookslm-resize-handle"), "resize handle present");
|
||||
b._panel.remove();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
// ── 30. Ad-hoc context chips (#81) ──
|
||||
await test("ad-hoc files and directories render as chips", async () => {
|
||||
const b = new BooksLM();
|
||||
b._panel = b._render();
|
||||
document.body.appendChild(b._panel);
|
||||
b._addAdhocFile("notes/a.md");
|
||||
b._addAdhocDir("projets/");
|
||||
const chips = b._panel.querySelectorAll(".bookslm-attachments .bookslm-chip");
|
||||
assert.equal(chips.length, 2, "one chip per ad-hoc entry");
|
||||
assert.ok(b._adhocDirs[0].path === "projets", "trailing slash stripped");
|
||||
b._removeAdhocFile("notes/a.md");
|
||||
assert.equal(b._panel.querySelectorAll(".bookslm-attachments .bookslm-chip").length, 1);
|
||||
b._panel.remove();
|
||||
});
|
||||
|
||||
// ── 31. Chat payload carries extras, images and skill (#81) ──
|
||||
await test("send payload includes extra context, images and skill", async () => {
|
||||
localStorage.clear();
|
||||
const b = new BooksLM();
|
||||
b._panel = b._render();
|
||||
document.body.appendChild(b._panel);
|
||||
b._mode = MODE.GENERAL;
|
||||
b._vault = "V";
|
||||
b._messages = [];
|
||||
b._adhocFiles = [{ path: "a.md" }];
|
||||
b._adhocDirs = [{ path: "sub" }];
|
||||
b._images = [{ path: "pics/x.png", name: "x.png" }];
|
||||
b._activeSkill = "research";
|
||||
b._panel.querySelector("textarea").value = "Analyse";
|
||||
|
||||
let posted = null;
|
||||
globalThis.fetch = async (url, opts) => {
|
||||
if (String(url).includes("/api/ai/bookslm/chat")) posted = JSON.parse(opts.body);
|
||||
return { ok: true, status: 200, body: { getReader: () => ({ read: async () => ({ done: true }) }) } };
|
||||
};
|
||||
await b._sendMessage();
|
||||
|
||||
assert.ok(posted, "chat request posted");
|
||||
assert.deepEqual(posted.extra_files, ["a.md"]);
|
||||
assert.deepEqual(posted.extra_directories, ["sub"]);
|
||||
assert.deepEqual(posted.images, [{ path: "pics/x.png" }]);
|
||||
assert.equal(posted.skill, "research");
|
||||
b._panel.remove();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
// ── 32. Images force the plain chat endpoint (not the agent) (#81) ──
|
||||
await test("images route to /chat even in agent mode", async () => {
|
||||
const b = new BooksLM();
|
||||
b._agentMode = true;
|
||||
let url = "";
|
||||
globalThis.fetch = async (u) => {
|
||||
url = String(u);
|
||||
return { ok: true, status: 200, json: async () => ({}) };
|
||||
};
|
||||
await b._postChat({ images: [{ data: "AAA", mime_type: "image/png" }] });
|
||||
assert.ok(url.includes("/api/ai/bookslm/chat"), "image request uses /chat");
|
||||
await b._postChat({ images: [] });
|
||||
assert.ok(url.includes("/api/ai/bookslm/agent"), "text request uses /agent in agent mode");
|
||||
});
|
||||
|
||||
// ── 33. Vision gate (#81) ──
|
||||
await test("_modelSupportsVision reflects selected model capabilities", async () => {
|
||||
localStorage.setItem("obsigate_ai_picker", JSON.stringify({
|
||||
provider: "deepseek", model: "deepseek-chat", capabilities: { chat: true, vision: false },
|
||||
}));
|
||||
const b = new BooksLM();
|
||||
assert.equal(await b._modelSupportsVision(), false);
|
||||
localStorage.setItem("obsigate_ai_picker", JSON.stringify({
|
||||
provider: "qwencloud", model: "qwen-vl-max", capabilities: { chat: true, vision: true },
|
||||
}));
|
||||
assert.equal(await b._modelSupportsVision(), true);
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
// ── 34. `/` command menu and selection (#81) ──
|
||||
await test("slash command menu lists skills and selects one", async () => {
|
||||
localStorage.clear();
|
||||
globalThis.fetch = async (url) => {
|
||||
if (String(url).includes("/api/ai/skills")) {
|
||||
return {
|
||||
ok: true, status: 200,
|
||||
json: async () => ({
|
||||
skills: [{ id: "research", label: "Recherche", icon: "🔎", type: "skill", description: "d" }],
|
||||
commands: [{ id: "help", label: "Aide", icon: "❓", type: "admin", description: "h" }],
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({}) };
|
||||
};
|
||||
const b = new BooksLM();
|
||||
b._panel = b._render();
|
||||
document.body.appendChild(b._panel);
|
||||
const ta = b._panel.querySelector("textarea");
|
||||
ta.value = "/res";
|
||||
ta.selectionStart = ta.selectionEnd = 4;
|
||||
await b._showCommandMenu("res");
|
||||
const menu = b._panel.querySelector(".bookslm-command-menu");
|
||||
assert.ok(!menu.classList.contains("hidden"), "command menu shown");
|
||||
assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 1);
|
||||
b._selectCommand({ id: "research", label: "Recherche", type: "skill" });
|
||||
assert.equal(b._activeSkill, "research");
|
||||
assert.ok(b._panel.querySelector(".bookslm-chip-skill"), "skill chip rendered");
|
||||
b._panel.remove();
|
||||
});
|
||||
|
||||
// ── 35. `@` mention menu adds ad-hoc context / images (#81) ──
|
||||
await test("mention menu adds files, directories and images", async () => {
|
||||
localStorage.clear();
|
||||
globalThis.fetch = async (url) => {
|
||||
if (String(url).includes("/api/tree-search")) {
|
||||
return {
|
||||
ok: true, status: 200,
|
||||
json: async () => ({ results: [
|
||||
{ path: "notes/a.md", type: "file" },
|
||||
{ path: "notes", type: "dir" },
|
||||
] }),
|
||||
};
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({}) };
|
||||
};
|
||||
const b = new BooksLM();
|
||||
b._panel = b._render();
|
||||
document.body.appendChild(b._panel);
|
||||
b._vault = "V";
|
||||
await b._showMentionMenu("notes");
|
||||
const menu = b._panel.querySelector(".bookslm-mention-menu");
|
||||
assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 2);
|
||||
b._selectMention({ id: "notes/a.md", type: "file" });
|
||||
assert.ok(b._adhocFiles.some((f) => f.path === "notes/a.md"));
|
||||
b._selectMention({ id: "notes", type: "dir" });
|
||||
assert.ok(b._adhocDirs.some((d) => d.path === "notes"));
|
||||
b._selectMention({ id: "pics/x.png", type: "file" });
|
||||
assert.ok(b._images.some((img) => img.path === "pics/x.png"));
|
||||
b._panel.remove();
|
||||
});
|
||||
|
||||
// ── 36. Capability checklist rendering (#81) ──
|
||||
await test("capability list renders checked and unchecked flags", () => {
|
||||
const caps = {
|
||||
chat: true, embeddings: false, rerank: false, images: false,
|
||||
video: false, audio_speech: false, audio_transcription: false, vision: true,
|
||||
};
|
||||
const box = ai.renderCapabilityList(caps);
|
||||
assert.equal(box.querySelectorAll(".ai-cap-item").length, 8);
|
||||
assert.equal(box.querySelectorAll(".ai-cap-item.on").length, 2);
|
||||
assert.ok(box.textContent.includes("☑"));
|
||||
assert.ok(box.textContent.includes("□"));
|
||||
});
|
||||
|
||||
// ── 37. Admin commands handled locally (#81) ──
|
||||
await test("admin commands are parsed without an LLM call", async () => {
|
||||
const b = new BooksLM();
|
||||
b._panel = b._render();
|
||||
document.body.appendChild(b._panel);
|
||||
b._messages = [];
|
||||
let fetched = false;
|
||||
globalThis.fetch = async () => { fetched = true; return { ok: true, status: 200, json: async () => ({}) }; };
|
||||
const handled = b._maybeRunAdminWithArg("/model deepseek-chat");
|
||||
assert.equal(handled, true);
|
||||
assert.equal(fetched, false, "no LLM request for admin command");
|
||||
assert.ok(b._messages.length === 1, "assistant note pushed");
|
||||
b._panel.remove();
|
||||
});
|
||||
|
||||
// ── Summary ──
|
||||
console.log(`\n${passCount}/${testCount} tests passed`);
|
||||
if (passCount !== testCount) {
|
||||
|
||||
@@ -256,6 +256,37 @@ class TestListModelsEndpoint:
|
||||
assert hdrs["api-key"] == "fake-xiaomi-key"
|
||||
assert "authorization" not in hdrs, f"Authorization leaked: {hdrs!r}"
|
||||
|
||||
def test_models_include_capabilities(self, admin_client, monkeypatch):
|
||||
"""The model list must expose per-model capability flags (#81)."""
|
||||
from backend import ai as aimod
|
||||
monkeypatch.setattr(aimod, "get_ai_key", lambda name: None)
|
||||
|
||||
token = _login_admin(admin_client)
|
||||
resp = admin_client.get(
|
||||
"/api/config/ai-models",
|
||||
params={"provider": "qwencloud"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
caps = data.get("capabilities") or {}
|
||||
assert "qwen-vl-max" in caps
|
||||
assert caps["qwen-vl-max"]["vision"] is True
|
||||
|
||||
def test_model_capabilities_endpoint(self, admin_client):
|
||||
"""GET /api/ai/model-capabilities returns the curated flags."""
|
||||
token = _login_admin(admin_client)
|
||||
resp = admin_client.get(
|
||||
"/api/ai/model-capabilities",
|
||||
params={"provider": "deepseek", "model": "deepseek-chat"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["provider"] == "deepseek"
|
||||
assert data["capabilities"]["chat"] is True
|
||||
assert data["capabilities"]["vision"] is False
|
||||
|
||||
def test_xiaomi_test_endpoint_uses_real_url(self, admin_client, monkeypatch):
|
||||
"""The /api/config/ai-keys/test endpoint must also use api.xiaomimimo.com.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user