Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26328fadeb | ||
|
|
c0eea526de | ||
|
|
94ea5909f4 | ||
|
|
3758db2861 | ||
|
|
8b09093aca | ||
|
|
61347e0f0b | ||
|
|
3131277b19 | ||
|
|
23a3c147cd | ||
|
|
f02174af57 | ||
|
|
e3434d19ea |
@@ -36,7 +36,9 @@ jobs:
|
||||
run: node tests/frontend/validate-imports.mjs
|
||||
|
||||
- name: Frontend unit tests
|
||||
run: node tests/frontend/unit.test.mjs
|
||||
run: |
|
||||
node tests/frontend/unit.test.mjs
|
||||
node tests/frontend/forge-completion.test.mjs
|
||||
|
||||
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition)
|
||||
run: |
|
||||
|
||||
+170
-1
@@ -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.9.1**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,175 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.9.1] — 2026-09-17
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-058 — Éditeur « Editer » : la barre de numérotation de ligne ne suit pas le thème** :
|
||||
CodeMirror peint son gutter (colonne des numéros de ligne) avec des valeurs claires codées
|
||||
en dur (`#f5f5f5`, bordure `#ddd`), si bien qu'en thème sombre la barre restait gris clair
|
||||
alors que le fond de l'éditeur suivait le thème. Le gutter est désormais dérivé des
|
||||
variables CSS du thème ObsiGate (`color-mix(in srgb, var(--text-primary) …)` pour un fond
|
||||
subtil, `--text-secondary` pour les numéros, `--border` pour la séparation), ce qui le fait
|
||||
suivre tous les thèmes et modes (sombre, clair, contraste élevé, sépia). Fichiers :
|
||||
`frontend/style.css`. Tests : `tests/frontend/editor-inline.test.mjs`.
|
||||
|
||||
---
|
||||
|
||||
## [2.9.0] — 2026-09-17
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#102 — Assistant IA : bouton « Ajouter la section » par bloc de code** : chaque bloc
|
||||
de code d'une réponse de l'assistant (par ex. une section ```markdown```) affiche un
|
||||
bouton discret « Ajouter la section » qui insère **uniquement ce bloc** dans le document
|
||||
ouvert (sans les délimiteurs de code), au lieu de la réponse complète. Fichiers :
|
||||
`frontend/js/bookslm.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`.
|
||||
Tests : `tests/frontend/ai.test.mjs`.
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-057 — Assistant IA : bouton « Ajouter » inopérant dans l'éditeur Forge** : le
|
||||
bouton ne ciblait que `state.editorView` (CodeMirror de « Editer ») et affichait « Aucun
|
||||
document ouvert dans l'éditeur » en Forge. `_insertIntoEditor()` prend désormais en
|
||||
charge les trois surfaces : CodeMirror, l'iframe Forge (délégation par
|
||||
`postMessage({ type: 'parent-insert' })` → `insertAtCursor` dans `editor-poc.html`) et le
|
||||
textarea de repli. Fichiers : `frontend/js/bookslm.js`, `frontend/editor-poc.html`.
|
||||
Tests : `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs`.
|
||||
|
||||
---
|
||||
|
||||
## [2.8.4] — 2026-09-17
|
||||
|
||||
---
|
||||
|
||||
## [2.8.3] — 2026-09-17
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-056 - Éditeur Forge en plein écran : l'Assistant IA s'ouvre en arrière-plan** :
|
||||
le panneau de l'assistant est monté dans le document parent, alors que le plein écran
|
||||
Forge porte sur l'iframe — l'API Fullscreen n'affichant que l'élément plein écran et ses
|
||||
descendants, le panneau restait invisible. Le plein écran est désormais quitté avant
|
||||
d'ouvrir le panneau : côté **parent** (`sync.js`, sur `forge-open-ai` — le plein écran
|
||||
peut appartenir au document parent et non à l'iframe) **et** côté iframe
|
||||
(`editor-poc.html`, `openAssistant`), la demande d'ouverture étant émise une fois la
|
||||
sortie effective. Fichiers : `frontend/editor-poc.html`, `frontend/js/sync.js`.
|
||||
Tests : `tests/frontend/forge-completion.test.mjs` (+1),
|
||||
`tests/frontend/editor-inline.test.mjs` (+1).
|
||||
|
||||
---
|
||||
|
||||
## [2.8.2] — 2026-09-17
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-055 (complément) - Éditeur Forge : une complétion acceptée au `Tab` était
|
||||
supprimée 1–2 s plus tard** : l'auto-sauvegarde (2 s) déclenche un événement SSE
|
||||
`index_updated` sur le fichier en cours, et le parent rechargeait alors le tampon de
|
||||
Forge **depuis le disque** — écrasant une complétion (ou toute frappe) faite après la
|
||||
sauvegarde. Le rechargement SSE est désormais ignoré tant que le tampon local est
|
||||
modifié (`isDirty`) ; seul un écrit externe (assistant IA) force le rechargement.
|
||||
L'auto-sauvegarde ne repasse plus l'état « enregistré » si des modifications sont
|
||||
arrivées pendant la requête (Forge **et** éditeur CodeMirror), et l'acceptation du
|
||||
ghost annule la requête de prédiction en attente. Fichiers : `frontend/editor-poc.html`,
|
||||
`frontend/js/utils.js`. Tests : `tests/frontend/forge-completion.test.mjs` (+3),
|
||||
`tests/frontend/editor-inline.test.mjs` (+1).
|
||||
|
||||
---
|
||||
|
||||
## [2.8.1] — 2026-09-17
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-055 - Éditeur Forge : autocomplétion Tab naturelle et fiable** : la touche `Tab`
|
||||
déclenchait simultanément trois actions indépendantes (indentation, complétion d'un mot du
|
||||
document, acceptation de la prédiction IA), ce qui insérait un ou deux espaces avant le mot
|
||||
complété — et le retour arrière effaçait alors l'ajout. La gestion de `Tab` est désormais
|
||||
**unifiée** avec une priorité claire (liste de suggestions ouverte → prédiction IA →
|
||||
complétion de mot du document → indentation), une seule action par appui. Les helpers de
|
||||
complétion sont extraits en fonctions pures partagées avec CodeMirror
|
||||
(`getWordFragment`, `findWordCompletions`, `normalizeGhost`, `chooseTabAction`) ; plusieurs
|
||||
candidats affichent une liste positionnée au curseur ; la **complétion fantôme** n'affiche
|
||||
plus un miroir transparent de tout le document (source du décalage visuel et des espaces
|
||||
fantômes) mais uniquement la prédiction, positionnée exactement au curseur et nettoyée dès
|
||||
que le curseur bouge ou que la vue défile ; une complétion de mot ne peut plus introduire
|
||||
d'espace. Le prompt `/api/ai/inline-complete` est simplifié et borné à 128 tokens pour des
|
||||
suggestions plus courtes et plus rapides. Fichiers : `frontend/editor-poc.html`,
|
||||
`frontend/js/autocomplete.js`, `backend/ai.py`. Tests :
|
||||
`tests/frontend/forge-completion.test.mjs` (nouveau, 28 tests).
|
||||
|
||||
---
|
||||
|
||||
## [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
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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.9.1).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.7.2 | Dernière mise à jour : Juin 2026*
|
||||
*Projet : ObsiGate | Version : 2.9.1 | Dernière mise à jour : Juin 2026*
|
||||
|
||||
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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.9.1).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.7.2 | Last updated: May 2026*
|
||||
*Project: ObsiGate | Version: 2.9.1 | Last updated: May 2026*
|
||||
|
||||
+85
-17
@@ -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
|
||||
)
|
||||
|
||||
+6
-3
@@ -353,10 +353,13 @@ async def ai_generate_frontmatter(text: str, provider: ProviderName | None = Non
|
||||
|
||||
|
||||
async def ai_inline_complete(text: str, provider: ProviderName | None = None) -> str:
|
||||
"""Inline completion — suggest continuation."""
|
||||
"""Inline completion — suggest a short continuation of the text before the cursor."""
|
||||
return await _call_deepseek_openrouter(
|
||||
f"Complete this text naturally. Return only the completion (just the new text, no repetition):\n\n{text}",
|
||||
SYSTEM_PROMPT, provider, temperature=0.3, max_tokens=512,
|
||||
"Continue the text below in the same language. Reply with ONLY the "
|
||||
"continuation: no repetition, no quotes, no explanation, at most one "
|
||||
"short sentence. If the text ends with a partial word, finish that word.\n\n"
|
||||
+ text,
|
||||
SYSTEM_PROMPT, provider, temperature=0.2, max_tokens=128,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+32
-2
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.7.2"
|
||||
version = "2.9.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.7.2"
|
||||
version = "2.9.1"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -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.9.1",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
+17
-1
@@ -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,13 @@ 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 `✓` + `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 |
|
||||
| *BUG-055* | [🟡 IMPORTANT] Éditeur Forge : l'autocomplétion (Tab) ajoute des espaces parasites, l'effacement détruit le mot complété et la complétion fantôme est illisible | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `frontend/editor-poc.html`, `frontend/js/autocomplete.js`, `backend/ai.py`, `.gitea/workflows/ci.yml`, `tests/frontend/forge-completion.test.mjs` (nouveau) | Forge : taper un mot, puis Tab pour compléter ; un espace (voire deux) s'insère avant le mot complété, et le retour arrière efface l'ajout. La prédiction IA s'affichait décalée (texte miroir du document entier) | **Cause** : trois gestionnaires `keydown` Tab indépendants s'exécutaient tous — l'indentation (`insertAtCursor(' ')`) s'ajoutait à la complétion de mot et à l'acceptation du ghost. **Correctif** : gestion **unifiée** de Tab (`liste ouverte > ghost > mot du document > indentation`, une seule action), helpers purs partagés (`getWordFragment`, `findWordCompletions`, `normalizeGhost`, `chooseTabAction`) dans `autocomplete.js`, liste déroulante si plusieurs candidats, dropdown positionné au curseur, ghost **positionné au curseur** (fini le miroir du document, nettoyé au déplacement/scroll), complétion de mot sans espace garanti (`normalizeGhost` tronque au premier espace) et prompt `/api/ai/inline-complete` simplifié. Tests : `tests/frontend/forge-completion.test.mjs` (28). | Cause du bug : l'indentation Tab n'était pas conditionnée à l'absence de suggestion. Le ghost re-rendait tout le texte transparent + prédiction, d'où l'impression d'espaces et les erreurs d'effacement |
|
||||
| *BUG-056* | [🟡 IMPORTANT] Éditeur Forge en plein écran : l'Assistant IA s'ouvre en arrière-plan et reste invisible | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/editor-poc.html`, `frontend/js/sync.js`, `tests/frontend/forge-completion.test.mjs`, `tests/frontend/editor-inline.test.mjs` | Forge : passer en plein écran puis cliquer le bouton « Assistant IA » (ou `Ctrl+J`) — le panneau s'ouvre dans le document parent, masqué par l'iframe plein écran | Sortie du plein écran **avant** d'ouvrir le panneau, des deux côtés : côté iframe (`openAssistant` → `document.exitFullscreen()` puis `postMessage` à la résolution) **et** côté parent (`sync.js` sur `forge-open-ai` → `document.exitFullscreen()` puis `openForCurrentContext()`), car le plein écran peut être détenu par le document parent et non par l'iframe (dans ce cas `document.fullscreenElement` est nul dans l'iframe et sa sortie échoue). Tests : `forge-completion.test.mjs` (+1), `editor-inline.test.mjs` (+1) | Le panneau assistant est monté dans `document.body` du parent : l'API Fullscreen ne rend que l'élément plein écran et ses descendants, donc il ne peut pas s'afficher au-dessus de l'iframe Forge en plein écran. La sortie côté iframe seule ne suffisait pas quand le parent détient le plein écran |
|
||||
| *BUG-057* | [🟡 IMPORTANT] Assistant IA : le bouton « Ajouter » est inopérant dans l'éditeur Forge (fonctionne seulement dans « Editer ») | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js`, `frontend/editor-poc.html` | Ouvrir un document dans Forge, demander une réponse à l'assistant puis cliquer « Ajouter » | `_insertIntoEditor()` cible Forge (`#forge-iframe`) : `postMessage({ type: 'parent-insert', text })` ; `editor-poc.html` insère au curseur (`insertAtCursor`) et marque le tampon modifié. Repli textarea inclus. Tests : `tests/frontend/ai.test.mjs` (+3), `tests/frontend/editor-inline.test.mjs` (+1) | `state.editorView` (CodeMirror) est nul en Forge : le clic affichait « Aucun document ouvert dans l'éditeur » |
|
||||
| *BUG-058* | [🔵 MINEUR] Éditeur « Editer » : la barre de numérotation de ligne ne suit pas la couleur du thème (gutter clair `#f5f5f5` en thème sombre) | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/style.css` | Ouvrir un document → Editer en thème sombre : la colonne des numéros de ligne reste gris clair alors que le fond de l'éditeur est sombre | Thème du gutter CodeMirror via les variables CSS (`color-mix(var(--text-primary) …)` pour le fond, `--text-secondary` pour les numéros, `--border` pour la séparation, `--text-primary` pour la ligne active) au lieu des valeurs codées en dur de CodeMirror ; test de non-régression dans `tests/frontend/editor-inline.test.mjs`. Vérifié Playwright (instance de test) : sombre `color(srgb 0.90 0.93 0.95 / 0.05)` + bordure `#21262d`, clair `color(srgb 0.12 0.14 0.16 / 0.05)` + bordure `#d0d7de` | CodeMirror applique `background:#f5f5f5` par défaut, indépendamment du thème ObsiGate ; en mode sombre le fond de l'éditeur suit `--bg-secondary` mais pas le gutter |
|
||||
| | | | | | | | | | | |
|
||||
|
||||
### TODOs techniques (améliorations / nouvelles tâches)
|
||||
@@ -213,6 +220,15 @@ 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 `✓`, `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) |
|
||||
| 2026-09-17 | BUG-055 | Correction | `frontend/editor-poc.html`, `frontend/js/autocomplete.js`, `backend/ai.py`, `.gitea/workflows/ci.yml`, `tests/frontend/forge-completion.test.mjs` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-055** : trois gestionnaires `keydown` Tab indépendants s'exécutaient à chaque appui — l'indentation (`insertAtCursor(' ')`) s'ajoutait à la complétion de mot (`insertAtCursor(suffixe)`) et à l'acceptation du ghost text, d'où l'espace parasite avant le mot complété puis un effacement destructeur. Gestion **unifiée** de Tab (`liste ouverte > ghost > mot du document > indentation`), helpers purs partagés (`getWordFragment`/`findWordCompletions`/`normalizeGhost`/`chooseTabAction`) extraits dans `autocomplete.js`, liste déroulante au curseur quand plusieurs mots correspondent, ghost **positionné au curseur** (plus de miroir du document entier, nettoyé au déplacement/scroll), complétion de mot sans espace garantie et prompt `/api/ai/inline-complete` simplifié (128 tokens). Vérifié : `tests/frontend/forge-completion.test.mjs` 28/28 (nouveau), `unit.test.mjs` 9/9, `editor-inline.test.mjs` 40/40, `ai.test.mjs` 88/88, validate-imports 38 modules, pytest 1101 passed / 6 skipped, ruff 0, mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-17 | BUG-055 (complément) | Correction | `frontend/editor-poc.html`, `frontend/js/utils.js`, `tests/frontend/forge-completion.test.mjs`, `tests/frontend/editor-inline.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-055 (complément)** : une complétion acceptée au `Tab` disparaissait 1–2 s plus tard. Cause : l'auto-sauvegarde (2 s) déclenche un `index_updated` SSE sur le fichier affiché, et `reloadExternalWrite` rechargeait le tampon Forge **depuis le disque**, écrasant toute frappe postérieure à la sauvegarde. Le rechargement SSE est désormais ignoré si le tampon est modifié (`parent-reload` sans `force` + `isDirty` ; garde équivalente sur le point d'auto-sauvegarde CodeMirror) ; seul `obsigate:file-written` (assistant IA) passe `force=true`. L'auto-sauvegarde ne remet plus l'état « enregistré » si des modifications sont arrivées pendant la requête (Forge + CodeMirror), et `acceptGhost()` annule la requête de prédiction en attente. Vérifié : `forge-completion.test.mjs` 31/31 (+3), `editor-inline.test.mjs` 41/41 (+1), 14 suites frontend vertes, validate-imports 38 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-17 | BUG-056 | Correction | `frontend/editor-poc.html`, `frontend/js/sync.js`, `tests/frontend/forge-completion.test.mjs`, `tests/frontend/editor-inline.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-056** : en plein écran Forge, l'Assistant IA s'ouvrait en arrière-plan. La sortie du plein écran est désormais faite **côté iframe** (`openAssistant` → `document.exitFullscreen()` puis `postMessage` à la résolution) **et côté parent** (`sync.js` sur `forge-open-ai` → `document.exitFullscreen()` puis `openForCurrentContext()`), car le plein écran peut appartenir au document parent (l'iframe voit alors `fullscreenElement` nul et sa sortie échoue — c'était le cas non couvert par le premier correctif). Vérifié : `forge-completion.test.mjs` 32/32 (+1), `editor-inline.test.mjs` 42/42 (+1), 14 suites frontend vertes, validate-imports 38 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-17 | BUG-057, #102 | Correction + feature | `frontend/js/bookslm.js`, `frontend/editor-poc.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs`, `docs/archive/COMPLETED_v1-v2.md`, `docs/ROADMAP.md`, `CHANGELOG.md` | **BUG-057** : le bouton « Ajouter » de l'assistant ne ciblait que `state.editorView` (CodeMirror) ; en Forge il affichait « Aucun document ouvert dans l'éditeur ». `_insertIntoEditor()` gère désormais les trois surfaces : CodeMirror, l'iframe Forge (`postMessage({ type: 'parent-insert', text })` → `insertAtCursor` dans `editor-poc.html`) et le textarea de repli. **#102** : chaque bloc de code d'une réponse reçoit un bouton « Ajouter la section » (`.bookslm-code-insert`, révélé au survol) qui insère le contenu du bloc sans les délimiteurs ` ``` `. Vérifié : `ai.test.mjs` 91/91 (+3), `editor-inline.test.mjs` 43/43 (+1), `forge-completion.test.mjs` 32/32, unit 9/9, validate-imports 38 modules, pytest 1101 passed / 6 skipped, ruff 0, mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-17 | BUG-058 | Correction | `frontend/style.css`, `tests/frontend/editor-inline.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-058** : la barre de numérotation de ligne de l'éditeur « Editer » ne suivait pas le thème — CodeMirror peint `.cm-gutters` avec des valeurs claires codées en dur (`#f5f5f5`, bordure `#ddd`), visibles en thème sombre. Correctif : le gutter dérive des variables CSS ObsiGate (`background: color-mix(in srgb, var(--text-primary) 5%, transparent)`, `color: var(--text-secondary)`, `border-right: 1px solid var(--border)`, ligne active `color-mix(… 10% …)` / `--text-primary`), donc il suit les 15 thèmes et les 4 modes. Vérifié : `editor-inline.test.mjs` 44/44 (+1), unit 9/9, validate-imports 38 modules, pytest 1101 passed / 6 skipped, ruff 0, mypy 0, et Playwright sur l'instance de test (route `style.css` remplacée par le fichier local) — sombre `color(srgb 0.90 0.93 0.95 / 0.05)` + bordure `#21262d`, clair `color(srgb 0.12 0.14 0.16 / 0.05)` + bordure `#d0d7de`, plus de `rgb(245,245,245)`. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.7.2 | **Dernière mise à jour :** 2026-09-16
|
||||
> **Version :** 2.9.1 | **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,9 @@
|
||||
| 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) |
|
||||
| BUG-057 | Assistant IA — bouton « Ajouter » fonctionnel dans l'éditeur Forge (en plus d'« Editer ») | 2.9.0 | [archive](./archive/COMPLETED_v1-v2.md) |
|
||||
| 102 | Assistant IA — bouton « Ajouter la section » par bloc de code (insertion du bloc seul) | 2.9.0 | [archive](./archive/COMPLETED_v1-v2.md) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -385,6 +385,25 @@ Fichiers texte non markdown :
|
||||
|
||||
---
|
||||
|
||||
## #102 — Assistant IA : « Ajouter » dans Forge + ajout d'un bloc de code ✅ TERMINÉ
|
||||
|
||||
Deux compléments au bouton « Ajouter » de l'assistant IA.
|
||||
|
||||
- **BUG-057 — Forge** : `bookslm.js::_insertIntoEditor()` ne ciblait que
|
||||
`state.editorView` (CodeMirror de « Editer ») et affichait « Aucun document ouvert
|
||||
dans l'éditeur » en Forge. Il prend désormais en charge les trois surfaces :
|
||||
CodeMirror, l'iframe Forge (délégation par `postMessage({ type: 'parent-insert' })`,
|
||||
insert au curseur via `insertAtCursor` côté `editor-poc.html`) et le textarea de
|
||||
repli.
|
||||
- **#102 — Ajout d'un bloc** : chaque bloc de code d'une réponse reçoit un bouton
|
||||
« Ajouter la section » (révélé au survol, `.bookslm-code-insert`) qui insère
|
||||
uniquement le contenu du bloc (sans les délimiteurs ` ``` `), au lieu de la réponse
|
||||
complète.
|
||||
- **Tests** : `tests/frontend/ai.test.mjs` (+3 : Forge, textarea, bloc de code) ;
|
||||
`tests/frontend/editor-inline.test.mjs` (+1 : handler `parent-insert`).
|
||||
|
||||
---
|
||||
|
||||
## Grosses fonctionnalités — fiches dédiées
|
||||
|
||||
| # | Feature | Version | Fiche |
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
+271
-190
@@ -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; }
|
||||
@@ -233,18 +212,14 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
.ac-item-label { font-family: var(--mono); font-weight: 500; white-space: nowrap; }
|
||||
.ac-item-detail { font-size: 10.5px; color: var(--text3); margin-left: auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 160px; }
|
||||
.ac-item-context { font-size: 9px; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; padding: 2px 10px 4px; }
|
||||
/* Ghost text overlay */
|
||||
/* Ghost text overlay (AI inline prediction, positioned at the caret) */
|
||||
.ghost-overlay {
|
||||
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
|
||||
position: absolute; top: 0; left: 0;
|
||||
pointer-events: none; z-index: 1;
|
||||
padding: 24px 16px 24px 12px;
|
||||
font-family: var(--mono); font-size: 13.5px; line-height: 1.75;
|
||||
white-space: pre-wrap; word-wrap: break-word;
|
||||
overflow: hidden; color: transparent;
|
||||
}
|
||||
.ghost-prediction {
|
||||
color: var(--text3); opacity: 0.5;
|
||||
white-space: pre; color: var(--text3); opacity: 0.55;
|
||||
}
|
||||
.ghost-prediction { color: inherit; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -264,7 +239,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)">?</button>
|
||||
<button class="btn" id="btn-preview" title="Toggle preview">👁</button>
|
||||
<button class="btn btn-ai off" id="btn-ai" title="AI Panel (Ctrl+J)">✨</button>
|
||||
<button class="btn btn-ai off" id="btn-ai" title="Assistant IA (Ctrl+J)">✨</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 +356,13 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
<div class="bubble-more-item" data-action="ai-continue">➕ 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">✨ AI Assistant</span>
|
||||
<button class="ai-panel-close" id="ai-close">×</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">✨ Improve overall style</button>
|
||||
<button class="ai-sugg">➕ Add a conclusion</button>
|
||||
<button class="ai-sugg">📝 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">✨ AI processing...</span>
|
||||
@@ -430,7 +386,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
<tr><td>/</td><td>Menu de commandes (en debut de ligne)</td></tr>
|
||||
<tr><td>Alt+I</td><td>Insertion rapide (Quick Insert)</td></tr>
|
||||
<tr><td>F1</td><td>Ce panneau d'aide</td></tr>
|
||||
<tr><td>Tab</td><td>Indenter la ligne / element de liste</td></tr>
|
||||
<tr><td>Tab</td><td>Valider la suggestion / completer le mot / indenter</td></tr>
|
||||
<tr><td>Shift+Tab</td><td>Desindenter</td></tr>
|
||||
</table>
|
||||
<h3>Formatage</h3>
|
||||
@@ -453,7 +409,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 +450,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');
|
||||
@@ -521,6 +476,11 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
var saveTimer = null;
|
||||
var aiConfigured = null;
|
||||
|
||||
// Shared autocomplete helpers (pure functions from autocomplete.js). Loaded
|
||||
// once at startup so the « Tab » handler can use them synchronously.
|
||||
var ac = null;
|
||||
import('/static/js/autocomplete.js').then(function(m) { ac = m; }).catch(function() {});
|
||||
|
||||
// Parse URL params
|
||||
(function() {
|
||||
var p = new URLSearchParams(window.location.search);
|
||||
@@ -615,7 +575,11 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
body: JSON.stringify({ content: content })
|
||||
}).then(function(r) {
|
||||
if (!r.ok) throw new Error(r.status);
|
||||
isDirty = false; originalContent = content;
|
||||
originalContent = content;
|
||||
// BUG-055 — edits typed while the save was in flight are newer than the
|
||||
// disk: stay dirty (and save again) so a later SSE reload can't drop them.
|
||||
if (val() !== content) { scheduleAutoSave(); return; }
|
||||
isDirty = false;
|
||||
saveDot.className = 'save-dot ok'; saveLabel.textContent = 'Saved';
|
||||
}).catch(function(e) {
|
||||
saveDot.className = 'save-dot err'; saveLabel.textContent = 'Erreur';
|
||||
@@ -699,6 +663,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 +691,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 +720,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 +737,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) {
|
||||
@@ -877,7 +858,27 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
})();
|
||||
|
||||
// ---- Caret position to pixel (for monospace textarea) ----
|
||||
function caretToPixel(pos) {
|
||||
// Measured once so the caret math matches the actual font metrics.
|
||||
var _charW = 0;
|
||||
function getCharWidth() {
|
||||
if (_charW) return _charW;
|
||||
var style = getComputedStyle(ta);
|
||||
var span = document.createElement('span');
|
||||
span.style.fontFamily = style.fontFamily;
|
||||
span.style.fontSize = style.fontSize;
|
||||
span.style.fontWeight = style.fontWeight;
|
||||
span.style.fontStyle = style.fontStyle;
|
||||
span.style.position = 'absolute';
|
||||
span.style.visibility = 'hidden';
|
||||
span.style.whiteSpace = 'pre';
|
||||
span.textContent = '0123456789';
|
||||
document.body.appendChild(span);
|
||||
_charW = (span.getBoundingClientRect().width / 10) || (parseFloat(style.fontSize) * 0.615);
|
||||
span.remove();
|
||||
return _charW;
|
||||
}
|
||||
|
||||
function caretToPixel(pos, raw) {
|
||||
if (pos == null) pos = getPos().s;
|
||||
var v = val();
|
||||
var before = v.substring(0, pos);
|
||||
@@ -888,20 +889,40 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
var fontSize = parseFloat(style.fontSize);
|
||||
var lineH = parseFloat(style.lineHeight);
|
||||
if (isNaN(lineH)) lineH = fontSize * 1.75;
|
||||
var charW = fontSize * 0.615;
|
||||
var charW = getCharWidth();
|
||||
var wrapRect = editorWrap.getBoundingClientRect();
|
||||
var taRect = ta.getBoundingClientRect();
|
||||
// Position relative to editorWrap
|
||||
var x = taRect.left - wrapRect.left + 2 + colNum * charW;
|
||||
// Position relative to editorWrap (top of the line below the caret).
|
||||
var x = taRect.left - wrapRect.left + colNum * charW;
|
||||
var y = taRect.top - wrapRect.top + (lineNum + 1) * lineH - ta.scrollTop;
|
||||
// Clamp
|
||||
if (x < 4) x = 4;
|
||||
if (x > wrapRect.width - 310) x = wrapRect.width - 310;
|
||||
if (y > wrapRect.height - 360) y = wrapRect.height - 360;
|
||||
if (y < 20) y = 20;
|
||||
if (!raw) {
|
||||
// Clamp for the popup menus (never the inline ghost, which is exact).
|
||||
if (x < 4) x = 4;
|
||||
if (x > wrapRect.width - 310) x = wrapRect.width - 310;
|
||||
if (y > wrapRect.height - 360) y = wrapRect.height - 360;
|
||||
if (y < 20) y = 20;
|
||||
}
|
||||
return { x: x, y: y };
|
||||
}
|
||||
|
||||
// Position of the caret itself (same line), used by the inline ghost text.
|
||||
function caretLinePixel(pos) {
|
||||
if (pos == null) pos = getPos().s;
|
||||
var v = val();
|
||||
var before = v.substring(0, pos);
|
||||
var lineNum = before.split('\n').length - 1;
|
||||
var colNum = pos - v.lastIndexOf('\n', pos - 1) - 1;
|
||||
if (colNum < 0) colNum = 0;
|
||||
var style = getComputedStyle(ta);
|
||||
var lineH = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.75;
|
||||
var wrapRect = editorWrap.getBoundingClientRect();
|
||||
var taRect = ta.getBoundingClientRect();
|
||||
return {
|
||||
x: taRect.left - wrapRect.left + colNum * getCharWidth(),
|
||||
y: taRect.top - wrapRect.top + lineNum * lineH - ta.scrollTop
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Slash menu ----
|
||||
function initSlashItems() { slashItems = Array.from(slashMenu.querySelectorAll('.slash-item')); }
|
||||
|
||||
@@ -1173,36 +1194,52 @@ 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() {
|
||||
var notify = function() {
|
||||
try {
|
||||
window.parent.postMessage({ type: 'forge-open-ai' }, '*');
|
||||
} catch (e) { /* not embedded */ }
|
||||
};
|
||||
// The shared assistant lives in the parent document, which cannot render
|
||||
// above a fullscreen Forge iframe: leave fullscreen first so the panel is
|
||||
// actually visible when it opens.
|
||||
if (document.fullscreenElement && document.exitFullscreen) {
|
||||
try {
|
||||
var p = document.exitFullscreen();
|
||||
if (p && p.then) p.then(notify, notify);
|
||||
else notify();
|
||||
} catch (e) { notify(); }
|
||||
} else {
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
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,16 +1329,11 @@ 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('*'); }
|
||||
if (e.ctrlKey && e.key === 'k') { e.preventDefault(); promptLink(); }
|
||||
// Tab: indent list items
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) { dedentLine(); } else { indentLine(); }
|
||||
}
|
||||
// Quick Insert: Alt+I
|
||||
if (e.altKey && e.key === 'i') { e.preventDefault(); showQI(); }
|
||||
});
|
||||
@@ -1433,16 +1465,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);
|
||||
|
||||
@@ -1450,11 +1474,23 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
window.addEventListener('message', function(e) {
|
||||
if (!e.data || !e.data.type) return;
|
||||
if (e.data.type === 'parent-save') { forceSave(); }
|
||||
// #102/BUG-057 — the parent AI assistant's « Ajouter » button inserts its
|
||||
// answer (or a single code block) at the current cursor position.
|
||||
if (e.data.type === 'parent-insert' && typeof e.data.text === 'string') {
|
||||
var p = getPos();
|
||||
insertAtCursor((p.s > 0 ? '\n' : '') + e.data.text);
|
||||
showToast('Texte ajoute au document', 'ok');
|
||||
}
|
||||
// #93 — the parent reloads the document after an external write (AI
|
||||
// assistant edit_file / append_to_file / create_file): re-read from disk
|
||||
// and drop the stale local buffer that would otherwise be autosaved back
|
||||
// over the assistant's change.
|
||||
if (e.data.type === 'parent-reload') {
|
||||
// #93/BUG-055 — the SSE `index_updated` broadcast is often caused by this
|
||||
// editor's own autosave. Reloading from disk then would clobber edits made
|
||||
// after the save (e.g. a Tab completion accepted in the meantime). Only an
|
||||
// external write (AI assistant) forces the reload past unsaved changes.
|
||||
if (!e.data.force && isDirty) return;
|
||||
clearTimeout(saveTimer);
|
||||
isDirty = false;
|
||||
loadFile();
|
||||
@@ -1532,10 +1568,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
if (lnGutter) {
|
||||
document.getElementById('ln-gutter').scrollTop = editorWrap.scrollTop;
|
||||
}
|
||||
// Sync ghost overlay scroll
|
||||
if (ghostOverlay) {
|
||||
ghostOverlay.scrollTop = editorWrap.scrollTop;
|
||||
}
|
||||
clearGhost();
|
||||
});
|
||||
|
||||
// Update line numbers on input
|
||||
@@ -1554,32 +1587,61 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
if (e.key === 'Escape' && helpOverlay.classList.contains('on')) { helpOverlay.classList.remove('on'); }
|
||||
});
|
||||
|
||||
// ---- Autocomplete (Tab completion from existing words) ----
|
||||
var _acTimer = null;
|
||||
// ---- Autocomplete : gestion unifiée de la touche Tab ----
|
||||
// Priorité : liste ouverte > prédiction IA (ghost) > complétion de mot du
|
||||
// document > indentation. Une seule action par appui (avant ce correctif,
|
||||
// l'indentation s'exécutait *en plus* de la complétion et ajoutait des espaces).
|
||||
ta.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey && !slashVisible && !qiMenu.classList.contains('on')) {
|
||||
var p = getPos();
|
||||
if (p.s !== p.e) return; // Don't autocomplete when selection exists (let indent handle it)
|
||||
var v = val();
|
||||
// Find the word fragment before cursor
|
||||
var start = p.s;
|
||||
while (start > 0 && /[\w\-\.\/]/.test(v.charAt(start - 1))) start--;
|
||||
var fragment = v.substring(start, p.s);
|
||||
if (fragment.length < 2) return; // Need at least 2 chars
|
||||
// Find matching words in the document
|
||||
var re = new RegExp('\\b' + fragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\\w\\-\\.\\/]+', 'gi');
|
||||
var matches = [];
|
||||
var m;
|
||||
while ((m = re.exec(v)) !== null) {
|
||||
if (matches.indexOf(m[0]) === -1) matches.push(m[0]);
|
||||
}
|
||||
if (matches.length === 1) {
|
||||
e.preventDefault();
|
||||
insertAtCursor(matches[0].substring(fragment.length), 0);
|
||||
if (e.key !== 'Tab' || e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
if (slashVisible || qiMenu.classList.contains('on')) return; // ces menus gèrent Tab
|
||||
|
||||
// 1. Liste d'autocomplétion ouverte → valider l'élément surligné
|
||||
if (acDropdown.classList.contains('active')) {
|
||||
e.preventDefault();
|
||||
if (acIdx >= 0 && acItems[acIdx]) applyAutocomplete(acItems[acIdx]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Prédiction IA affichée → l'accepter
|
||||
if (_ghostText) {
|
||||
e.preventDefault();
|
||||
acceptGhost();
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Complétion à partir des mots du document
|
||||
var p = getPos();
|
||||
if (!e.shiftKey && p.s === p.e && ac) {
|
||||
var frag = ac.getWordFragment(val(), p.s);
|
||||
if (frag.fragment.length >= 2) {
|
||||
var candidates = ac.findWordCompletions(val(), p.s, frag.fragment, 8);
|
||||
var action = ac.chooseTabAction({ candidates: candidates });
|
||||
if (action === 'word') {
|
||||
e.preventDefault();
|
||||
insertAtCursor(candidates[0].slice(frag.fragment.length), 0);
|
||||
return;
|
||||
}
|
||||
if (action === 'word-list') {
|
||||
e.preventDefault();
|
||||
showWordCompletions(candidates, frag);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Défaut : indenter / désindenter
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) { dedentLine(); } else { indentLine(); }
|
||||
});
|
||||
|
||||
// Suggestion list for several document words sharing the typed prefix.
|
||||
function showWordCompletions(words, frag) {
|
||||
var items = words.map(function(w) {
|
||||
return { label: w, detail: 'mot du document', insert: w.slice(frag.fragment.length), kind: 'word' };
|
||||
});
|
||||
showAutocomplete(items, 'Mots du document');
|
||||
}
|
||||
|
||||
// ---- Title sync: first heading line <-> title bar (NO rename) ----
|
||||
function syncTitleFromContent() {
|
||||
var v = val();
|
||||
@@ -1681,13 +1743,24 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
acItems = items;
|
||||
acIdx = -1;
|
||||
if (!items.length) { hideAutocomplete(); return; }
|
||||
// Position: centered below the textarea (simple, reliable)
|
||||
var rect = ta.getBoundingClientRect();
|
||||
acDropdown.style.left = (rect.left + rect.width / 2) + 'px';
|
||||
acDropdown.style.top = (rect.bottom + 6) + 'px';
|
||||
acDropdown.style.transform = 'translateX(-50%)';
|
||||
// Keep dropdown within viewport
|
||||
acDropdown.style.maxWidth = Math.min(380, rect.width - 32) + 'px';
|
||||
// Position: just below the caret, clamped to the viewport (natural place
|
||||
// for a completion list instead of a fixed centered dropdown).
|
||||
var pt = caretToPixel(ta.selectionStart, true);
|
||||
var wr = editorWrap.getBoundingClientRect();
|
||||
var width = Math.min(380, Math.max(220, editorWrap.clientWidth - 32));
|
||||
var estH = Math.min(items.length * 26 + 30, 260);
|
||||
var left = wr.left + pt.x;
|
||||
var top = wr.top + pt.y + 4;
|
||||
if (left + width > window.innerWidth - 8) left = window.innerWidth - width - 8;
|
||||
if (left < 8) left = 8;
|
||||
if (top + estH > window.innerHeight - 8) {
|
||||
top = Math.max(8, wr.top + pt.y - estH - 2);
|
||||
}
|
||||
acDropdown.style.width = width + 'px';
|
||||
acDropdown.style.maxWidth = width + 'px';
|
||||
acDropdown.style.left = left + 'px';
|
||||
acDropdown.style.top = top + 'px';
|
||||
acDropdown.style.transform = 'none';
|
||||
|
||||
var html = context ? '<div class="ac-item-context">' + context + '</div>' : '';
|
||||
items.forEach(function(item, i) {
|
||||
@@ -1728,6 +1801,23 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
|
||||
function applyAutocomplete(item) {
|
||||
if (!item || !item.insert) return;
|
||||
|
||||
// Document word completions are inserted plainly at the caret — no
|
||||
// context-aware replacement (which would wipe the line in frontmatter).
|
||||
if (item.kind === 'word') {
|
||||
var wp = getPos();
|
||||
ta.value = val().slice(0, wp.s) + item.insert + val().slice(wp.e);
|
||||
var wnp = wp.s + item.insert.length;
|
||||
ta.setSelectionRange(wnp, wnp);
|
||||
hideAutocomplete();
|
||||
clearGhost();
|
||||
ta.focus();
|
||||
markDirty();
|
||||
autoHeight();
|
||||
updateLineNumbers();
|
||||
return;
|
||||
}
|
||||
|
||||
var start = ta.selectionStart;
|
||||
var end = ta.selectionEnd;
|
||||
var before = ta.value.slice(0, start);
|
||||
@@ -1757,6 +1847,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
}
|
||||
|
||||
hideAutocomplete();
|
||||
clearGhost();
|
||||
ta.focus();
|
||||
markDirty();
|
||||
autoHeight();
|
||||
@@ -1798,25 +1889,28 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
|
||||
function clearGhost() {
|
||||
_ghostText = '';
|
||||
ghostOverlay.innerHTML = '';
|
||||
if (ghostPred) ghostPred.textContent = '';
|
||||
}
|
||||
|
||||
function showGhost(prediction) {
|
||||
if (!prediction) { clearGhost(); return; }
|
||||
_ghostText = prediction;
|
||||
var before = ta.value.slice(0, ta.selectionStart);
|
||||
// Show: existing text (transparent) + prediction (visible faded)
|
||||
ghostOverlay.innerHTML = escHtml(before) + '<span class="ghost-prediction">' + escHtml(prediction) + '</span>';
|
||||
// Only the prediction is rendered, positioned exactly at the caret: no
|
||||
// mirror of the whole document, so no misalignment and no phantom spaces.
|
||||
ghostPred.textContent = prediction;
|
||||
var pt = caretLinePixel(ta.selectionStart);
|
||||
ghostOverlay.style.left = pt.x + 'px';
|
||||
ghostOverlay.style.top = pt.y + 'px';
|
||||
}
|
||||
|
||||
function acceptGhost() {
|
||||
if (!_ghostText) return;
|
||||
if (_ghostTimer) { clearTimeout(_ghostTimer); _ghostTimer = null; }
|
||||
var start = ta.selectionStart;
|
||||
var before = ta.value.slice(0, start);
|
||||
var after = ta.value.slice(start);
|
||||
// Trim to avoid double spaces
|
||||
var insert = _ghostText.replace(/^\s+/, '');
|
||||
if (!insert) return;
|
||||
if (!insert) { clearGhost(); return; }
|
||||
ta.value = before + insert + after;
|
||||
ta.setSelectionRange(start + insert.length, start + insert.length);
|
||||
clearGhost();
|
||||
@@ -1827,51 +1921,39 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
}
|
||||
|
||||
function requestGhostCompletion() {
|
||||
if (acDropdown.classList.contains('active')) return; // list open: don't compete
|
||||
if (ta.selectionStart !== ta.selectionEnd) return; // no prediction over a selection
|
||||
var fullText = ta.value.slice(0, ta.selectionStart);
|
||||
if (fullText.trim().length < 1) return;
|
||||
if (!fullText.trim()) return;
|
||||
var lastChar = fullText.slice(-1);
|
||||
var midWord = /[a-zA-Z0-9\u00C0-\u024F]$/.test(lastChar);
|
||||
var midWord = /[\w\u00C0-\u024F]$/.test(lastChar);
|
||||
var inputText = midWord ? (fullText.match(/([\w\u00C0-\u024F]+)$/) || [''])[0] : fullText;
|
||||
|
||||
// Get user's preferred language
|
||||
var lang = 'fr';
|
||||
try { lang = localStorage.getItem('obsigate-lang') || 'fr'; } catch(e) {}
|
||||
var langNames = { fr: 'French', en: 'English', es: 'Spanish', de: 'German' };
|
||||
var langName = langNames[lang] || 'French';
|
||||
|
||||
var prompt, inputText;
|
||||
if (midWord) {
|
||||
var wordMatch = fullText.match(/([\w\u00C0-\u024F]+)$/);
|
||||
var partialWord = wordMatch ? wordMatch[1] : fullText;
|
||||
inputText = partialWord;
|
||||
var context = fullText.slice(0, -partialWord.length).trim();
|
||||
prompt = 'Complete this ' + langName + ' word. The word is: "' + partialWord +
|
||||
'". Context: "' + (context || '(start of line)') +
|
||||
'". Return ONLY the remaining letters. Do NOT add spaces. Example: "famil" → "ial" for "familial".';
|
||||
} else {
|
||||
inputText = fullText;
|
||||
prompt = 'Continue this ' + langName + ' text naturally. Return ONLY the new text (do NOT repeat):\n' + fullText;
|
||||
}
|
||||
// The backend turns this text into a short « continue this text » prompt.
|
||||
var ghostBody = { text: fullText };
|
||||
// #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) {
|
||||
var raw = (data.result || '').trim();
|
||||
if (!raw || raw.length < 1) return;
|
||||
var prediction = raw;
|
||||
if (midWord && prediction.toLowerCase().startsWith(inputText.toLowerCase())) {
|
||||
prediction = prediction.slice(inputText.length);
|
||||
} else if (!midWord && prediction.startsWith(inputText)) {
|
||||
prediction = prediction.slice(inputText.length);
|
||||
}
|
||||
prediction = prediction.trim();
|
||||
var prediction = ac
|
||||
? ac.normalizeGhost(data.result || '', inputText, midWord)
|
||||
: String(data.result || '').trim();
|
||||
if (!prediction) return;
|
||||
// Don't suggest text that is already present after the cursor.
|
||||
var alreadyThere = ta.value.slice(ta.selectionStart).trimStart();
|
||||
if (prediction && prediction.length > 0 && !alreadyThere.startsWith(prediction.slice(0, Math.min(6, prediction.length)))) {
|
||||
showGhost(prediction);
|
||||
}
|
||||
if (alreadyThere.startsWith(prediction.slice(0, Math.min(6, prediction.length)))) return;
|
||||
// Ignore a stale response if the caret moved while we were waiting.
|
||||
if (ta.selectionStart !== ta.selectionEnd) return;
|
||||
showGhost(prediction);
|
||||
})
|
||||
.catch(function() { /* silent */ });
|
||||
}
|
||||
@@ -1883,15 +1965,14 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
_ghostTimer = setTimeout(requestGhostCompletion, 600);
|
||||
});
|
||||
|
||||
// Clear the prediction as soon as the caret moves elsewhere.
|
||||
document.addEventListener('selectionchange', function() {
|
||||
if (document.activeElement === ta) clearGhost();
|
||||
});
|
||||
|
||||
function escHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
ta.addEventListener('keydown', function(e) {
|
||||
// Tab: accept ghost text prediction
|
||||
if (e.key === 'Tab' && _ghostText && !acDropdown.classList.contains('active')) {
|
||||
e.preventDefault();
|
||||
acceptGhost();
|
||||
return;
|
||||
}
|
||||
// Escape: clear ghost text
|
||||
if (e.key === 'Escape' && _ghostText && !acDropdown.classList.contains('active')) {
|
||||
clearGhost();
|
||||
@@ -1900,7 +1981,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
if (acDropdown.classList.contains('active')) {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); acIdx = Math.min(acIdx + 1, acItems.length - 1); highlightAcItem(); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); acIdx = Math.max(acIdx - 1, 0); highlightAcItem(); }
|
||||
else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (acIdx >= 0 && acItems[acIdx]) applyAutocomplete(acItems[acIdx]);
|
||||
}
|
||||
@@ -2000,7 +2081,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>
|
||||
|
||||
@@ -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"
|
||||
@@ -4310,6 +4319,12 @@
|
||||
Réponses formatées : titres, listes, tableaux, citations et
|
||||
blocs de code.
|
||||
</li>
|
||||
<li data-i18n="help.assistant_insert">
|
||||
Le bouton « Ajouter » (au survol d'une réponse) insère la
|
||||
réponse dans le document ouvert dans l'éditeur (Editer ou
|
||||
Forge) ; chaque bloc de code propose « Ajouter la section »
|
||||
pour n'insérer que ce bloc.
|
||||
</li>
|
||||
<li data-i18n="help.assistant_links">
|
||||
Les fichiers et chemins cités sont des liens : cliquez sur un
|
||||
fichier pour l'ouvrir, sur un dossier pour le révéler dans
|
||||
|
||||
@@ -347,4 +347,97 @@ function filterFiles(partial) {
|
||||
});
|
||||
}
|
||||
|
||||
export { suggest, searchWikilinks, detectContext, MERMAID_TYPES, CODE_LANGUAGES, FRONTMATTER_FIELDS, MARKDOWN_FORMAT };
|
||||
// ── Word completion (Forge « Tab » completion) ────────────────────────
|
||||
// A word character matches the same set used by the editor: letters, digits,
|
||||
// underscore, dash, dot and slash (so paths like `docs/guide` complete too).
|
||||
var _WORD_CHAR = /[\w\-.\/]/;
|
||||
|
||||
function isWordChar(ch) {
|
||||
return !!ch && _WORD_CHAR.test(ch);
|
||||
}
|
||||
|
||||
/** Return the word fragment immediately before the cursor. */
|
||||
function getWordFragment(text, cursorPos) {
|
||||
var start = cursorPos;
|
||||
while (start > 0 && isWordChar(text.charAt(start - 1))) start--;
|
||||
return { start: start, fragment: text.slice(start, cursorPos) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect unique words of `text` that start with `fragment` (case-insensitive).
|
||||
* The occurrence currently being typed at the cursor is ignored. `limit` caps
|
||||
* the number of candidates (0 = no limit).
|
||||
*/
|
||||
function findWordCompletions(text, cursorPos, fragment, limit) {
|
||||
if (!fragment || fragment.length < 2) return [];
|
||||
var lower = fragment.toLowerCase();
|
||||
var re = /[\w\-.\/]+/g;
|
||||
var seen = Object.create(null);
|
||||
var out = [];
|
||||
var m;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
var word = m[0];
|
||||
if (word.length <= fragment.length) continue;
|
||||
if (word.toLowerCase().indexOf(lower) !== 0) continue;
|
||||
// Skip the word currently being typed (it starts exactly at the cursor).
|
||||
if (m.index === cursorPos - fragment.length) continue;
|
||||
var key = word.toLowerCase();
|
||||
if (seen[key]) continue;
|
||||
seen[key] = true;
|
||||
out.push(word);
|
||||
if (limit && out.length >= limit) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up a raw AI prediction before displaying/inserting it.
|
||||
*
|
||||
* - strips the echoed input prefix (mid-word or full context),
|
||||
* - for a mid-word completion, keeps only the first token so a word completion
|
||||
* can never introduce a stray space,
|
||||
* - trims any remaining leading whitespace.
|
||||
*/
|
||||
function normalizeGhost(raw, inputText, midWord) {
|
||||
var p = raw == null ? '' : String(raw).trim();
|
||||
if (!p) return '';
|
||||
if (inputText) {
|
||||
var inp = String(inputText);
|
||||
if (midWord && p.toLowerCase().indexOf(inp.toLowerCase()) === 0) {
|
||||
p = p.slice(inp.length);
|
||||
} else if (!midWord && p.indexOf(inp) === 0) {
|
||||
p = p.slice(inp.length);
|
||||
}
|
||||
}
|
||||
if (midWord) p = p.split(/\s+/)[0];
|
||||
return p.replace(/^\s+/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what the « Tab » key should do. Kept pure so the editor and the tests
|
||||
* share the exact same priority rules.
|
||||
*
|
||||
* dropdown → an autocomplete list is open: accept the highlighted item
|
||||
* ghost → an AI inline prediction is displayed: accept it
|
||||
* dedent → Shift+Tab: remove indentation
|
||||
* word → a single document word matches: insert its suffix
|
||||
* word-list → several words match: open the suggestion list
|
||||
* indent → default: indent the current line
|
||||
*/
|
||||
function chooseTabAction(state) {
|
||||
state = state || {};
|
||||
if (state.dropdownOpen) return 'dropdown';
|
||||
if (state.ghost) return 'ghost';
|
||||
if (state.shiftKey) return 'dedent';
|
||||
if (state.hasSelection) return 'indent';
|
||||
var candidates = state.candidates || [];
|
||||
if (candidates.length === 1) return 'word';
|
||||
if (candidates.length > 1) return 'word-list';
|
||||
return 'indent';
|
||||
}
|
||||
|
||||
export {
|
||||
suggest, searchWikilinks, detectContext,
|
||||
MERMAID_TYPES, CODE_LANGUAGES, FRONTMATTER_FIELDS, MARKDOWN_FORMAT,
|
||||
isWordChar, getWordFragment, findWordCompletions, normalizeGhost, chooseTabAction,
|
||||
};
|
||||
|
||||
+79
-16
@@ -2153,6 +2153,7 @@ class BooksLM {
|
||||
bubble.className = 'bookslm-bubble assistant';
|
||||
const { text, actions } = this._extractActions(msg.content || '');
|
||||
bubble.innerHTML = this._renderMarkdown(text);
|
||||
this._enhanceCodeBlocks(bubble);
|
||||
// Plain chat has no steps block: the pending answer itself carries the
|
||||
// animated indicator until the first token arrives.
|
||||
if (running && !text && !actions.length) {
|
||||
@@ -2234,28 +2235,90 @@ class BooksLM {
|
||||
}
|
||||
|
||||
/**
|
||||
* Append an assistant answer to the Forge editor document (Notion “Ajouter”).
|
||||
* No-ops with a toast when no editor session is open.
|
||||
* Append an assistant answer (or a single code block) to the document open
|
||||
* in the active editor (Notion “Ajouter”). Supports the three editor
|
||||
* surfaces: CodeMirror (« Editer »), the Forge iframe and the plain textarea
|
||||
* fallback. No-ops with a toast when no editor session is open.
|
||||
*/
|
||||
_insertIntoEditor(text) {
|
||||
const value = String(text == null ? '' : text);
|
||||
if (!value.trim()) return;
|
||||
|
||||
// 1. CodeMirror editor (« Editer »).
|
||||
const view = state.editorView;
|
||||
if (!view || !view.state || typeof view.dispatch !== 'function') {
|
||||
showToast(t('bookslm.insert_no_editor'), 'info');
|
||||
if (view && view.state && typeof view.dispatch === 'function') {
|
||||
try {
|
||||
const at = view.state.selection.main.to;
|
||||
const insert = (at > 0 ? '\n' : '') + value;
|
||||
view.dispatch({
|
||||
changes: { from: at, insert },
|
||||
selection: { anchor: at + insert.length },
|
||||
});
|
||||
if (typeof view.focus === 'function') view.focus();
|
||||
showToast(t('bookslm.inserted'), 'success');
|
||||
} catch (e) {
|
||||
console.warn('AI assistant: insert into editor failed', e);
|
||||
showToast(t('bookslm.insert_no_editor'), 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const at = view.state.selection.main.to;
|
||||
const insert = (at > 0 ? '\n' : '') + text;
|
||||
view.dispatch({
|
||||
changes: { from: at, insert },
|
||||
selection: { anchor: at + insert.length },
|
||||
});
|
||||
if (typeof view.focus === 'function') view.focus();
|
||||
showToast(t('bookslm.inserted'), 'success');
|
||||
} catch (e) {
|
||||
console.warn('AI assistant: insert into editor failed', e);
|
||||
showToast(t('bookslm.insert_no_editor'), 'error');
|
||||
|
||||
// 2. Forge editor (same-origin iframe): its buffer lives in the child
|
||||
// document, so the insertion is delegated with a postMessage.
|
||||
const forge = document.getElementById('forge-iframe');
|
||||
if (forge && forge.contentWindow) {
|
||||
try {
|
||||
forge.contentWindow.postMessage({ type: 'parent-insert', text: value }, '*');
|
||||
showToast(t('bookslm.inserted'), 'success');
|
||||
} catch (e) {
|
||||
console.warn('AI assistant: insert into Forge failed', e);
|
||||
showToast(t('bookslm.insert_no_editor'), 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Plain textarea fallback (CodeMirror failed to load).
|
||||
const ta = state.fallbackEditorEl;
|
||||
if (ta && typeof ta.value === 'string') {
|
||||
const start = ta.selectionStart != null ? ta.selectionStart : ta.value.length;
|
||||
const end = ta.selectionEnd != null ? ta.selectionEnd : ta.value.length;
|
||||
const insert = (start > 0 ? '\n' : '') + value;
|
||||
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end);
|
||||
const pos = start + insert.length;
|
||||
if (typeof ta.setSelectionRange === 'function') ta.setSelectionRange(pos, pos);
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
if (typeof ta.focus === 'function') ta.focus();
|
||||
showToast(t('bookslm.inserted'), 'success');
|
||||
return;
|
||||
}
|
||||
|
||||
showToast(t('bookslm.insert_no_editor'), 'info');
|
||||
}
|
||||
|
||||
/**
|
||||
* #102 — Add a discreet « Ajouter » button to every fenced code block of an
|
||||
* answer, so a single proposed section can be inserted instead of the whole
|
||||
* reply. The button reuses the message action styling.
|
||||
*/
|
||||
_enhanceCodeBlocks(bubble) {
|
||||
if (!bubble || typeof bubble.querySelectorAll !== 'function') return;
|
||||
const blocks = bubble.querySelectorAll('pre');
|
||||
blocks.forEach((pre) => {
|
||||
const code = pre.querySelector('code');
|
||||
if (!code) return;
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'bookslm-code-block';
|
||||
pre.parentNode.insertBefore(wrap, pre);
|
||||
wrap.appendChild(pre);
|
||||
const btn = this._actionBtn(
|
||||
t('bookslm.insert_block'),
|
||||
'insert',
|
||||
t('bookslm.insert_block_hint'),
|
||||
() => this._insertIntoEditor(code.textContent),
|
||||
);
|
||||
btn.classList.add('bookslm-code-insert');
|
||||
wrap.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
_copyText(text) {
|
||||
|
||||
@@ -645,5 +645,30 @@ 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).
|
||||
var openForgeAssistant = function() {
|
||||
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);
|
||||
});
|
||||
};
|
||||
// BUG-056 — the assistant panel is mounted in this document. The native
|
||||
// fullscreen may be owned by the parent (Forge iframe) rather than by the
|
||||
// iframe itself, so the parent must also leave fullscreen before the
|
||||
// panel can be seen.
|
||||
if (document.fullscreenElement && document.exitFullscreen) {
|
||||
try {
|
||||
document.exitFullscreen().catch(function() {}).then(openForgeAssistant, openForgeAssistant);
|
||||
} catch (err) {
|
||||
openForgeAssistant();
|
||||
}
|
||||
} else {
|
||||
openForgeAssistant();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+105
-8
@@ -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();
|
||||
@@ -643,17 +653,22 @@ function _invalidateActiveTabCache(vault, path) {
|
||||
* pre-write content, and — worse — an open editor holds the old text in memory
|
||||
* and its 2s autosave would overwrite the assistant's change with it.
|
||||
*/
|
||||
async function reloadExternalWrite(vault, path) {
|
||||
async function reloadExternalWrite(vault, path, force = false) {
|
||||
if (!vault || !path) return;
|
||||
const isEdited = state.editorVault === vault && state.editorPath === path;
|
||||
if (isEdited) {
|
||||
// Forge hosts its own buffer inside an iframe: ask it to reload from disk.
|
||||
var forgeFrame = document.getElementById("forge-iframe");
|
||||
if (forgeFrame && forgeFrame.contentWindow) {
|
||||
forgeFrame.contentWindow.postMessage({ type: 'parent-reload' }, '*');
|
||||
forgeFrame.contentWindow.postMessage({ type: 'parent-reload', force: !!force }, '*');
|
||||
return;
|
||||
}
|
||||
if (!state.editorView) return;
|
||||
// BUG-055 — a non-forced reload (SSE `index_updated`) is often caused by the
|
||||
// editor's own autosave: reloading then would clobber edits made after the
|
||||
// save. Skip while the save dot reports unsaved local changes.
|
||||
var dirtyDot = document.getElementById("editor-save-dot");
|
||||
if (!force && dirtyDot && dirtyDot.classList.contains("dirty")) return;
|
||||
try {
|
||||
const rawUrl = `/api/file/${encodeURIComponent(vault)}/raw?path=${encodeURIComponent(path)}`;
|
||||
const rawData = await api(rawUrl);
|
||||
@@ -683,6 +698,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 = '✓';
|
||||
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");
|
||||
@@ -727,9 +762,23 @@ async function saveFile(silent = false) {
|
||||
throw new Error(error.detail || "Erreur de sauvegarde");
|
||||
}
|
||||
|
||||
// Update save dot to saved state
|
||||
if (saveDot) { saveDot.className = 'editor-save-dot ok'; }
|
||||
if (saveLabel) saveLabel.textContent = 'Saved';
|
||||
// BUG-055 — only mark clean when nothing changed while saving; otherwise
|
||||
// keep the unsaved state and schedule another autosave (an SSE reload of
|
||||
// the file must not drop edits typed during the request).
|
||||
const currentContent = state.editorView
|
||||
? state.editorView.state.doc.toString()
|
||||
: state.fallbackEditorEl
|
||||
? state.fallbackEditorEl.value
|
||||
: content;
|
||||
if (silent && currentContent !== content) {
|
||||
if (saveDot) { saveDot.className = 'editor-save-dot dirty'; }
|
||||
if (saveLabel) saveLabel.textContent = 'Unsaved';
|
||||
clearTimeout(window._obsigateAutoSaveTimer);
|
||||
window._obsigateAutoSaveTimer = setTimeout(() => saveFile(true), 2000);
|
||||
} else {
|
||||
if (saveDot) { saveDot.className = 'editor-save-dot ok'; }
|
||||
if (saveLabel) saveLabel.textContent = 'Saved';
|
||||
}
|
||||
|
||||
if (silent) {
|
||||
// Auto-save: brief green flash on save button
|
||||
@@ -750,6 +799,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 +857,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();
|
||||
}
|
||||
});
|
||||
@@ -844,7 +940,8 @@ function initEditor() {
|
||||
// visible right away and never overwritten by the editor's autosave.
|
||||
window.addEventListener("obsigate:file-written", (e) => {
|
||||
const detail = (e && e.detail) || {};
|
||||
reloadExternalWrite(detail.vault, detail.path);
|
||||
// force: the assistant's write must win over the stale local buffer.
|
||||
reloadExternalWrite(detail.vault, detail.path, true);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
@@ -1288,6 +1290,7 @@
|
||||
"help.assistant_panel": "🧠 Assistant panel (BooksLM)",
|
||||
"help.assistant_panel_desc": "The side assistant (floating button or a folder's context menu) answers in formatted Markdown and contextualises your directories or documents. In general mode it also knows what you are looking at: open documents, current directory, active search and recently modified files.",
|
||||
"help.assistant_markdown": "Formatted answers: headings, lists, tables, quotes and code blocks.",
|
||||
"help.assistant_insert": "The \"Add\" button (revealed on hover of an answer) inserts the answer into the document open in the editor (Editer or Forge); each code block offers \"Add section\" to insert just that block.",
|
||||
"help.assistant_links": "Cited files and paths are links: a bare filename copies the name to the clipboard, a folder is revealed in the tree, and a file path opens it in the viewer.",
|
||||
"help.assistant_sessions": "The header history icon lists past sessions (reopen or delete); “+” starts a new conversation.",
|
||||
"help.assistant_agent": "The \"agent mode\" button enables tools (read, list, search); modifying actions require confirmation with a change preview.",
|
||||
@@ -1783,6 +1786,8 @@
|
||||
"bookslm.insert_hint": "Append the answer to the document open in the editor",
|
||||
"bookslm.inserted": "Answer added to the document",
|
||||
"bookslm.insert_no_editor": "No document open in the editor",
|
||||
"bookslm.insert_block": "Add section",
|
||||
"bookslm.insert_block_hint": "Add only this code block to the document open in the editor",
|
||||
"ai.steps_count": "{count} step",
|
||||
"ai.steps_count_plural": "{count} steps",
|
||||
"ai.activity_thinking": "Thinking…",
|
||||
|
||||
@@ -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",
|
||||
@@ -1288,6 +1290,7 @@
|
||||
"help.assistant_panel": "🧠 Panneau Assistant (BooksLM)",
|
||||
"help.assistant_panel_desc": "L'assistant latéral (bouton flottant ou menu contextuel d'un dossier) répond en Markdown formaté et contextualise vos répertoires ou documents. En contexte général, il connaît aussi ce que vous voyez : documents ouverts, répertoire courant, recherche en cours et fichiers récemment modifiés.",
|
||||
"help.assistant_markdown": "Réponses formatées : titres, listes, tableaux, citations et blocs de code.",
|
||||
"help.assistant_insert": "Le bouton « Ajouter » (au survol d'une réponse) insère la réponse dans le document ouvert dans l'éditeur (Editer ou Forge) ; chaque bloc de code propose « Ajouter la section » pour n'insérer que ce bloc.",
|
||||
"help.assistant_links": "Les fichiers et chemins cités sont des liens : un simple nom de fichier copie le nom dans le presse-papiers, un dossier est révélé dans l'arborescence, et un chemin de fichier l'ouvre dans le viewer.",
|
||||
"help.assistant_sessions": "L'icône historique de l'en-tête liste les sessions passées (recharger ou supprimer) ; « + » démarre une nouvelle conversation.",
|
||||
"help.assistant_agent": "Le bouton « mode agent » active les outils (lire, lister, chercher) ; les actions de modification demandent une confirmation avec aperçu des changements.",
|
||||
@@ -1783,6 +1786,8 @@
|
||||
"bookslm.insert_hint": "Ajouter la réponse au document ouvert dans l'éditeur",
|
||||
"bookslm.inserted": "Réponse ajoutée au document",
|
||||
"bookslm.insert_no_editor": "Aucun document ouvert dans l'éditeur",
|
||||
"bookslm.insert_block": "Ajouter la section",
|
||||
"bookslm.insert_block_hint": "Ajouter uniquement ce bloc de code au document ouvert dans l'éditeur",
|
||||
"ai.steps_count": "{count} étape",
|
||||
"ai.steps_count_plural": "{count} étapes",
|
||||
"ai.activity_thinking": "Réflexion…",
|
||||
|
||||
@@ -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
|
||||
@@ -3575,6 +3592,20 @@ select {
|
||||
overflow-x: auto !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
/* BUG-058: CodeMirror paints its line-number gutter with hardcoded light
|
||||
defaults (#f5f5f5 background, #ddd border), so the bar stayed pale in dark
|
||||
themes while the editor body followed the theme. Deriving the tint from
|
||||
`--text-primary` keeps the gutter subtle and makes it adapt to every theme
|
||||
and mode (dark, light, high-contrast, sepia). */
|
||||
.cm-editor .cm-gutters {
|
||||
background: color-mix(in srgb, var(--text-primary) 5%, transparent) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
border-right: 1px solid var(--border) !important;
|
||||
}
|
||||
.cm-editor .cm-gutters .cm-activeLineGutter {
|
||||
background: color-mix(in srgb, var(--text-primary) 10%, transparent) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
.fallback-editor {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
@@ -9514,6 +9545,14 @@ body.popup-mode .content-area {
|
||||
.bookslm-bubble.assistant { align-self: flex-start; background: transparent; padding: 0; width: 100%; max-width: 100%; color: var(--text-primary); }
|
||||
.bookslm-bubble.assistant code { background: rgba(0,0,0,0.2); padding: 1px 4px; border-radius: 3px; font-size: 0.9em; }
|
||||
.bookslm-bubble.assistant pre { background: rgba(0,0,0,0.3); padding: 10px; border-radius: 6px; overflow-x: auto; margin: 8px 0; }
|
||||
/* #102 — per-code-block “Ajouter”: a discreet button in the block's corner,
|
||||
revealed on hover/focus, that inserts only this section. */
|
||||
.bookslm-code-block { position: relative; }
|
||||
.bookslm-code-block .bookslm-code-insert { position: absolute; top: 6px; right: 6px;
|
||||
z-index: 1; background: var(--surface2); border-color: var(--border); opacity: 0;
|
||||
transition: opacity 0.15s; }
|
||||
.bookslm-code-block:hover .bookslm-code-insert,
|
||||
.bookslm-code-block:focus-within .bookslm-code-insert { opacity: 1; }
|
||||
.bookslm-sources { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }
|
||||
.bookslm-source-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.7.2",
|
||||
"version": "2.9.1",
|
||||
"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": {
|
||||
|
||||
@@ -404,6 +404,73 @@ async function main() {
|
||||
panel.remove();
|
||||
});
|
||||
|
||||
await test("add action delegates to the Forge iframe when no CodeMirror is open (BUG-057)", () => {
|
||||
const b = new BooksLM();
|
||||
const panel = b._render();
|
||||
b._panel = panel;
|
||||
document.body.appendChild(panel);
|
||||
b._messages = [{ role: "assistant", content: "Réponse Forge" }];
|
||||
b._renderMessages();
|
||||
state.editorView = null;
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.id = "forge-iframe";
|
||||
document.body.appendChild(iframe);
|
||||
const posted = [];
|
||||
iframe.contentWindow.postMessage = (msg) => posted.push(msg);
|
||||
panel.querySelector(".bookslm-msg.assistant .bookslm-msg-action-insert").click();
|
||||
assert.equal(posted.length, 1, "one postMessage to the Forge iframe");
|
||||
assert.equal(posted[0].type, "parent-insert");
|
||||
assert.ok(posted[0].text.includes("Réponse Forge"), "answer forwarded");
|
||||
iframe.remove();
|
||||
panel.remove();
|
||||
});
|
||||
|
||||
await test("add action falls back to the plain textarea editor", () => {
|
||||
const b = new BooksLM();
|
||||
const panel = b._render();
|
||||
b._panel = panel;
|
||||
document.body.appendChild(panel);
|
||||
b._messages = [{ role: "assistant", content: "Réponse textarea" }];
|
||||
b._renderMessages();
|
||||
state.editorView = null;
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = "Ligne existante";
|
||||
document.body.appendChild(ta);
|
||||
ta.setSelectionRange(ta.value.length, ta.value.length);
|
||||
state.fallbackEditorEl = ta;
|
||||
panel.querySelector(".bookslm-msg.assistant .bookslm-msg-action-insert").click();
|
||||
assert.ok(ta.value.includes("Réponse textarea"), "answer appended to the textarea");
|
||||
state.fallbackEditorEl = null;
|
||||
ta.remove();
|
||||
panel.remove();
|
||||
});
|
||||
|
||||
await test("code block exposes an add-section button inserting only the block (#102)", () => {
|
||||
const b = new BooksLM();
|
||||
const panel = b._render();
|
||||
b._panel = panel;
|
||||
document.body.appendChild(panel);
|
||||
b._messages = [{
|
||||
role: "assistant",
|
||||
content: "Voici la section :\n\n```markdown\n## Titre\n\nContenu\n```\n\nFin.",
|
||||
}];
|
||||
b._renderMessages();
|
||||
const btn = panel.querySelector(".bookslm-msg.assistant .bookslm-code-insert");
|
||||
assert.ok(btn, "per-block add button rendered");
|
||||
const dispatched = [];
|
||||
state.editorView = {
|
||||
state: { selection: { main: { to: 0 } } },
|
||||
dispatch: (spec) => dispatched.push(spec),
|
||||
focus: () => {},
|
||||
};
|
||||
btn.click();
|
||||
assert.equal(dispatched.length, 1, "editor dispatch called");
|
||||
assert.ok(dispatched[0].changes.insert.includes("## Titre"), "block content inserted");
|
||||
assert.ok(!dispatched[0].changes.insert.includes("```"), "code fences stripped");
|
||||
state.editorView = null;
|
||||
panel.remove();
|
||||
});
|
||||
|
||||
await test("manual wheel scroll releases the top-pinning", async () => {
|
||||
const b = new BooksLM();
|
||||
const panel = b._render();
|
||||
|
||||
@@ -275,14 +275,23 @@ test("utils.js detachInlineEditor tears the session down without re-rendering",
|
||||
});
|
||||
|
||||
test("utils.js reloads the displayed document after an AI write", () => {
|
||||
const fn = utilsSrc.match(/async function reloadExternalWrite\(vault, path\) \{([\s\S]*?)\n\}/);
|
||||
const fn = utilsSrc.match(/async function reloadExternalWrite\(vault, path, force = false\) \{([\s\S]*?)\n\}/);
|
||||
assert.ok(fn, "reloadExternalWrite not found");
|
||||
assert.match(fn[1], /postMessage\(\{ type: 'parent-reload' \}, '\*'\)/);
|
||||
assert.match(fn[1], /postMessage\(\{ type: 'parent-reload', force: !!force \}, '\*'\)/);
|
||||
assert.match(fn[1], /suppressAutoSaveOnce = true;/);
|
||||
assert.match(utilsSrc, /window\.addEventListener\("obsigate:file-written"/);
|
||||
assert.match(utilsSrc, /if \(suppressAutoSaveOnce\) \{/, "autosave skipped on programmatic reload");
|
||||
});
|
||||
|
||||
test("utils.js does not clobber unsaved edits on a non-forced reload (BUG-055)", () => {
|
||||
const fn = utilsSrc.match(/async function reloadExternalWrite\(vault, path, force = false\) \{([\s\S]*?)\n\}/);
|
||||
assert.ok(fn, "reloadExternalWrite not found");
|
||||
// Guard before the CodeMirror dispatch
|
||||
assert.match(fn[1], /if \(!force && dirtyDot && dirtyDot\.classList\.contains\("dirty"\)\) return;/);
|
||||
// The AI write forces the reload past unsaved local changes
|
||||
assert.match(utilsSrc, /reloadExternalWrite\(detail\.vault, detail\.path, true\);/);
|
||||
});
|
||||
|
||||
test("sync.js forge-close goes through the shared close path", () => {
|
||||
assert.ok(syncSrc.includes("import { closeEditor, reloadExternalWrite } from './utils.js';"), "utils import missing");
|
||||
const handler = syncSrc.match(/if \(e\.data\.type === 'forge-close'\) \{([\s\S]*?)\n \}/);
|
||||
@@ -290,6 +299,14 @@ test("sync.js forge-close goes through the shared close path", () => {
|
||||
assert.match(handler[1], /closeEditor\(\);/);
|
||||
});
|
||||
|
||||
test("sync.js leaves fullscreen before opening the Forge assistant (BUG-056)", () => {
|
||||
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], /document\.fullscreenElement/);
|
||||
assert.match(handler[1], /document\.exitFullscreen\(\)/);
|
||||
assert.match(handler[1], /openForCurrentContext\(\)/);
|
||||
});
|
||||
|
||||
test("sync.js keeps an open edition session alive on external file changes", () => {
|
||||
const sse = syncSrc.match(/const changed = \(data\.changes \|\| \[\]\)([\s\S]*?)\n \}/);
|
||||
assert.ok(sse, "SSE index_updated refresh block not found");
|
||||
@@ -330,6 +347,13 @@ test("editor-poc.html reloads Forge buffer on parent-reload", () => {
|
||||
assert.match(handler[1], /loadFile\(\);/);
|
||||
});
|
||||
|
||||
test("editor-poc.html inserts assistant text on parent-insert (BUG-057)", () => {
|
||||
assert.match(forgeSrc, /if \(e\.data\.type === 'parent-insert' && typeof e\.data\.text === 'string'\) \{/);
|
||||
const handler = forgeSrc.match(/if \(e\.data\.type === 'parent-insert' && typeof e\.data\.text === 'string'\) \{([\s\S]*?)\n \}/);
|
||||
assert.ok(handler, "parent-insert handler not found");
|
||||
assert.match(handler[1], /insertAtCursor\(/);
|
||||
});
|
||||
|
||||
test("style.css lets the inline editor fill the content area", () => {
|
||||
assert.match(cssSrc, /\.editor-modal\.editor-inline-mode \{/);
|
||||
assert.match(cssSrc, /\.editor-modal\.editor-inline-mode \{[\s\S]*?pointer-events: none;/);
|
||||
@@ -356,6 +380,115 @@ test("style.css: #editor-body scrolls through the CodeMirror scroller only", ()
|
||||
"global .cm-scroller min-height override reintroduces the double scrollbar");
|
||||
});
|
||||
|
||||
// ── BUG-058: the line-number gutter follows the active theme ──
|
||||
test("style.css themes the CodeMirror line-number gutter with CSS variables", () => {
|
||||
const gutter = cssSrc.match(/\.cm-editor \.cm-gutters \{([^}]*)\}/);
|
||||
assert.ok(gutter, "themed .cm-gutters rule not found");
|
||||
assert.match(gutter[1], /background:\s*color-mix\([^;]*var\(--text-primary\)/,
|
||||
"gutter background must derive from the theme, not CodeMirror's hardcoded #f5f5f5");
|
||||
assert.match(gutter[1], /color:\s*var\(--text-secondary\)/);
|
||||
assert.match(gutter[1], /border-right:\s*1px solid var\(--border\)/);
|
||||
const active = cssSrc.match(/\.cm-editor \.cm-gutters \.cm-activeLineGutter \{([^}]*)\}/);
|
||||
assert.ok(active, "themed .cm-activeLineGutter rule not found");
|
||||
assert.match(active[1], /color-mix\([^;]*var\(--text-primary\)/);
|
||||
});
|
||||
|
||||
// ── 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 = '✓'/);
|
||||
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`);
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — Forge autocomplete tests (BUG-055).
|
||||
*
|
||||
* The Forge editor (`frontend/editor-poc.html`) shares its pure completion
|
||||
* helpers with CodeMirror through `frontend/js/autocomplete.js`. This suite
|
||||
* pins the behaviour that fixes the « Tab adds a stray space » bug:
|
||||
*
|
||||
* - getWordFragment / findWordCompletions — document word completion
|
||||
* - normalizeGhost — AI inline prediction cleanup
|
||||
* - chooseTabAction — single action per Tab press
|
||||
*
|
||||
* Usage: node tests/frontend/forge-completion.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
||||
|
||||
const mod = await import(
|
||||
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "autocomplete.js")).href
|
||||
);
|
||||
const {
|
||||
isWordChar,
|
||||
getWordFragment,
|
||||
findWordCompletions,
|
||||
normalizeGhost,
|
||||
chooseTabAction,
|
||||
} = mod;
|
||||
|
||||
let testCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
testCount++;
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
} catch (err) {
|
||||
failCount++;
|
||||
console.error(` ✗ ${name}\n ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── isWordChar ─────────────────────────────────────────────────────────────
|
||||
test("isWordChar accepts letters, digits, _ - . /", () => {
|
||||
for (const ch of ["a", "Z", "0", "_", "-", ".", "/"]) {
|
||||
assert.equal(isWordChar(ch), true, `expected ${JSON.stringify(ch)} to be a word char`);
|
||||
}
|
||||
});
|
||||
|
||||
test("isWordChar rejects spaces and punctuation", () => {
|
||||
for (const ch of [" ", "\n", "!", "@", "#", "(", "\t"]) {
|
||||
assert.equal(isWordChar(ch), false, `expected ${JSON.stringify(ch)} not to be a word char`);
|
||||
}
|
||||
assert.equal(isWordChar(""), false);
|
||||
assert.equal(isWordChar(undefined), false);
|
||||
});
|
||||
|
||||
// ── getWordFragment ────────────────────────────────────────────────────────
|
||||
test("getWordFragment returns the fragment before the cursor", () => {
|
||||
const text = "hello wor";
|
||||
assert.deepEqual(getWordFragment(text, text.length), { start: 6, fragment: "wor" });
|
||||
});
|
||||
|
||||
test("getWordFragment stops at non-word characters", () => {
|
||||
const text = "a (tabl";
|
||||
assert.deepEqual(getWordFragment(text, text.length), { start: 3, fragment: "tabl" });
|
||||
});
|
||||
|
||||
test("getWordFragment returns an empty fragment at a word boundary", () => {
|
||||
const text = "hello ";
|
||||
assert.deepEqual(getWordFragment(text, text.length), { start: 6, fragment: "" });
|
||||
});
|
||||
|
||||
test("getWordFragment handles a mid-document cursor", () => {
|
||||
const text = "one two three";
|
||||
// cursor right after "tw"
|
||||
assert.deepEqual(getWordFragment(text, 6), { start: 4, fragment: "tw" });
|
||||
});
|
||||
|
||||
// ── findWordCompletions ────────────────────────────────────────────────────
|
||||
test("findWordCompletions returns prefix matches from the document", () => {
|
||||
const text = "table tableau tab\n";
|
||||
const cursor = text.length;
|
||||
assert.deepEqual(findWordCompletions(text, cursor, "tab", 8), ["table", "tableau"]);
|
||||
});
|
||||
|
||||
test("findWordCompletions ignores the occurrence being typed", () => {
|
||||
const text = "table and tab";
|
||||
const cursor = text.length; // typing the trailing "tab"
|
||||
assert.deepEqual(findWordCompletions(text, cursor, "tab", 8), ["table"]);
|
||||
});
|
||||
|
||||
test("findWordCompletions is case-insensitive and unique", () => {
|
||||
const text = "Table TABLE table\n";
|
||||
const cursor = text.length;
|
||||
assert.deepEqual(findWordCompletions(text, cursor, "tab", 8), ["Table"]);
|
||||
});
|
||||
|
||||
test("findWordCompletions completes file-like tokens", () => {
|
||||
const text = "docs/guide.md docs/guide\n";
|
||||
const cursor = text.length;
|
||||
assert.deepEqual(findWordCompletions(text, cursor, "docs/", 8), ["docs/guide.md", "docs/guide"]);
|
||||
});
|
||||
|
||||
test("findWordCompletions honours the limit", () => {
|
||||
const text = "abc abd abe abf abg\n";
|
||||
const cursor = text.length;
|
||||
assert.equal(findWordCompletions(text, cursor, "ab", 3).length, 3);
|
||||
});
|
||||
|
||||
test("findWordCompletions needs at least two characters", () => {
|
||||
const text = "apple apricot\n";
|
||||
assert.deepEqual(findWordCompletions(text, text.length, "a", 8), []);
|
||||
assert.deepEqual(findWordCompletions(text, text.length, "", 8), []);
|
||||
});
|
||||
|
||||
// ── normalizeGhost ─────────────────────────────────────────────────────────
|
||||
test("normalizeGhost strips an echoed prefix (mid-word)", () => {
|
||||
assert.equal(normalizeGhost("familial", "famil", true), "ial");
|
||||
});
|
||||
|
||||
test("normalizeGhost keeps only the first token for a word completion", () => {
|
||||
// A word completion must never introduce a space.
|
||||
assert.equal(normalizeGhost("familial and more", "famil", true), "ial");
|
||||
});
|
||||
|
||||
test("normalizeGhost tolerates a leading space from the model", () => {
|
||||
assert.equal(normalizeGhost(" monde", "bonjour", false), "monde");
|
||||
});
|
||||
|
||||
test("normalizeGhost strips a repeated full context", () => {
|
||||
assert.equal(normalizeGhost("Bonjour le monde", "Bonjour", false), " le monde".trim());
|
||||
});
|
||||
|
||||
test("normalizeGhost returns an empty string for empty input", () => {
|
||||
assert.equal(normalizeGhost("", "abc", true), "");
|
||||
assert.equal(normalizeGhost(null, "abc", true), "");
|
||||
assert.equal(normalizeGhost(" ", "abc", false), "");
|
||||
});
|
||||
|
||||
test("normalizeGhost keeps a phrase continuation intact", () => {
|
||||
assert.equal(normalizeGhost("la suite de la phrase.", "Voici", false), "la suite de la phrase.");
|
||||
});
|
||||
|
||||
// ── chooseTabAction ────────────────────────────────────────────────────────
|
||||
test("chooseTabAction prioritises the open list", () => {
|
||||
assert.equal(chooseTabAction({ dropdownOpen: true, ghost: true }), "dropdown");
|
||||
});
|
||||
|
||||
test("chooseTabAction accepts a ghost prediction before word completion", () => {
|
||||
assert.equal(chooseTabAction({ ghost: true, candidates: ["table"] }), "ghost");
|
||||
});
|
||||
|
||||
test("chooseTabAction inserts a single word match", () => {
|
||||
assert.equal(chooseTabAction({ candidates: ["table"] }), "word");
|
||||
});
|
||||
|
||||
test("chooseTabAction opens the list for several matches", () => {
|
||||
assert.equal(chooseTabAction({ candidates: ["table", "tableau"] }), "word-list");
|
||||
});
|
||||
|
||||
test("chooseTabAction indents when nothing matches", () => {
|
||||
assert.equal(chooseTabAction({ candidates: [] }), "indent");
|
||||
assert.equal(chooseTabAction({}), "indent");
|
||||
});
|
||||
|
||||
test("chooseTabAction dedents on Shift+Tab", () => {
|
||||
assert.equal(chooseTabAction({ shiftKey: true }), "dedent");
|
||||
assert.equal(chooseTabAction({ shiftKey: true, candidates: ["table"] }), "dedent");
|
||||
});
|
||||
|
||||
test("chooseTabAction indents when a selection exists", () => {
|
||||
assert.equal(chooseTabAction({ hasSelection: true, candidates: ["table"] }), "indent");
|
||||
});
|
||||
|
||||
// ── Static wiring of frontend/editor-poc.html ──────────────────────────────
|
||||
import { readFileSync } from "node:fs";
|
||||
const FORGE_HTML = readFileSync(
|
||||
path.join(REPO_ROOT, "frontend", "editor-poc.html"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
test("Forge uses the shared completion helpers", () => {
|
||||
for (const call of ["ac.getWordFragment(", "ac.findWordCompletions(", "ac.chooseTabAction("]) {
|
||||
assert.ok(FORGE_HTML.includes(call), `editor-poc.html must call ${call}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("Forge has a single indentation call site (no double Tab handling)", () => {
|
||||
const indentCalls = FORGE_HTML.match(/indentLine\(\);/g) || [];
|
||||
assert.equal(indentCalls.length, 1, "indentLine() must only be called from the unified Tab handler");
|
||||
});
|
||||
|
||||
test("Forge ghost text no longer mirrors the whole document", () => {
|
||||
assert.ok(!FORGE_HTML.includes("ghostOverlay.innerHTML"), "the mirror overlay must be gone");
|
||||
});
|
||||
|
||||
test("Forge ignores its own autosave reload while the buffer is dirty (BUG-055)", () => {
|
||||
assert.ok(
|
||||
FORGE_HTML.includes("if (!e.data.force && isDirty) return;"),
|
||||
"parent-reload must not clobber unsaved local edits",
|
||||
);
|
||||
});
|
||||
|
||||
test("Forge cancels a pending ghost request when the prediction is accepted", () => {
|
||||
const fn = FORGE_HTML.match(/function acceptGhost\(\) \{([\s\S]*?)\n \}/);
|
||||
assert.ok(fn, "acceptGhost not found");
|
||||
assert.match(fn[1], /clearTimeout\(_ghostTimer\)/);
|
||||
});
|
||||
|
||||
test("Forge autosave keeps the buffer dirty if edits arrive during the request", () => {
|
||||
const fn = FORGE_HTML.match(/function autoSave\(\) \{([\s\S]*?)\n \}/);
|
||||
assert.ok(fn, "autoSave not found");
|
||||
assert.match(fn[1], /if \(val\(\) !== content\) \{ scheduleAutoSave\(\); return; \}/);
|
||||
});
|
||||
|
||||
test("Forge leaves fullscreen before opening the shared assistant", () => {
|
||||
const fn = FORGE_HTML.match(/function openAssistant\(\) \{([\s\S]*?)\n \}/);
|
||||
assert.ok(fn, "openAssistant not found");
|
||||
assert.match(fn[1], /document\.fullscreenElement/);
|
||||
assert.match(fn[1], /document\.exitFullscreen\(\)/);
|
||||
assert.match(fn[1], /postMessage\(\{ type: 'forge-open-ai' \}, '\*'\)/);
|
||||
});
|
||||
|
||||
// ── Summary ────────────────────────────────────────────────────────────────
|
||||
console.log(`\n${testCount} passed, ${failCount} failed`);
|
||||
process.exit(failCount > 0 ? 1 : 0);
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user