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