diff --git a/CHANGELOG.md b/CHANGELOG.md index 90ae09e..018042c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) ; diff --git a/backend/ai_chat.py b/backend/ai_chat.py index 19edf6a..106201c 100644 --- a/backend/ai_chat.py +++ b/backend/ai_chat.py @@ -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 diff --git a/backend/ai_routes.py b/backend/ai_routes.py index c161681..ea0fbcf 100644 --- a/backend/ai_routes.py +++ b/backend/ai_routes.py @@ -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), + } diff --git a/backend/bookslm.py b/backend/bookslm.py index f0ff266..e98e5dc 100644 --- a/backend/bookslm.py +++ b/backend/bookslm.py @@ -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 { diff --git a/backend/bookslm_routes.py b/backend/bookslm_routes.py index aa79513..caa3ecc 100644 --- a/backend/bookslm_routes.py +++ b/backend/bookslm_routes.py @@ -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) diff --git a/backend/main.py b/backend/main.py index 60ab37d..a51b42b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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"} diff --git a/backend/model_capabilities.py b/backend/model_capabilities.py new file mode 100644 index 0000000..47c9b05 --- /dev/null +++ b/backend/model_capabilities.py @@ -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), + } diff --git a/backend/schemas.py b/backend/schemas.py index aea7fe7..1566220 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -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): diff --git a/backend/skills.py b/backend/skills.py new file mode 100644 index 0000000..e7d1f13 --- /dev/null +++ b/backend/skills.py @@ -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 ", + "description": "Changer de fournisseur LLM", + }, + { + "id": "model", + "label": "Changer de modèle", + "icon": "🧠", + "type": "admin", + "usage": "/model ", + "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 diff --git a/backend/skills_routes.py b/backend/skills_routes.py new file mode 100644 index 0000000..46559e2 --- /dev/null +++ b/backend/skills_routes.py @@ -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} diff --git a/docs/AI_ARCHITECTURE_GUIDE.md b/docs/AI_ARCHITECTURE_GUIDE.md index 668d11f..3930370 100644 --- a/docs/AI_ARCHITECTURE_GUIDE.md +++ b/docs/AI_ARCHITECTURE_GUIDE.md @@ -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 | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d428b9a..bee0638 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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** | diff --git a/docs/features/ai-assistant-commands.md b/docs/features/ai-assistant-commands.md new file mode 100644 index 0000000..74bfa25 --- /dev/null +++ b/docs/features/ai-assistant-commands.md @@ -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 `, + `/model `, `/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. diff --git a/frontend/index.html b/frontend/index.html index 6cf3edf..e760b52 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2015,6 +2015,18 @@ +
+ +
+