Compare commits

..
4 Commits
Author SHA1 Message Date
bruno 3131277b19 feat(forge): assistant IA partage et plein ecran Forge/Editer (#101)
CI / lint (push) Successful in 1m33s
CI / security (push) Successful in 1m1s
CI / test (push) Successful in 2m50s
CI / build (push) Successful in 1m6s
CI / e2e (push) Successful in 10m48s
2026-09-17 08:30:32 -04:00
bruno 23a3c147cd fix(editor): le bouton Sauvegarder ne reste plus bloque sur le spinner (BUG-054)
CI / lint (push) Successful in 1m45s
CI / security (push) Successful in 1m1s
CI / test (push) Successful in 2m55s
CI / build (push) Successful in 57s
CI / e2e (push) Successful in 10m48s
2026-09-17 08:00:13 -04:00
bruno f02174af57 fix(ai): mode agent utilise les outils natifs au lieu du bloc obsigate-action (BUG-053)
CI / lint (push) Successful in 1m33s
CI / security (push) Successful in 1m0s
CI / test (push) Successful in 2m57s
CI / build (push) Successful in 56s
CI / e2e (push) Successful in 10m48s
2026-09-17 07:30:31 -04:00
bruno e3434d19ea fix(ai): garantir une reponse finale quand la boucle d'agent epuise son budget (BUG-052)
CI / lint (push) Successful in 1m31s
CI / security (push) Successful in 1m2s
CI / test (push) Successful in 3m25s
CI / build (push) Successful in 56s
CI / e2e (push) Successful in 10m41s
2026-09-16 23:28:56 -04:00
27 changed files with 675 additions and 127 deletions
+70 -1
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.7.2**.
> [Unreleased](#unreleased). La dernière version livrée est **2.8.0**.
---
@@ -14,6 +14,75 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.8.0] — 2026-09-17
### Ajouté
- **#101 - Forge : Assistant IA partagé et plein écran (Forge & Editer)** : le bouton
« AI Panel » de Forge ouvre désormais le **panneau Assistant IA** existant (barre latérale)
au lieu d'un mini-chat isolé — même contenu, même fournisseur/modèle, même historique, mêmes
menus `/` et `@`, sans duplication. Forge lit la sélection du sélecteur de l'assistant
(`localStorage['obsigate_ai_picker']`, même origine) et l'injecte dans ses appels
`/api/ai/*` et sa **complétion fantôme** (repli `ollama` si aucun fournisseur choisi) ; au
passage, les endpoints erronés sont corrigés (`make-longer`/`make-shorter`, `target_lang`).
Un bouton **plein écran** natif est ajouté à Forge (iframe `allow="fullscreen"`) et à
l'éditeur **Editer** (plein écran sur le conteneur, fonctionne en modale comme en inline,
sortie à la fermeture). Libellés i18n FR/EN. Fichiers : `frontend/editor-poc.html`,
`frontend/js/sync.js`, `frontend/js/viewer.js`, `frontend/js/utils.js`,
`frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`. Tests :
`tests/frontend/editor-inline.test.mjs` (+10).
---
## [2.7.5] — 2026-09-17
### Corrigé
- **BUG-054 - Éditeur « Editer » : le bouton Sauvegarder restait bloqué sur le spinner de
chargement** : le bouton `#editor-save` est un nœud DOM partagé entre toutes les sessions
d'édition (y compris en mode en ligne). Une sauvegarde manuelle y remplaçait le crochet par un
spinner et le désactivait, mais cet état n'était jamais remis à zéro : après une sauvegarde
réussie (l'éditeur se ferme puis se rouvre), après une sauvegarde Forge, ou après un échec de
requête (le `catch` ne restaurait ni l'icône ni l'état), le spinner persistait jusqu'à un
rechargement complet de la page. Un helper `resetSaveButton()` restaure désormais le crochet
et réactive le bouton à l'ouverture (`openEditor`), à la fermeture (`closeEditor`) et en cas
d'échec (`saveFile`). Fichier : `frontend/js/utils.js`. Tests :
`tests/frontend/editor-inline.test.mjs` (+4).
---
## [2.7.4] — 2026-09-17
### Corrigé
- **BUG-053 - Assistant IA (mode agent) : le fichier demandé n'est pas créé** : le prompt
système du mode Général (et du dossier vide) enseignait encore le **protocole texte**
`obsigate-action`, si bien que le modèle décrivait l'action dans un bloc texte au lieu
d'appeler l'outil natif `create_file` — rien n'était donc créé (et le bloc, volumineux,
était tronqué avant sa fermeture). En mode agent, le prompt demande désormais d'appeler
directement les outils natifs (`create_file`, `create_directory`, …) et interdit les blocs
`obsigate-action` ; le chat classique conserve le protocole texte. La limite de sortie de
l'agent passe à 8 192 jetons pour laisser place au contenu complet d'un fichier.
Fichiers : `backend/bookslm.py`, `backend/bookslm_routes.py`. Tests : `tests/test_bookslm.py` (+3).
---
## [2.7.3] — 2026-09-16
### Corrigé
- **BUG-052 - Assistant IA : recherche web sans réponse finale (étapes et sources affichées, aucun texte)** :
quand le budget d'itérations (`DEFAULT_MAX_ITERATIONS = 10`) ou le quota d'appels d'outils
était épuisé pendant que le modèle enchaînait encore des recherches/lectures, la boucle
d'agent renvoyait un contenu vide → la conversation n'affichait que les étapes et les
sources. La boucle effectue désormais un **dernier appel sans outil** qui demande au modèle
de synthétiser les informations recueillies (`_finalize_answer`), avec un repli déterministe
listant les sources si cet appel échoue ou reste vide. Les appels d'outils non atteints du
lot en cours de quota reçoivent un résultat `deferred` pour garder la conversation valide.
Fichier : `backend/agent/loop.py`. Tests : `tests/test_agent_loop.py` (+2).
---
## [2.7.2] — 2026-09-16
### Corrigé
+3 -3
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.7.2-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.8.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -916,8 +916,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.7.2).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.8.0).
---
*Projet : ObsiGate | Version : 2.7.2 | Dernière mise à jour : Juin 2026*
*Projet : ObsiGate | Version : 2.8.0 | Dernière mise à jour : Juin 2026*
+3 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.7.2-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.8.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1085,8 +1085,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.7.2).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.8.0).
---
*Project: ObsiGate | Version: 2.7.2 | Last updated: May 2026*
*Project: ObsiGate | Version: 2.8.0 | Last updated: May 2026*
+1 -1
View File
@@ -1 +1 @@
2.7.2
2.8.0
+85 -17
View File
@@ -40,6 +40,15 @@ MAX_TOOL_RESULT_CHARS = 100_000
# Quota: maximum tool calls executed per agent run (``BOOKSLM_MAX_TOOL_CALLS``).
DEFAULT_MAX_TOOL_CALLS = int(os.environ.get("BOOKSLM_MAX_TOOL_CALLS", "25"))
# Sent as a last user turn when the loop stopped before the model produced an
# answer (iteration/quota budget exhausted while it was still calling tools).
_FINALIZE_INSTRUCTION = (
"N'appelle plus aucun outil. Réponds maintenant directement à l'utilisateur, "
"en français, à partir des informations déjà recueillies ci-dessus. "
"Structure la réponse en Markdown, cite les liens sources utiles, et si les "
"informations sont insuffisantes, dis-le explicitement."
)
# Stopping reasons
STOP_DONE = "done"
STOP_MAX_ITERATIONS = "max_iterations"
@@ -109,8 +118,8 @@ def _assistant_tool_message(content: str | None, tool_calls: list[Any]) -> dict[
}
def _deferred_tool_message(call: Any) -> dict[str, Any]:
"""Answer a tool call that was not reached because the run paused.
def _deferred_tool_message(call: Any, reason: str | None = None) -> dict[str, Any]:
"""Answer a tool call that was not reached because the run stopped early.
A single LLM response may carry several tool calls. When one of them is
mutating and pauses the run for confirmation, the assistant message already
@@ -125,7 +134,7 @@ def _deferred_tool_message(call: Any) -> dict[str, Any]:
"name": call.name,
"content": json.dumps({
"status": "deferred",
"reason": (
"reason": reason or (
"Not executed: the run paused to confirm an earlier tool call. "
"Re-issue this call if it is still needed."
),
@@ -133,6 +142,69 @@ def _deferred_tool_message(call: Any) -> dict[str, Any]:
}
def _fallback_summary(executed: list[ToolCallRecord]) -> str:
"""Deterministic non-empty answer built from the gathered tool results.
Used only if the final synthesis call fails or returns nothing, so a turn
never ends on an empty message (BUG-052).
"""
lines: list[str] = []
for record in executed:
data = record.result
if not isinstance(data, dict):
continue
for item in (data.get("results") or [])[:5]:
if not isinstance(item, dict):
continue
title = item.get("title") or item.get("url") or ""
url = item.get("url") or ""
lines.append(f"- [{title}]({url})" if url else f"- {title}")
if data.get("url") and data.get("text"):
title = data.get("title") or data["url"]
lines.append(f"- [{title}]({data['url']})")
if not lines:
return "Je n'ai pas pu produire de réponse à partir des résultats obtenus."
unique = list(dict.fromkeys(lines))
return "Voici les sources pertinentes trouvées :\n" + "\n".join(unique)
async def _finalize_answer(
llm: Callable[..., Any],
convo: list[dict[str, Any]],
executed: list[ToolCallRecord],
steps: list[dict[str, Any]],
iterations: int,
stopped: str,
) -> AgentResult:
"""Guarantee a textual answer when the loop stopped before producing one.
Web research often exhausts the iteration budget while the model is still
calling tools; returning ``content=""`` left the conversation with steps and
sources but no answer. One final tool-less call asks the model to synthesize
the gathered results, and a deterministic source list is used as a last
resort (BUG-052).
"""
content = ""
if executed:
try:
response = await llm(
[*convo, {"role": "user", "content": _FINALIZE_INSTRUCTION}], []
)
content = (response.content or "").strip()
except Exception as e:
logger.warning(f"Agent final synthesis failed: {e}")
if not content:
content = _fallback_summary(executed)
return AgentResult(
content=content,
messages=convo,
tool_calls=executed,
steps=steps,
iterations=iterations,
stopped=stopped,
)
def _execute_confirmed(
ctx: ToolContext,
confirm_pending: dict[str, Any],
@@ -275,13 +347,14 @@ async def run_agent(
for index, call in enumerate(response.tool_calls):
if quota is not None and len(executed) >= quota:
logger.warning(f"Agent reached the tool-call quota ({quota})")
return AgentResult(
content=response.content or "",
messages=convo,
tool_calls=executed,
steps=steps,
iterations=iteration,
stopped=STOP_QUOTA_EXCEEDED,
# Keep the conversation valid for the synthesis call: the
# assistant message announced every tool call of the batch.
for skipped in response.tool_calls[index:]:
convo.append(_deferred_tool_message(
skipped, "Not executed: the tool-call quota was reached."
))
return await _finalize_answer(
llm, convo, executed, steps, iteration, STOP_QUOTA_EXCEEDED
)
try:
result = call_tool(call.name, ctx, call.arguments)
@@ -327,11 +400,6 @@ async def run_agent(
})
logger.warning(f"Agent reached max iterations ({max_iterations})")
return AgentResult(
content="",
messages=convo,
tool_calls=executed,
steps=steps,
iterations=max_iterations,
stopped=STOP_MAX_ITERATIONS,
return await _finalize_answer(
llm, convo, executed, steps, max_iterations, STOP_MAX_ITERATIONS
)
+32 -2
View File
@@ -486,12 +486,17 @@ def build_system_prompt(context: dict[str, Any], scope: str = "directory", vault
return prompt
GENERAL_SYSTEM_PROMPT = """Tu es l'assistant intégré d'ObsiGate, une application web auto-hébergée pour consulter, rechercher et éditer des vaults Obsidian (Markdown).
GENERAL_SYSTEM_HEADER = """Tu es l'assistant intégré d'ObsiGate, une application web auto-hébergée pour consulter, rechercher et éditer des vaults Obsidian (Markdown).
Tes deux rôles :
1. **Aider sur l'application** : expliquer la navigation, la recherche (full-text, filtres `tag:`, `created:`, `path:`), l'éditeur (CodeMirror, autosave, raccourcis), les onglets et le split view, les sauvegardes et la restauration, le partage public, l'export (HTML/Markdown/ePub/PDF), Mermaid, Excalidraw, les plugins, les thèmes, le mode hors-ligne, le MFA, etc.
2. **Proposer des actions concrètes** : créer un fichier ou un dossier dans un vault.
"""
# Text action protocol — used by the classic (non-agent) chat endpoint, where
# the model has no native tool calling; the frontend turns each block into a
# clickable “Apply” card.
GENERAL_ACTION_TEXT_PROTOCOL = """
Quand l'utilisateur demande explicitement de créer un fichier, inclus un bloc de ce type dans ta réponse (un bloc par fichier, et rien d'autre à l'intérieur du bloc) :
```obsigate-action
@@ -513,6 +518,25 @@ Règles :
- Réponds dans la langue de l'utilisateur, de façon concise et structurée (Markdown).
"""
# Agent mode: the model has native tools, so it must call them (function
# calling) instead of emitting the text `obsigate-action` blocks — otherwise
# the requested file is never created (BUG-053).
GENERAL_ACTION_TOOL_PROTOCOL = """
Tu disposes d'outils natifs (function calling) pour lire, chercher et modifier les vaults : `create_file`, `create_directory`, `append_to_file`, `edit_file`, `read_file`, `search_fulltext`, etc.
Quand l'utilisateur demande explicitement de créer un fichier, **appelle directement l'outil `create_file`** avec `{"vault": "<nom du vault>", "path": "<chemin/relatif.md>", "content": "<contenu markdown>"}`. Pour créer un dossier, appelle `create_directory`.
Règles :
- N'écris **jamais** de bloc ```obsigate-action``` : en mode agent, toutes les actions passent par les outils natifs.
- Écris le contenu **complet** demandé dans l'argument `content` (ne le tronque pas, pas de « … » ni de ligne omise).
- Pour créer un fichier dans un nouveau dossier, un seul appel `create_file` avec le chemin complet suffit (les dossiers parents sont créés automatiquement).
- N'invente jamais un nom de vault : utilise l'un des vaults disponibles listés ci-dessous.
- Réponds dans la langue de l'utilisateur, de façon concise et structurée (Markdown).
"""
# Backwards-compatible alias (classic chat prompt).
GENERAL_SYSTEM_PROMPT = GENERAL_SYSTEM_HEADER + GENERAL_ACTION_TEXT_PROTOCOL
def _format_app_context(app_context: dict[str, Any] | None, recent_files: list[dict[str, Any]] | None) -> str:
"""Render the live application state for the General assistant prompt.
@@ -588,14 +612,20 @@ def build_general_system_prompt(
vaults: list[str] | None = None,
app_context: dict[str, Any] | None = None,
recent_files: list[dict[str, Any]] | None = None,
agent: bool = False,
) -> str:
"""System prompt for the General assistant (app help + actions).
``app_context`` carries the live UI state (open documents, current
directory, active search) and ``recent_files`` the last modified files, so
the assistant knows what the user is doing rather than answering blind.
``agent`` selects the action protocol: the classic chat endpoint (no native
tools) uses the text ``obsigate-action`` blocks, while the tool-calling
agent endpoint must invoke the native tools instead (BUG-053).
"""
prompt = GENERAL_SYSTEM_PROMPT
protocol = GENERAL_ACTION_TOOL_PROTOCOL if agent else GENERAL_ACTION_TEXT_PROTOCOL
prompt = GENERAL_SYSTEM_HEADER + protocol
if vaults:
prompt += "\nVaults disponibles : " + ", ".join(sorted(vaults)) + "\n"
else:
+17 -3
View File
@@ -193,10 +193,12 @@ def _recent_files_for_prompt(current_user, limit: int = 10) -> list[dict[str, An
return []
def _resolve_system_prompt(req, current_user) -> str:
def _resolve_system_prompt(req, current_user, agent: bool = False) -> str:
"""Resolve the vault access and build the assistant system prompt.
Shared by the classic chat endpoint and the tool-calling agent endpoint.
``agent=True`` selects the native-tool action protocol (no text
``obsigate-action`` blocks) for the General/empty-directory prompts.
"""
mode = _normalize_mode(req.mode)
vault_path: Path | None = None
@@ -222,6 +224,7 @@ def _resolve_system_prompt(req, current_user) -> str:
list(index.keys()),
app_context=_submitted_app_context(req),
recent_files=_recent_files_for_prompt(current_user),
agent=agent,
)
elif effective_mode == "documents":
prompt = build_system_prompt(context, scope="documents", vault_name=req.vault)
@@ -233,6 +236,7 @@ def _resolve_system_prompt(req, current_user) -> str:
list(index.keys()),
app_context=_submitted_app_context(req),
recent_files=_recent_files_for_prompt(current_user),
agent=agent,
)
prompt += (
f"\n## Dossier vide\nLe dossier « {req.directory or '/'} » "
@@ -243,6 +247,14 @@ def _resolve_system_prompt(req, current_user) -> str:
else:
prompt = build_system_prompt(context, scope="directory", vault_name=req.vault)
if agent and effective_mode != "general" and context["file_count"] > 0:
prompt += (
"\n## Mode agent\n"
"Utilise les outils natifs (function calling) pour agir sur les fichiers "
"(`create_file`, `create_directory`, `append_to_file`, `edit_file`, …). "
"N'écris jamais de bloc ```obsigate-action```."
)
skill_id = getattr(req, "skill", None)
if skill_id:
skill_prompt = get_skill_prompt(skill_id, current_user)
@@ -499,7 +511,7 @@ async def api_bookslm_agent(
``confirm`` / ``confirm_messages``.
"""
_validate_vision_support(req)
system_prompt = _resolve_system_prompt(req, current_user)
system_prompt = _resolve_system_prompt(req, current_user, agent=True)
vault_path = _resolve_optional_vault_path(req, current_user)
messages: list[dict] = [{"role": "system", "content": system_prompt}]
@@ -519,7 +531,9 @@ async def api_bookslm_agent(
provider=req.provider,
model=req.model,
temperature=0.3,
max_tokens=4096,
# Tool-call arguments can carry a whole file body (e.g. a generated
# table): leave more room than the plain-chat default.
max_tokens=8192,
)
async def generate_sse():
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.7.2"
version = "2.8.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.7.2"
version = "2.8.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.7.2",
"version": "2.8.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+8 -1
View File
@@ -14,7 +14,7 @@
- **Projet** : ObsiGate — Porte d'entrée web pour vaults Obsidian
- **Stack** : Python 3.11+ (backend FastAPI) · JavaScript/Vanilla (frontend) · Tauri/Rust (desktop)
- **Dernière mise à jour** : 2026-09-16
- **Dernière mise à jour** : 2026-09-17
---
@@ -161,6 +161,9 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-049* | [🔵 MINEUR] Assistant IA : icône du bouton « + » invisible (largeur SVG nulle) | 🟢 corrigé | P3 | 📱 frontend | IA | `frontend/style.css` | Ouvrir l'assistant et observer le bouton « + » | Sélecteur porté à `.bookslm-input-area button.bookslm-btn-plus` (la règle générique `padding: 8px 16px` sur un bouton 32 px annulait la largeur de contenu) | Vérifié navigateur : SVG 0 px → 18 px. Journal 2026-09-16 |
| *BUG-050* | [🟡 IMPORTANT] Assistant IA : échec de la création d'un sous-dossier contenant un fichier (appels d'outils parallèles + confirmation) | 🟢 corrigé | P1 | ⚙️ backend + 🤖 ia | IA | `backend/agent/loop.py`, `backend/services/mutations.py`, `backend/tools/service.py`, `backend/bookslm.py` | Mode Agent : « crée le dossier X et un fichier Y dedans » puis Appliquer | `backend/agent/loop.py` : résultats « deferred » (`_deferred_tool_message`) pour les `tool_calls` non atteints lors d'une pause de confirmation ; `backend/services/mutations.py` : `create_directory(..., exist_ok=True)` ; `backend/tools/service.py` + `backend/bookslm.py` : consignes `create_file` (parents auto-créés, chemin imbriqué unique) | Cause : le message assistant listait plusieurs `tool_calls` mais la pause n'ajoutait le résultat que du seul appel confirmé → conversation invalide (tool_call_id sans réponse) au resume. Tests : `tests/test_agent_loop.py` (+1), `tests/test_tools_mutations.py` (+1), `tests/test_api_main.py` (+1) |
| *BUG-051* | [🟡 IMPORTANT] Assistant IA : la recherche web répond toujours « je ne peux pas accéder à internet » (mode agent ou non) | 🟢 corrigé | P1 | ⚙️ backend + 🤖 ia | IA | `backend/tools/web.py`, `tests/test_web_tools.py` | Assistant : « fais une recherche sur l'horaire du Canadien de Montréal 2026-2027 » | `backend/tools/web.py` : chaîne de repli sans clé — SearXNG puis DuckDuckGo (HTML sans JS) puis Bing (HTML), premier fournisseur non vide retenu (`provider`), replis désactivables via `OBSIGATE_WEB_FALLBACK=0` | Cause : l'instance SearXNG par défaut (`search.dracodev.net`) remonte 0 résultat (moteurs amont suspendus/CAPTCHA) → le modèle en déduisait une absence d'accès réseau. Tests : `tests/test_web_tools.py` (+4) |
| *BUG-052* | [🟡 IMPORTANT] Assistant IA : recherche web sans réponse finale (10 étapes + sources affichées, aucun texte dans la conversation) | 🟢 corrigé | P1 | ⚙️ backend + 🤖 ia | IA | `backend/agent/loop.py`, `tests/test_agent_loop.py` | Assistant (mode agent) : recherche web qui enchaîne 10 étapes puis n'affiche aucune réponse | `backend/agent/loop.py` : `_finalize_answer` — dernier appel LLM sans outil (instruction de synthèse) quand le budget d'itérations/quota est épuisé, repli déterministe `_fallback_summary` (liste des sources), résultats `deferred` pour les appels non atteints du lot en quota | Cause : `content=""` renvoyé sur `STOP_MAX_ITERATIONS`/`STOP_QUOTA_EXCEEDED` alors que le modèle appelait encore des outils. Tests : `tests/test_agent_loop.py` (+2) |
| *BUG-053* | [🟡 IMPORTANT] Assistant IA (mode agent) : le fichier demandé n'est pas créé — le modèle émet un bloc texte `obsigate-action` au lieu d'appeler l'outil `create_file` | 🟢 corrigé | P1 | ⚙️ backend + 🤖 ia | IA | `backend/bookslm.py`, `backend/bookslm_routes.py`, `tests/test_bookslm.py` | Mode agent, contexte Général (ou dossier vide) : « créer le fichier TestVault/sport/… avec le tableau des 84 matchs » → réponse avec un bloc ```obsigate-action``` tronqué, aucun fichier | `backend/bookslm.py` : protocole d'action scindé — `GENERAL_ACTION_TOOL_PROTOCOL` (outils natifs, interdiction des blocs `obsigate-action`) utilisé quand `agent=True`, protocole texte conservé pour le chat classique ; `backend/bookslm_routes.py` : `_resolve_system_prompt(..., agent=True)` depuis l'endpoint agent + règle « Mode agent » pour les prompts dossier/documents, `max_tokens` agent 4096 → 8192 (contenu de fichier complet) | Cause : le prompt Général enseignait encore le protocole texte alors que l'agent dispose du function calling. Tests : `tests/test_bookslm.py` (+3) |
| *BUG-054* | [🟡 IMPORTANT] Éditeur « Editer » : le bouton Sauvegarder reste bloqué sur le spinner de chargement (retour au crochet uniquement après un refresh complet) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/utils.js` | Ouvrir un fichier → Editer → cliquer Sauvegarder (ou Ctrl+S) ; rouvrir l'éditeur : le bouton reste un spinner désactivé | Nouveau helper `resetSaveButton()` (crochet `&#10003;` + `disabled=false` + styles en ligne nettoyés) appelé à l'ouverture (`openEditor`), à la fermeture (`closeEditor`) et en cas d'échec (`saveFile`). Tests : `tests/frontend/editor-inline.test.mjs` (+4) | Le nœud `#editor-save` est partagé entre sessions : l'état « spinner + désactivé » posé par une sauvegarde manuelle n'était jamais remis à zéro (succès → fermeture puis réouverture, Forge, ou échec réseau dans le `catch`). Seul un rechargement de `index.html` restaurait le crochet |
| | | | | | | | | | | |
### TODOs techniques (améliorations / nouvelles tâches)
@@ -213,6 +216,10 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-09-16 | BUG-050 | Correction | `backend/agent/loop.py`, `backend/services/mutations.py`, `backend/tools/service.py`, `backend/bookslm.py`, `tests/test_agent_loop.py`, `tests/test_tools_mutations.py`, `tests/test_api_main.py`, `docs/features/ai-tools-mcp.md`, `CHANGELOG.md` | **BUG-050** : création d'un sous-dossier contenant un fichier en mode Agent. (1) La boucle d'agent renvoyait la conversation sans réponse pour les `tool_calls` non atteints lorsqu'un appel mutateur déclenchait une confirmation → le provider rejetait le tour de reprise (« tool_call_id » orphelin) ; les appels restants reçoivent désormais un résultat `deferred` explicite (`_deferred_tool_message`) que le modèle réémet après confirmation. (2) `create_directory` est idempotent côté outil IA (`exist_ok=True`, succès si le dossier existe), le REST restant strict (409). (3) Consignes renforcées : `create_file` crée les dossiers parents, un seul appel avec chemin imbriqué suffit (`backend/bookslm.py`, descriptions d'outils). Vérifié : pytest 1091 passed / 6 skipped, ruff 0 (backend), mypy 0 (71 fichiers), tests frontend validate-imports 38 modules + unit 9/9. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-16 | BUG-051 | Correction | `backend/tools/web.py`, `tests/test_web_tools.py`, `docs/features/ai-tools-roadmap.md`, `docs/features/ai-assistant-conversation-ux.md`, `docs/ROADMAP.md`, `CHANGELOG.md` | **BUG-051** : `web_search` ne dépend plus d'une seule instance SearXNG. Nouvelle chaîne de fournisseurs (`_provider_chain`) : SearXNG (auto-hébergé, JSON) → DuckDuckGo (`html.duckduckgo.com/html/`, extraction `result__a`/`result__snippet`, décodage du lien `uddg=`) → Bing (`www.bing.com/search`, extraction `h2 > a` + `p.b_lineclamp*`, décodage de la redirection `u=a1<base64url>`), UA navigateur, premier fournisseur non vide retenu et exposé (`provider`). Le champ `warning` final liste les fournisseurs essayés ; replis désactivables via `OBSIGATE_WEB_FALLBACK=0` ; erreur `web_search_unavailable` uniquement si tous les fournisseurs sont injoignables. Vérifié : pytest 1082 passed / 6 skipped (14 erreurs MCP préexistantes, sans lien), ruff 0 (backend), mypy 0 (`backend/tools/web.py`), `tests/test_web_tools.py` 13/13, recherche live Bing (horaire Canadiens) sur l'hôte. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-16 | BUG-051 (complément) | Correction | `backend/tools/web.py`, `CHANGELOG.md` | **BUG-051** suite : un `User-Agent` navigateur seul ne suffit pas — Bing renvoie une SERP factice (résultats sans rapport, ex. « highest paying jobs » / « Sam Reid ») aux requêtes sans en-têtes de navigation. Ajout de `BROWSER_HEADERS` (`Accept-Language`, `Sec-Fetch-*`, `Upgrade-Insecure-Requests`) pour DuckDuckGo et Bing. Vérifié **en conteneur** (`obsigate-test`, v2.7.1) : `web_search('Canadien de Montreal horaire matchs 2026 2027')` → `provider: bing`, 5 résultats pertinents (nhl.com/fr/canadiens, rds.ca, fr.wikipedia.org). | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-16 | BUG-052 | Correction | `backend/agent/loop.py`, `tests/test_agent_loop.py`, `CHANGELOG.md` | **BUG-052** : la boucle d'agent ne rendait plus jamais de réponse vide. `_finalize_answer` : à l'épuisement du budget d'itérations (`STOP_MAX_ITERATIONS`) ou du quota d'appels (`STOP_QUOTA_EXCEEDED`), un dernier appel LLM **sans outil** reçoit une instruction de synthèse (« N'appelle plus aucun outil. Réponds maintenant… ») et son texte devient la réponse ; si l'appel échoue ou reste vide, `_fallback_summary` compose une liste déterministe des sources (`web_search`/`fetch_url`) pour ne jamais renvoyer un tour vide. Les `tool_calls` non atteints lors d'un arrêt sur quota reçoivent un résultat `deferred` (conversation valide pour la synthèse). Vérifié : `tests/test_agent_loop.py` 16/16 (+2 : synthèse finale, repli sources), suite complète 1084 passed / 6 skipped (14 erreurs MCP préexistantes), ruff 0 (backend), mypy 0 (`backend/agent/loop.py`). | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-16 | BUG-053 | Correction | `backend/bookslm.py`, `backend/bookslm_routes.py`, `tests/test_bookslm.py`, `CHANGELOG.md` | **BUG-053** : en mode agent, le prompt Général (et dossier vide) enseignait le protocole texte `obsigate-action` ; le modèle décrivait donc l'action au lieu d'appeler l'outil natif `create_file` (bloc volumineux de surcroît tronqué avant fermeture → aucun fichier créé). Le prompt est scindé : `GENERAL_ACTION_TOOL_PROTOCOL` (appel direct des outils natifs, interdiction explicite des blocs `obsigate-action`) pour `build_general_system_prompt(agent=True)`, le protocole texte restant utilisé par le chat classique ; `_resolve_system_prompt` propage `agent` et ajoute une règle « Mode agent » aux prompts dossier/documents ; `max_tokens` de l'agent porté à 8192 pour un contenu de fichier complet. Vérifié : `tests/test_bookslm.py` 68/68 (+3 : prompt agent sans protocole texte, prompt classique inchangé, prompt système de l'endpoint agent), suite complète 1087 passed / 6 skipped (14 erreurs MCP préexistantes), ruff 0 (backend), mypy 0 (`backend/bookslm.py`, `backend/bookslm_routes.py`). | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-17 | BUG-054 | Correction | `frontend/js/utils.js`, `tests/frontend/editor-inline.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-054** : le bouton `#editor-save` (nœud partagé entre toutes les sessions d'édition) restait bloqué sur le spinner de chargement et désactivé — une sauvegarde manuelle (clic ou Ctrl+S) remplaçait le crochet par le loader et ne le restaurait jamais : succès (l'éditeur se ferme, la réouverture réaffichait le spinner), sauvegarde Forge, ou échec réseau (le `catch` ne restaurait ni l'icône ni l'état). Nouveau helper `resetSaveButton()` (crochet `&#10003;`, `disabled=false`, styles en ligne nettoyés) appelé à l'ouverture (`openEditor`), à la fermeture (`closeEditor`) et en cas d'échec (`saveFile`). Vérifié : `tests/frontend/editor-inline.test.mjs` 29/29 (+4), validate-imports 38 modules / 0 erreur. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-17 | #101 | Feature | `frontend/editor-poc.html`, `frontend/js/sync.js`, `frontend/js/viewer.js`, `frontend/js/utils.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/editor-inline.test.mjs`, `docs/features/forge-assistant.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md` | **#101** : le bouton « AI Panel » de Forge ouvre désormais l'**Assistant IA** partagé (`postMessage forge-open-ai` → `bookslm.openForCurrentContext()`) au lieu du mini-chat isolé (supprimé) ; Forge lit `localStorage['obsigate_ai_picker']` (`aiPickerSelection()`) pour ses appels `/api/ai/*` et sa complétion fantôme (repli `ollama`), endpoints corrigés (`make-longer`/`make-shorter`, `target_lang`) ; bouton **plein écran** natif ajouté à Forge (iframe `allow="fullscreen"`) et à Editer (`#editor-fullscreen`, conteneur `#editor-container`, sortie à la fermeture, Échap laissé au navigateur) ; i18n `editor.fullscreen`/`editor.exit_fullscreen`. Vérifié : `tests/frontend/editor-inline.test.mjs` 40/40 (+10), unit 9/9, validate-imports 38 modules, 13 suites JSDOM vertes. | 🟢 livré (en attente vérif utilisateur) |
---
+2 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.7.2 | **Dernière mise à jour :** 2026-09-16
> **Version :** 2.8.0 | **Dernière mise à jour :** 2026-09-17
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
@@ -198,6 +198,7 @@
| 98 | Assistant IA — Filtre de recherche dans la sidebar « Historique IA » | 2.5.0 | [features/ai-assistant-history.md](./features/ai-assistant-history.md) |
| 99 | Sidebar — Filtrage des vues « Récents » et « Sauvegardes » | 2.6.0 | [features/sidebar-filters.md](./features/sidebar-filters.md) |
| 100 | Assistant IA — Deep Research en pastille (au lieu du texte injecté) | 2.6.0 | [features/ai-assistant-history.md](./features/ai-assistant-history.md) |
| 101 | Forge — Assistant IA partagé (bouton AI Panel = assistant, fournisseur/modèle configuré, autocomplétion) + plein écran Forge/Editer | 2.8.0 | [features/forge-assistant.md](./features/forge-assistant.md) |
---
+1 -1
View File
@@ -17,7 +17,7 @@
## B. Function calling in-app (3-4 jours) — ✅ livré (2026-09-11)
- [x] **B1.** Abstraction tool-calling provider-agnostique : `backend/ai_chat.py` (`chat_completion`, `ToolCall`, `LLMResponse`) — OpenAI-compat (`tools`/`tool_choice`, parsing `tool_calls`) + Gemini (`functionDeclarations`/`functionCall`)
- [x] **B2.** Agent loop `backend/agent/loop.py` : boucle tool→résultat→tool, limite d'itérations (10), truncation des résultats ; endpoint opt-in `POST /api/ai/bookslm/agent` (events SSE `tool`/`message`/`confirmation`)
- [x] **B3.** Fallback : retry sans `tools` si le provider rejette les tools (400/404/422) → chat simple ; protocole texte `obsigate-action` conservé côté frontend
- [x] **B3.** Fallback : retry sans `tools` si le provider rejette les tools (400/404/422) → chat simple ; protocole texte `obsigate-action` conservé côté frontend **pour le chat classique uniquement** (BUG-053 : en mode agent, le prompt impose les outils natifs et interdit les blocs `obsigate-action`)
- [x] **B4.** SSE réellement streaming — `ai_chat.stream_completion` (`_openai_stream` + `_gemini_stream`) alimente `/api/ai/bookslm/chat` token par token ; le middleware GZip laisse passer les endpoints SSE BooksLM.
- [x] **B5.** Confirmations UI : toggle « mode agent » (front → `/agent`), événements `tool`/`confirmation`, carte Apply + aperçu diff (LCS) pour les mutations, reprise `confirm`/`confirm_messages` côté backend. *S'active dès que la phase D enregistre des outils `write`.*
- [x] **B6.** Outils de navigation in-app : `open_file`, `reveal_in_tree` (événement `obsigate:open-file`) — livré via les liens cliquables de l'assistant (#80, [ai-assistant-ux.md](./ai-assistant-ux.md))
+5
View File
@@ -80,6 +80,11 @@ audit) et **jamais** avec un token en dur :
- Livré : la note intermédiaire du modèle devient une étape visible.
- Extension possible : exposer les itérations de la boucle (`iterations`) comme
étapes de planification quand un outil de plan est ajouté.
- **Garantie de réponse finale — ✅ livré (BUG-052)** : à l'épuisement du budget
d'itérations ou du quota d'appels d'outils, `_finalize_answer` déclenche un
dernier appel LLM **sans outil** (instruction de synthèse) ; un repli
déterministe liste les sources si cet appel échoue. Une recherche web ne peut
plus se terminer sur une conversation sans texte.
## 4. Transverse — à faire avec la phase 2
+86
View File
@@ -0,0 +1,86 @@
# #101 - Forge : Assistant IA partagé + plein écran (Forge & Editer)
> **Statut :** 🟢 livré (en attente de vérification utilisateur)
> **Version :** 2.8.0
> **Composants :** `frontend/editor-poc.html`, `frontend/js/sync.js`,
> `frontend/js/viewer.js`, `frontend/js/utils.js`, `frontend/index.html`,
> `frontend/style.css`, `frontend/locales/{fr,en}.json`
> **Tests :** `tests/frontend/editor-inline.test.mjs` (+10)
## Contexte
L'éditeur **Forge** (`frontend/editor-poc.html`, iframe même origine) embarque son propre
mini-panneau « AI Panel » : un chat rudimentaire qui appelait `/api/ai/improve` **sans
fournisseur ni modèle** (donc toujours le défaut serveur), sans historique, sans streaming,
sans commandes `/` ni contexte `@`. L'éditeur **Editer** (CodeMirror) n'avait aucun bouton
plein écran, et Forge non plus.
L'utilisateur veut :
1. que le bouton « AI Panel » de Forge affiche **le même contenu** que le panneau
**Assistant IA** (fournisseur/modèle, historique, skills `/`, contexte `@`) ;
2. que Forge utilise le **fournisseur et le modèle configurés** dans l'Assistant IA
(actions IA, autocomplétion fantôme) ;
3. un bouton **plein écran** dans Forge **et** dans Editer.
## Conception
### 1. Forge ouvre l'Assistant IA existant (pas de duplication)
`bookslm.js` est un singleton monté sur le document parent, couplé à `state`, `TabManager`,
`AuthManager` et au CSS global : le porter dans l'iframe serait une duplication lourde à
maintenir. Forge étant une **iframe même origine**, son bouton AI se contente de demander au
parent d'ouvrir l'assistant :
| Côté | Mécanisme |
|---|---|
| Iframe | `openAssistant()` → `parent.postMessage({ type: 'forge-open-ai' }, '*')` (`#btn-ai`, `Ctrl+J`) |
| Parent (`sync.js`) | sur `forge-open-ai` : `import('./bookslm.js')` → `openForCurrentContext()` |
Le mini-panneau Forge (`#ai-panel`, `sendAIChat`, suggestions) est **supprimé** : le contenu,
le fournisseur/modèle, l'historique, les menus `/` et `@` sont ceux de l'assistant, sans
double maintenance.
### 2. Fournisseur/modèle partagé
Le sélecteur de l'assistant persiste son choix dans `localStorage['obsigate_ai_picker']`
(`{provider, model}`), clé **partagée** par l'iframe (même origine). Forge la lit via
`aiPickerSelection()` et l'injecte :
- dans `aiCall()` (actions IA du menu `/` et de la bulle de sélection) ;
- dans la **complétion fantôme** (`/api/ai/inline-complete`) — repli `ollama` si aucun
fournisseur n'est sélectionné (comportement local conservé).
Les noms d'endpoints erronés sont corrigés au passage : `make-longer` / `make-shorter`
(au lieu de `lengthen` / `simplify`) et `target_lang` pour la traduction.
### 3. Plein écran natif
- **Forge** : bouton `#btn-fullscreen` dans la barre, `document.documentElement.requestFullscreen()`
(l'iframe reçoit `allow="fullscreen"` côté parent, `viewer.js`), icône basculée sur
`fullscreenchange`.
- **Editer** : bouton `#editor-fullscreen` dans l'en-tête ; plein écran sur le conteneur
`#editor-container` (`getEditorContainer()`, fonctionne en mode modale **et** inline),
styles `:fullscreen` (`width/height: 100vw/100vh`), icône/label basculés, sortie du plein
écran à la fermeture (`closeEditor`) et Échap laissé au navigateur pendant le plein écran.
Libellés i18n `editor.fullscreen` / `editor.exit_fullscreen` (FR + EN).
## Fichiers
| Fichier | Modification |
|---|---|
| `frontend/editor-poc.html` | suppression du mini-panneau AI ; `openAssistant()` (postMessage) ; `aiPickerSelection()` injecté dans `aiCall`/complétion fantôme ; `AI_MAP` corrigé ; bouton + logique plein écran |
| `frontend/js/sync.js` | routage `forge-open-ai` → `bookslm.openForCurrentContext()` |
| `frontend/js/viewer.js` | `allow="fullscreen"` sur `#forge-iframe` |
| `frontend/index.html` | bouton `#editor-fullscreen` (titre i18n) |
| `frontend/js/utils.js` | `toggleEditorFullscreen` / `updateFullscreenButton` / `isEditorFullscreen` ; sortie du plein écran dans `closeEditor` |
| `frontend/style.css` | `.editor-container:fullscreen` |
| `frontend/locales/{fr,en}.json` | `editor.fullscreen`, `editor.exit_fullscreen` |
## Tests
`tests/frontend/editor-inline.test.mjs` (+10) : suppression du mini-panneau, postMessage
`forge-open-ai`, routage `sync.js`, lecture de `obsigate_ai_picker`, endpoints corrigés,
complétion fantôme, boutons plein écran (Forge + Editer), `allow="fullscreen"`, CSS
`:fullscreen`, clés i18n.
+65 -88
View File
@@ -100,27 +100,6 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
.preview-content img { max-width: 100%; border-radius: var(--r); }
.preview-loading { text-align: center; padding: 40px; color: var(--text3); font-size: 13px; }
/* AI Panel (right) */
.ai-panel { width: 320px; min-width: 320px; background: var(--bg2); border-left: 1px solid var(--border); display: flex; flex-direction: column; transition: transform 200ms, min-width 200ms, width 200ms; overflow: hidden; }
.ai-panel.hidden { transform: translateX(100%); min-width: 0; width: 0; border-left: none; }
.ai-panel-head { padding: 12px 14px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
.ai-panel-title { font-weight: 700; font-size: 13px; color: var(--ai); }
.ai-panel-close { width: 26px; height: 26px; border-radius: var(--r); border: none; background: transparent; color: var(--text3); cursor: pointer; font-size: 15px; }
.ai-panel-close:hover { background: var(--bg-hover); color: var(--text); }
.ai-chat { flex: 1; padding: 14px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; }
.ai-suggestions { padding: 10px 14px; border-top: 1px solid var(--border); }
.ai-sugg { display: block; width: 100%; text-align: left; padding: 7px 10px; margin-bottom: 3px; border-radius: var(--r); border: none; background: transparent; color: var(--text2); cursor: pointer; font-size: 12px; font-family: var(--sans); }
.ai-sugg:hover { background: var(--bg-hover); color: var(--text); }
.ai-input-row { padding: 8px 14px; border-top: 1px solid var(--border); display: flex; gap: 6px; }
.ai-input { flex: 1; padding: 7px 10px; border-radius: var(--r); border: 1px solid var(--border); background: var(--bg3); color: var(--text); font-size: 12px; outline: none; font-family: var(--sans); }
.ai-input:focus { border-color: var(--ai); }
.ai-send { padding: 7px 12px; border-radius: var(--r); border: none; background: var(--ai); color: #fff; cursor: pointer; font-size: 12px; font-weight: 600; }
.ai-msg { padding: 8px 11px; border-radius: var(--r); font-size: 12px; line-height: 1.5; max-width: 90%; white-space: pre-wrap; }
.ai-msg.user { background: var(--bg3); align-self: flex-end; }
.ai-msg.bot { background: var(--ai-bg); color: var(--ai); align-self: flex-start; }
.ai-msg.loading { color: var(--text3); font-style: italic; align-self: flex-start; }
.ai-chat-placeholder { color: var(--text3); font-size: 12px; padding: 10px; }
/* Slash menu */
.slash { position: fixed; z-index: 200; width: 300px; max-height: 360px; overflow-y: auto; background: var(--bg4); border: 1px solid var(--border2); border-radius: var(--R); box-shadow: var(--shadow); display: none; }
.slash.on { display: block; animation: fadeIn 120ms; }
@@ -264,7 +243,8 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
<div class="bar-right">
<button class="btn" id="btn-help" title="Aide / Raccourcis (F1)">&#63;</button>
<button class="btn" id="btn-preview" title="Toggle preview">&#128065;</button>
<button class="btn btn-ai off" id="btn-ai" title="AI Panel (Ctrl+J)">&#10024;</button>
<button class="btn btn-ai off" id="btn-ai" title="Assistant IA (Ctrl+J)">&#10024;</button>
<button class="btn" id="btn-fullscreen" title="Plein écran (F11)" aria-label="Plein écran"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg></button>
<div class="save-dot ok" id="save-dot"><span class="dot"></span><span id="save-label">Saved</span></div>
</div>
</div>
@@ -380,33 +360,13 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
<div class="bubble-more-item" data-action="ai-continue">&#10133; Continue writing</div>
</div>
</div>
<!-- AI Panel -->
<div class="ai-panel hidden" id="ai-panel">
<div class="ai-panel-head">
<span class="ai-panel-title">&#10024; AI Assistant</span>
<button class="ai-panel-close" id="ai-close">&times;</button>
</div>
<div class="ai-chat" id="ai-chat">
<div class="ai-chat-placeholder">Ask AI about your document. Use slash /ai or select text for AI actions.</div>
</div>
<div class="ai-suggestions">
<button class="ai-sugg">&#10024; Improve overall style</button>
<button class="ai-sugg">&#10133; Add a conclusion</button>
<button class="ai-sugg">&#128221; Summarize document</button>
</div>
<div class="ai-input-row">
<input class="ai-input" id="ai-input" placeholder="Ask AI...">
<button class="ai-send" id="ai-send">Send</button>
</div>
</div>
</div>
<!-- Status bar -->
<div class="stat">
<div class="stat-left">
<span>Type <strong>/</strong> for commands | <strong>Alt+\</strong> autocomplete | <strong>Alt+I</strong> Insert</span>
<span>Ctrl+J AI panel</span>
<span>Ctrl+J Assistant IA</span>
<span>Ctrl+S save</span>
<span>Ctrl+K link</span>
<span class="stat-ai" id="stat-ai">&#10024; AI processing...</span>
@@ -453,7 +413,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
<table>
<tr><td>Ctrl+S</td><td>Sauvegarder</td></tr>
<tr><td>Clic sur le titre</td><td>Renommer le fichier</td></tr>
<tr><td>Ctrl+J</td><td>Panneau AI</td></tr>
<tr><td>Ctrl+J</td><td>Assistant IA</td></tr>
<tr><td>Alt+\</td><td>Autocompletion intelligente</td></tr>
<tr><td>Enter (liste)</td><td>Continue la liste (-, *, 1.) et checkboxes</td></tr>
<tr><td>Enter (liste vide)</td><td>Termine la liste</td></tr>
@@ -494,7 +454,6 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
var bubble = document.getElementById('bubble');
var bubbleMore = document.getElementById('bubble-more-menu');
var qiMenu = document.getElementById('qi-menu');
var aiPanel = document.getElementById('ai-panel');
var btnAI = document.getElementById('btn-ai');
var btnPreview = document.getElementById('btn-preview');
var saveDot = document.getElementById('save-dot');
@@ -699,6 +658,21 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
}
// ---- AI calls ----
// #101 — Use the provider/model selected in the AI Assistant. The picker
// stores its choice in the same-origin `localStorage` key shared with the
// parent app (`obsigate_ai_picker`), so Forge AI actions follow the assistant.
function aiPickerSelection() {
try {
var raw = localStorage.getItem('obsigate_ai_picker');
if (!raw) return {};
var p = JSON.parse(raw);
var out = {};
if (p && p.provider) out.provider = p.provider;
if (p && p.model) out.model = p.model;
return out;
} catch (e) { return {}; }
}
function aiCall(endpoint, text, extra, callback) {
if (!aiConfigured) {
// Check status first, then retry
@@ -712,6 +686,8 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
}
showAIProcessing();
var body = { text: text };
var pick = aiPickerSelection();
Object.keys(pick).forEach(function(k) { body[k] = pick[k]; });
if (extra) Object.keys(extra).forEach(function(k) { body[k] = extra[k]; });
fetch('/api/ai/' + endpoint, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -739,10 +715,10 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
function updateAIButton() {
if (aiConfigured) {
btnAI.className = 'btn btn-ai'; btnAI.title = 'AI Panel (Ctrl+J)';
btnAI.className = 'btn btn-ai'; btnAI.title = 'Assistant IA (Ctrl+J)';
document.getElementById('stat-ai-ready').style.display = 'inline';
} else {
btnAI.className = 'btn btn-ai off'; btnAI.title = 'AI not configured';
btnAI.className = 'btn btn-ai off'; btnAI.title = 'Assistant IA non configure';
document.getElementById('stat-ai-ready').style.display = 'none';
}
}
@@ -756,9 +732,9 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
'ai-fix': { ep: 'fix-spelling', desc: 'fix spelling' },
'ai-continue': { ep: 'continue', desc: 'continue writing' },
'ai-summarize': { ep: 'summarize', desc: 'summarize' },
'ai-translate': { ep: 'translate', extra: { language: 'en' }, desc: 'translate' },
'ai-shorter': { ep: 'simplify', desc: 'make shorter' },
'ai-longer': { ep: 'lengthen', desc: 'make longer' },
'ai-translate': { ep: 'translate', extra: { target_lang: 'en' }, desc: 'translate' },
'ai-shorter': { ep: 'make-shorter', desc: 'make shorter' },
'ai-longer': { ep: 'make-longer', desc: 'make longer' },
};
function execAIAction(actionKey) {
@@ -1173,36 +1149,38 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
showQI();
}
// ---- AI Panel ----
function toggleAIPanel() {
aiPanel.classList.toggle('hidden');
btnAI.classList.toggle('active', !aiPanel.classList.contains('hidden'));
// ---- AI Assistant (#101) ----
// The rich assistant (provider/model picker, streaming, `/` skills, `@`
// context, history) lives in the parent application. Forge is a same-origin
// iframe, so the AI button simply asks the parent to open it — no duplicated
// panel, and the configured provider/model is shared automatically.
function openAssistant() {
try {
window.parent.postMessage({ type: 'forge-open-ai' }, '*');
} catch (e) { /* not embedded */ }
}
function sendAIChat() {
var input = document.getElementById('ai-input');
var chat = document.getElementById('ai-chat');
var text = input.value.trim();
if (!text) return;
input.value = '';
// ---- Fullscreen (#101) ----
var btnFullscreen = document.getElementById('btn-fullscreen');
var FS_ENTER = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>';
var FS_EXIT = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/></svg>';
var um = document.createElement('div'); um.className = 'ai-msg user'; um.textContent = text;
chat.appendChild(um);
var lm = document.createElement('div'); lm.className = 'ai-msg loading';
lm.innerHTML = '<span class="spin"></span>Thinking...';
chat.appendChild(lm);
chat.scrollTop = chat.scrollHeight;
aiCall('improve', text, null, function(result) {
lm.remove();
var bm = document.createElement('div'); bm.className = 'ai-msg bot';
bm.textContent = result || '(no response)';
chat.appendChild(bm);
chat.scrollTop = chat.scrollHeight;
});
function toggleFullscreen() {
if (!document.fullscreenElement) {
var req = document.documentElement.requestFullscreen && document.documentElement.requestFullscreen();
if (req && req.catch) req.catch(function() { showToast('Plein écran indisponible', 'err'); });
} else if (document.exitFullscreen) {
document.exitFullscreen();
}
}
document.addEventListener('fullscreenchange', function() {
var on = !!document.fullscreenElement;
btnFullscreen.innerHTML = on ? FS_EXIT : FS_ENTER;
btnFullscreen.title = on ? 'Quitter le plein écran' : 'Plein écran (F11)';
btnFullscreen.classList.toggle('active', on);
});
// ---- Drag & Drop ----
var dragC = 0;
document.addEventListener('dragenter', function(e) { e.preventDefault(); dragC++; if (dragC === 1) dropOverlay.classList.add('on'); });
@@ -1292,7 +1270,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
var before = l.text.substring(0, p.s - l.start);
if (before === '' || /^\s*$/.test(before)) setTimeout(showSlash, 20);
}
if (e.ctrlKey && e.key === 'j') { e.preventDefault(); toggleAIPanel(); }
if (e.ctrlKey && e.key === 'j') { e.preventDefault(); openAssistant(); }
if (e.ctrlKey && e.key === 's') { e.preventDefault(); forceSave(); }
if (e.ctrlKey && e.key === 'b') { e.preventDefault(); wrapSelection('**'); }
if (e.ctrlKey && e.key === 'i') { e.preventDefault(); wrapSelection('*'); }
@@ -1433,16 +1411,8 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
if (langPicker.classList.contains('on') && !langPicker.contains(e.target)) { langPicker.classList.remove('on'); ta.focus(); }
});
btnAI.addEventListener('click', toggleAIPanel);
document.getElementById('ai-close').addEventListener('click', toggleAIPanel);
document.getElementById('ai-send').addEventListener('click', sendAIChat);
document.getElementById('ai-input').addEventListener('keydown', function(e) { if (e.key === 'Enter') sendAIChat(); });
document.querySelectorAll('.ai-sugg').forEach(function(b) {
b.addEventListener('click', function() {
document.getElementById('ai-input').value = b.textContent.trim();
sendAIChat();
});
});
btnAI.addEventListener('click', openAssistant);
btnFullscreen.addEventListener('click', toggleFullscreen);
btnPreview.addEventListener('click', togglePreview);
@@ -1852,10 +1822,17 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
prompt = 'Continue this ' + langName + ' text naturally. Return ONLY the new text (do NOT repeat):\n' + fullText;
}
var ghostBody = { text: prompt };
// #101 — follow the AI Assistant's configured provider/model; fall back to
// the local Ollama model when the assistant has no explicit selection.
var ghostPick = aiPickerSelection();
ghostBody.provider = ghostPick.provider || 'ollama';
if (ghostPick.model) ghostBody.model = ghostPick.model;
fetch('/api/ai/inline-complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: prompt, provider: 'ollama' })
body: JSON.stringify(ghostBody)
})
.then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function(data) {
@@ -2000,7 +1977,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
console.log('ObsiGate Editor v2 ready');
console.log(' File: ' + (fileVault ? fileVault + '/' + filePath : 'demo mode'));
console.log(' AI: checking...');
console.log(' Type / for commands | Select text for bubble | Alt+\\ autocomplete | Ctrl+J AI | Ctrl+S save');
console.log(' Type / for commands | Select text for bubble | Alt+\\ autocomplete | Ctrl+J Assistant IA | Ctrl+S save');
})();
</script>
+9
View File
@@ -1436,6 +1436,15 @@
><span class="dot"></span
><span id="editor-save-label">Saved</span></span
>
<button
class="editor-btn"
id="editor-fullscreen"
title="Plein écran"
aria-label="Plein écran"
data-i18n-attr="title:editor.fullscreen"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>
</button>
<button
class="editor-btn danger"
id="editor-delete"
+10
View File
@@ -645,5 +645,15 @@ export function init() {
// (inline mode) and re-renders the document read view.
closeEditor();
}
if (e.data.type === 'forge-open-ai') {
// #101 — Forge has no rich AI panel of its own: its AI button asks the
// parent to open the shared AI Assistant (same provider/model, skills,
// context and history).
import('./bookslm.js').then(function(m) {
if (m && m.default) m.default.openForCurrentContext();
}).catch(function(err) {
console.warn('[Forge] Unable to open AI assistant', err);
});
}
});
}
+79 -2
View File
@@ -1,5 +1,6 @@
import { state } from './state.js';
import { api } from './auth.js';
import { t } from './i18n.js';
import { openFile, showWelcome } from './viewer.js';
import { refreshSidebarForContext, refreshTagsForContext } from './sidebar.js';
import { createAIToolbar } from './ai.js';
@@ -365,6 +366,9 @@ function escapeHtml(str) {
async function openEditor(vaultName, filePath) {
state.editorVault = vaultName;
state.editorPath = filePath;
// A previous session may have left the shared save button in its spinner
// state (manual save, Forge, failed request). BUG-054.
resetSaveButton();
const modal = document.getElementById("editor-modal");
const titleInput = document.getElementById("editor-title-input");
@@ -582,6 +586,12 @@ function closeEditor() {
const modal = document.getElementById("editor-modal");
if (!modal) return;
modal.classList.remove("active");
resetSaveButton();
// Leaving the editor must also leave native fullscreen. #101
if (isEditorFullscreen() && document.exitFullscreen) {
const fsPromise = document.exitFullscreen();
if (fsPromise && fsPromise.catch) fsPromise.catch(() => {});
}
stopCollab();
if (state.editorView) {
state.editorView.destroy();
@@ -683,6 +693,26 @@ async function reloadExternalWrite(vault, path) {
}
}
/**
* Restore the save button to its idle state (checkmark, enabled).
*
* The button (`#editor-save`) is a single shared DOM node reused across every
* edition session, including inline mode where it travels with the editor
* container. A manual save swaps its content for a spinner and disables it; if
* that state is not cleared on success/failure, the spinner leaks into the next
* session and only a full page reload (which re-parses index.html) brings the
* checkmark back. BUG-054.
*/
function resetSaveButton() {
const saveBtn = document.getElementById("editor-save");
if (!saveBtn) return;
saveBtn.disabled = false;
saveBtn.innerHTML = '&#10003;';
saveBtn.style.background = '';
saveBtn.style.color = '';
saveBtn.style.borderColor = '';
}
async function saveFile(silent = false) {
// If Forge is open, delegate save to the iframe
var forgeFrame = document.getElementById("forge-iframe");
@@ -750,6 +780,7 @@ async function saveFile(silent = false) {
}
} catch (err) {
console.error("Save error:", err);
resetSaveButton();
if (saveDot) { saveDot.className = 'editor-save-dot err'; }
if (saveLabel) saveLabel.textContent = 'Erreur';
// If offline, queue the save for later sync
@@ -807,19 +838,65 @@ async function deleteFile() {
}
}
// ---------------------------------------------------------------------------
// Fullscreen (#101)
// ---------------------------------------------------------------------------
const FS_ENTER_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>';
const FS_EXIT_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/></svg>';
/** True when the editor container currently owns the fullscreen viewport. */
function isEditorFullscreen() {
if (!document.fullscreenElement) return false;
const container = getEditorContainer();
return !!(container && (document.fullscreenElement === container || container.contains(document.fullscreenElement)));
}
/** Toggle native fullscreen on the editor container (modal or inline). */
function toggleEditorFullscreen() {
const container = getEditorContainer();
if (!container) return;
if (document.fullscreenElement) {
if (document.exitFullscreen) {
const p = document.exitFullscreen();
if (p && p.catch) p.catch(() => {});
}
return;
}
if (container.requestFullscreen) {
const p = container.requestFullscreen();
if (p && p.catch) p.catch(() => {});
}
}
/** Sync the fullscreen button icon/label with the current fullscreen state. */
function updateFullscreenButton() {
const btn = document.getElementById("editor-fullscreen");
if (!btn) return;
const on = isEditorFullscreen();
btn.innerHTML = on ? FS_EXIT_SVG : FS_ENTER_SVG;
const label = t(on ? "editor.exit_fullscreen" : "editor.fullscreen");
btn.title = label;
btn.setAttribute("aria-label", label);
btn.classList.toggle("active", on);
}
function initEditor() {
const cancelBtn = document.getElementById("editor-cancel");
const deleteBtn = document.getElementById("editor-delete");
const saveBtn = document.getElementById("editor-save");
const fullscreenBtn = document.getElementById("editor-fullscreen");
const modal = document.getElementById("editor-modal");
cancelBtn.addEventListener("click", closeEditor);
deleteBtn.addEventListener("click", deleteFile);
saveBtn.addEventListener("click", () => saveFile());
if (fullscreenBtn) fullscreenBtn.addEventListener("click", toggleEditorFullscreen);
document.addEventListener("fullscreenchange", updateFullscreenButton);
// ESC to close
// ESC to close — unless we are in native fullscreen, where Escape exits
// fullscreen first (the editor must stay open). #101
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && modal.classList.contains("active")) {
if (e.key === "Escape" && !document.fullscreenElement && modal.classList.contains("active")) {
closeEditor();
}
});
+3
View File
@@ -781,6 +781,9 @@ export function renderFile(data) {
var iframe = document.createElement("iframe");
iframe.id = "forge-iframe";
iframe.src = "/editor-poc?vault=" + encodeURIComponent(data.vault) + "&path=" + encodeURIComponent(data.path) + "&_ts=" + Date.now();
// #101 — allow the Forge editor's own Fullscreen button to work.
iframe.setAttribute("allow", "fullscreen");
iframe.setAttribute("allowfullscreen", "");
// Inline: the iframe fills the content area; overlay: the original 82vh.
iframe.style.cssText = "width:100%;height:" + (inline ? "100%" : "82vh") + ";border:none;display:block;";
bodyEl.appendChild(iframe);
+2
View File
@@ -654,12 +654,14 @@
"editor.delete_error": "Delete error",
"editor.edit": "Edit current file",
"editor.edit_current_desc": "Open current file in editor",
"editor.exit_fullscreen": "Exit fullscreen",
"editor.find": "Find in file...",
"editor.find_case": "Case sensitive",
"editor.find_regex": "Regex",
"editor.find_whole": "Whole word",
"editor.forge_close_editor": "Close editor / modal",
"editor.forge_help": "Forge editor help",
"editor.fullscreen": "Fullscreen",
"editor.no_results": "No results",
"editor.replace": "Replace",
"editor.save": "Save",
+2
View File
@@ -654,12 +654,14 @@
"editor.delete_error": "Erreur de suppression",
"editor.edit": "Éditer fichier courant",
"editor.edit_current_desc": "Ouvrir le fichier actif dans l'éditeur",
"editor.exit_fullscreen": "Quitter le plein écran",
"editor.find": "Rechercher dans le fichier...",
"editor.find_case": "Respecter la casse",
"editor.find_regex": "Regex",
"editor.find_whole": "Mot entier",
"editor.forge_close_editor": "Fermer l'éditeur / modale",
"editor.forge_help": "Aide de l'éditeur Forge",
"editor.fullscreen": "Plein écran",
"editor.no_results": "Aucun résultat",
"editor.replace": "Remplacer",
"editor.save": "Enregistrer",
+17
View File
@@ -2796,6 +2796,23 @@ select {
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
/* Native fullscreen (#101) — the editor fills the viewport regardless of the
inline/modal constraints above. */
.editor-container:fullscreen {
width: 100vw;
height: 100vh;
max-width: none;
max-height: none;
border: none;
border-radius: 0;
box-shadow: none;
}
.editor-container:fullscreen .editor-body,
.editor-container:fullscreen .editor-body-cm {
flex: 1;
min-height: 0;
}
/* --- Inline edition (#93) --------------------------------------------------
When a document is being edited, `#editor-container` is moved into the
document content area: the editor *replaces* the read view instead of
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.7.2",
"version": "2.8.0",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js",
"directories": {
+96
View File
@@ -356,6 +356,102 @@ test("style.css: #editor-body scrolls through the CodeMirror scroller only", ()
"global .cm-scroller min-height override reintroduces the double scrollbar");
});
// ── BUG-054: the shared save button must not stay stuck on the spinner ──
test("utils.js resetSaveButton restores the checkmark and re-enables the button", () => {
const fn = utilsSrc.match(/function resetSaveButton\(\) \{([\s\S]*?)\n\}/);
assert.ok(fn, "resetSaveButton not found");
assert.match(fn[1], /saveBtn\.disabled = false;/);
assert.match(fn[1], /saveBtn\.innerHTML = '&#10003;'/);
assert.match(fn[1], /saveBtn\.style\.background = '';/);
});
test("utils.js openEditor resets the save button before each session", () => {
const fn = utilsSrc.match(/async function openEditor\(vaultName, filePath\) \{([\s\S]*?)\n\}/);
assert.ok(fn, "openEditor not found");
assert.match(fn[1], /resetSaveButton\(\);/);
});
test("utils.js closeEditor resets the save button on success/cancel/delete", () => {
const fn = utilsSrc.match(/function closeEditor\(\) \{([\s\S]*?)\n\}/);
assert.ok(fn, "closeEditor not found");
assert.match(fn[1], /resetSaveButton\(\);/);
});
test("utils.js saveFile restores the save button when the request fails", () => {
assert.match(utilsSrc, /console\.error\("Save error:", err\);\s*\n\s*resetSaveButton\(\);/);
});
// ── #101: Forge uses the shared AI assistant + native fullscreen ──
test("editor-poc.html opens the shared AI assistant from its AI button", () => {
assert.ok(!/id="ai-panel"/.test(forgeSrc), "Forge mini AI panel markup must be gone");
assert.match(forgeSrc, /postMessage\(\{ type: 'forge-open-ai' \}, '\*'\)/);
assert.match(forgeSrc, /btnAI\.addEventListener\('click', openAssistant\)/);
});
test("sync.js routes forge-open-ai to the BooksLM assistant", () => {
const handler = syncSrc.match(/if \(e\.data\.type === 'forge-open-ai'\) \{([\s\S]*?)\n \}/);
assert.ok(handler, "forge-open-ai handler not found");
assert.match(handler[1], /import\('\.\/bookslm\.js'\)/);
assert.match(handler[1], /openForCurrentContext\(\)/);
});
test("editor-poc.html AI calls use the assistant's configured provider/model", () => {
const pick = forgeSrc.match(/function aiPickerSelection\(\) \{([\s\S]*?)\n \}/);
assert.ok(pick, "aiPickerSelection not found");
assert.match(pick[1], /obsigate_ai_picker/);
assert.match(forgeSrc, /var pick = aiPickerSelection\(\);/);
assert.match(forgeSrc, /Object\.keys\(pick\)\.forEach/);
});
test("editor-poc.html fixes AI endpoint names and translate param", () => {
assert.match(forgeSrc, /'ai-longer':\s*\{ ep: 'make-longer'/);
assert.match(forgeSrc, /'ai-shorter':\s*\{ ep: 'make-shorter'/);
assert.match(forgeSrc, /'ai-translate':\s*\{ ep: 'translate', extra: \{ target_lang: 'en' \}/);
});
test("editor-poc.html ghost completion follows the configured provider", () => {
assert.match(forgeSrc, /ghostBody\.provider = ghostPick\.provider \|\| 'ollama';/);
assert.match(forgeSrc, /ghostBody\.model = ghostPick\.model;/);
});
test("editor-poc.html has a native fullscreen button", () => {
assert.match(forgeSrc, /id="btn-fullscreen"/);
assert.match(forgeSrc, /document\.documentElement\.requestFullscreen/);
assert.match(forgeSrc, /document\.addEventListener\('fullscreenchange'/);
});
test("viewer.js allows fullscreen in the Forge iframe", () => {
assert.match(viewerSrc, /iframe\.setAttribute\("allow", "fullscreen"\)/);
});
test("index.html has an editor fullscreen button with i18n title", () => {
assert.match(indexSrc, /id="editor-fullscreen"/);
assert.match(indexSrc, /data-i18n-attr="title:editor\.fullscreen"/);
});
test("utils.js wires the editor fullscreen button and exits on close", () => {
assert.match(utilsSrc, /function toggleEditorFullscreen\(\)/);
assert.match(utilsSrc, /function updateFullscreenButton\(\)/);
assert.match(utilsSrc, /document\.addEventListener\("fullscreenchange", updateFullscreenButton\)/);
const close = utilsSrc.match(/function closeEditor\(\) \{([\s\S]*?)\n\}/);
assert.ok(close, "closeEditor not found");
assert.match(close[1], /document\.exitFullscreen\(\)/);
});
test("style.css makes the editor fill the screen in fullscreen", () => {
assert.match(cssSrc, /\.editor-container:fullscreen \{/);
assert.match(cssSrc, /\.editor-container:fullscreen \{[\s\S]*?height: 100vh;/);
});
test("locales expose the fullscreen labels (FR + EN)", () => {
const fr = JSON.parse(read("frontend", "locales", "fr.json"));
const en = JSON.parse(read("frontend", "locales", "en.json"));
for (const key of ["editor.fullscreen", "editor.exit_fullscreen"]) {
assert.ok(fr[key], `fr.json missing ${key}`);
assert.ok(en[key], `en.json missing ${key}`);
}
});
console.log(`\n${testCount - failCount}/${testCount} tests passed`);
if (failCount > 0) {
console.error(`${failCount} test(s) failed`);
+34
View File
@@ -182,6 +182,40 @@ class TestConfirmationAndLimits:
assert result.iterations == 2
assert len(result.tool_calls) == 2
@pytest.mark.asyncio
async def test_max_iterations_synthesizes_final_answer(self, monkeypatch):
"""BUG-052: exhausting the budget must still produce an answer."""
_register(monkeypatch, "_echo", lambda ctx, params: {"value": 1})
llm = ScriptedLLM([
LLMResponse(tool_calls=[ToolCall(id="1", name="_echo", arguments={})]),
LLMResponse(tool_calls=[ToolCall(id="2", name="_echo", arguments={})]),
LLMResponse(content="synthèse finale"),
])
result = await run_agent(
[{"role": "user", "content": "loop"}], ctx=_ctx(), llm=llm, max_iterations=2
)
assert result.stopped == STOP_MAX_ITERATIONS
assert result.content == "synthèse finale"
# The last call is tool-less and carries the synthesis instruction.
assert llm.calls[-1]["tools"] == []
assert "N'appelle plus aucun outil" in llm.calls[-1]["messages"][-1]["content"]
@pytest.mark.asyncio
async def test_max_iterations_falls_back_to_sources(self, monkeypatch):
"""An empty/failed synthesis still returns the gathered sources."""
_register(monkeypatch, "_search", lambda ctx, params: {
"results": [{"title": "T", "url": "https://ex.dev/a"}]
})
llm = ScriptedLLM([
LLMResponse(tool_calls=[ToolCall(id="1", name="_search", arguments={})]),
LLMResponse(content=""),
])
result = await run_agent(
[{"role": "user", "content": "loop"}], ctx=_ctx(), llm=llm, max_iterations=1
)
assert result.stopped == STOP_MAX_ITERATIONS
assert "https://ex.dev/a" in result.content
# ═══════════════════════════════════════════════════════════════════
# Permissions (index-backed)
+41
View File
@@ -729,6 +729,23 @@ class TestGeneralPrompt:
prompt = build_general_system_prompt(["Alpha"])
assert "Contexte applicatif actuel" not in prompt
def test_general_prompt_agent_uses_native_tools(self):
"""BUG-053: the agent must call tools, not emit `obsigate-action` blocks."""
from backend.bookslm import build_general_system_prompt
prompt = build_general_system_prompt(["Alpha"], agent=True)
assert "create_file" in prompt
# The text-protocol example block must not be taught in agent mode.
assert '"action": "create_file"' not in prompt
assert "inclus un bloc de ce type" not in prompt
def test_general_prompt_classic_keeps_text_protocol(self):
"""The classic chat endpoint still uses the text action protocol."""
from backend.bookslm import build_general_system_prompt
prompt = build_general_system_prompt(["Alpha"])
assert '"action": "create_file"' in prompt
def test_documents_prompt_scope(self):
from backend.bookslm import build_system_prompt
@@ -899,6 +916,30 @@ class TestBooksLMAgentEndpoint:
)
assert resp.status_code == 401
def test_agent_prompt_uses_native_tools_not_text_protocol(self, bookslm_client, monkeypatch):
"""BUG-053: the agent system prompt must not teach the text protocol."""
import backend.bookslm_routes as routes
from backend.ai_chat import LLMResponse
captured = {}
async def fake_chat_completion(messages, **kwargs):
captured["system"] = messages[0]["content"]
return LLMResponse(content="ok")
monkeypatch.setattr(routes, "chat_completion", fake_chat_completion)
monkeypatch.setattr(routes, "_resolve_provider_name", lambda requested: "deepseek")
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.post(
"/api/ai/bookslm/agent",
json={"directory": "", "message": "cree un fichier", "mode": "general"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
assert "create_file" in captured["system"]
assert '"action": "create_file"' not in captured["system"]
def test_agent_tool_call_flow(self, bookslm_client, monkeypatch):
import backend.bookslm_routes as routes
from backend.ai_chat import LLMResponse, ToolCall