feat(ai): menu @ instantane, selecteurs agrandis et BooksLM racine (#82)
Nouvel endpoint GET /api/vault/{vault}/paths + prechargement et filtrage client du menu @ (affichage instantane des fichiers/repertoires). Selecteurs Fournisseur/Modele agrandis (0,8rem / 34px) pour s'aligner sur le reste du site. Ajout de BooksLM au menu contextuel de la racine des vaults.
This commit is contained in:
+13
-4
@@ -14,6 +14,12 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#82 Assistant IA — menu `@` instantané** — nouvel endpoint `GET /api/vault/{vault}/paths`
|
||||
(liste plate et plafonnée de l'index des chemins). Le frontend la récupère **une fois par vault**
|
||||
(préchargée à l'ouverture du panneau) puis filtre **côté client** pendant la frappe : l'affichage
|
||||
des fichiers/répertoires est immédiat, au lieu d'une requête réseau par caractère. Tests :
|
||||
`tests/test_api_main.py` (+2), `tests/frontend/ai.test.mjs` (+1).
|
||||
|
||||
- **#82 Assistant IA — Section « Fournisseur & modèle » compacte** — la barre de sélection du
|
||||
modèle devient **discrète** : la liste complète des types d'endpoints (Chat, Embeddings, Vision…)
|
||||
est remplacée par un **bouton d'information ⓘ** qui affiche les capacités dans une **bulle** au
|
||||
@@ -229,10 +235,13 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
statut masqué, description accessible au **survol** de l'en-tête) ; le placeholder
|
||||
« Posez une question sur ces documents… » est retiré ; l'indice clavier
|
||||
« Entrée pour envoyer · Ctrl+Entrée… » est retiré ; le bouton « Envoyer » devient un **emoji
|
||||
compact** (✈️). Un **indicateur d'activité** apparaît pendant le traitement et détaille le
|
||||
workflow : envoi, réception de la réponse, appels d'outils (`Outil : X…`), attente de
|
||||
confirmation, succès ou échec. i18n FR/EN (`ai.activity_*`, `ai.composer_label`). Tests JSDOM
|
||||
(`tests/frontend/ai.test.mjs` +1).
|
||||
compact** (✈️). Les sélecteurs Fournisseur/Modèle adoptent la **taille des autres contrôles du
|
||||
site** (police 0,8 rem, hauteur 34 px, rayon 6 px) au lieu d'être plus fins. Un **indicateur
|
||||
d'activité** apparaît pendant le traitement et détaille le workflow : envoi, réception de la
|
||||
réponse, appels d'outils (`Outil : X…`), attente de confirmation, succès ou échec. Le menu
|
||||
contextuel de la **racine d'une vault** expose aussi « 🧠 BooksLM », comme les répertoires.
|
||||
i18n FR/EN (`ai.activity_*`, `ai.composer_label`). Tests JSDOM
|
||||
(`tests/frontend/ai.test.mjs` +2).
|
||||
|
||||
### Corrigé
|
||||
|
||||
|
||||
+39
-1
@@ -136,7 +136,7 @@ from backend.services.mutations import (
|
||||
restore_backup as service_restore_backup,
|
||||
)
|
||||
from backend.services.recent import humanize_mtime, list_recent
|
||||
from backend.services.search import advanced_search_vaults, search_paths, search_vaults
|
||||
from backend.services.search import advanced_search_vaults, list_paths, search_paths, search_vaults
|
||||
from backend.services.search import list_tags as service_list_tags
|
||||
from backend.services.vaults import (
|
||||
browse_directory,
|
||||
@@ -276,6 +276,21 @@ class TreeSearchResponse(BaseModel):
|
||||
results: list[TreeSearchResult] = Field(description="Matching files and directories")
|
||||
|
||||
|
||||
class VaultPathEntry(BaseModel):
|
||||
"""A single indexed path (file or directory) in a vault."""
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Full relative path")
|
||||
name: str = Field(description="File or directory name")
|
||||
type: str = Field(description="'file' or 'directory'")
|
||||
|
||||
|
||||
class VaultPathsResponse(BaseModel):
|
||||
"""Flat list of every indexed path in a vault (capped)."""
|
||||
vault: str = Field(description="Vault name")
|
||||
count: int = Field(description="Number of returned entries")
|
||||
results: list[VaultPathEntry] = Field(description="Indexed files and directories")
|
||||
|
||||
|
||||
class AdvancedSearchResultItem(BaseModel):
|
||||
"""A single advanced search result with highlighted snippet."""
|
||||
vault: str = Field(description="Vault name")
|
||||
@@ -2580,6 +2595,29 @@ async def api_tree_search(
|
||||
return search_paths(q, vault)
|
||||
|
||||
|
||||
@app.get("/api/vault/{vault_name}/paths", response_model=VaultPathsResponse)
|
||||
async def api_vault_paths(
|
||||
vault_name: str,
|
||||
limit: int = Query(5000, ge=1, le=20000, description="Maximum number of indexed paths to return"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return a flat list of every indexed file and directory in a vault.
|
||||
|
||||
Used by the AI assistant ``@`` mention menu to filter paths instantly on
|
||||
the client (one request instead of one per keystroke).
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
limit: Maximum number of entries returned.
|
||||
|
||||
Returns:
|
||||
``VaultPathsResponse`` with the vault's indexed paths.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
return list_paths(vault_name, limit=limit)
|
||||
|
||||
|
||||
@app.get("/api/search/advanced", response_model=AdvancedSearchResponse)
|
||||
async def api_advanced_search(
|
||||
q: str = Query("", description="Advanced search query (supports tag:, vault:, title:, path:, ext: operators)"),
|
||||
|
||||
@@ -113,3 +113,32 @@ def search_paths(q: str, vault: str = "all") -> dict[str, Any]:
|
||||
})
|
||||
|
||||
return {"query": q, "vault_filter": vault, "results": results}
|
||||
|
||||
|
||||
def list_paths(vault: str, limit: int = 5000) -> dict[str, Any]:
|
||||
"""Return a flat, capped list of every indexed path in a vault.
|
||||
|
||||
Backs the AI assistant ``@`` mention menu: fetching the whole path index
|
||||
once lets the client filter files/directories instantly instead of issuing
|
||||
a request per keystroke.
|
||||
|
||||
Args:
|
||||
vault: Vault name.
|
||||
limit: Maximum number of entries returned.
|
||||
|
||||
Returns:
|
||||
``{"vault", "count", "results": [{vault, path, name, type}]}``.
|
||||
"""
|
||||
from backend.indexer import path_index
|
||||
|
||||
entries = path_index.get(vault, [])
|
||||
results = [
|
||||
{
|
||||
"vault": vault,
|
||||
"path": entry["path"],
|
||||
"name": entry["name"],
|
||||
"type": entry["type"],
|
||||
}
|
||||
for entry in entries[: max(0, limit)]
|
||||
]
|
||||
return {"vault": vault, "count": len(results), "results": results}
|
||||
|
||||
@@ -154,6 +154,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-09-12 | BUG-012, #82 | Correction + UX | `frontend/js/bookslm.js`, `frontend/js/ai.js`, `frontend/style.css`, `frontend/sw.js`, `frontend/locales/{fr,en}.json`, `tests/frontend/ai.test.mjs` | BUG-012 : le menu `@` est toujours rendu et `_mentionVault()` retombe sur le vault de contexte puis le premier vault disponible. #82 : suppression des intitulés « Fournisseur & modèle » / « Fournisseur : », description du contexte Général déplacée en info-bulle, placeholder retiré, bouton Envoyer en emoji ✈️, indicateur d'activité (envoi/réception/outils/confirmation/succès/échec), `SW_VERSION` v5. Vérifié Playwright + tests frontend 45/45. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-12 | BUG-013, BUG-014 | Correction | `frontend/js/bookslm.js`, `frontend/index.html`, `frontend/sw.js`, `tests/frontend/ai.test.mjs` | BUG-013 : liens fichiers/dossiers via `_activeVault()` (vault de contexte → sélection → premier vault). BUG-014 : navigation ↑/↓ gérée au niveau du panneau en phase de capture. Purge des caches élargie (`SW_VERSION` v6, migration `obsigate-*`). Vérifié : tests frontend 47/47, pytest 930. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-12 | BUG-015, #82 | Correction + UX | `frontend/js/bookslm.js`, `frontend/style.css`, `tests/frontend/ai.test.mjs` | BUG-015 : sélection des menus `/` et `@` rendue visible (`--bg-hover` + barre d'accent à gauche) car `--surface2` = `--bg-primary`. #82 : retrait de l'indice clavier sous la zone de saisie. Vérifié Playwright (contraste `#1f2430` vs `#0f1117` + barre accent) et tests frontend 47/47. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-12 | #82 | Amélioration perf + UX | `backend/services/search.py`, `backend/main.py`, `frontend/js/bookslm.js`, `frontend/js/ui.js`, `frontend/style.css`, `tests/test_api_main.py`, `tests/frontend/ai.test.mjs` | #82 : endpoint `GET /api/vault/{vault}/paths` + préchargement et filtrage client du menu `@` (instantané) ; sélecteurs Fournisseur/Modèle agrandis (0,8 rem / 34 px) ; « 🧠 BooksLM » ajouté au menu contextuel de la racine des vaults. Vérifié : pytest 151 (ciblés) + ruff/mypy, tests frontend 49/49. | ✅ livré (en attente vérif utilisateur) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -76,7 +76,17 @@
|
||||
barre d'accent à gauche (`--surface2` étant identique à `--bg-primary` en thème sombre,
|
||||
la sélection était invisible) — BUG-015.
|
||||
|
||||
## H. Points d'attention
|
||||
## H. Performance `@`, sélecteurs agrandis & BooksLM racine (#82) — ✅ livré
|
||||
- [x] Nouvel endpoint `GET /api/vault/{vault}/paths` (`backend/services/search.py: list_paths`,
|
||||
`backend/main.py`) : index des chemins plat et plafonné (5000 par défaut).
|
||||
- [x] Frontend : `_ensurePaths()` précharge cette liste **une fois par vault** (au `openContext`),
|
||||
puis `_showMentionMenu` filtre **côté client** → affichage instantané des fichiers/
|
||||
répertoires, sans requête par frappe (repli serveur si le cache est vide).
|
||||
- [x] Sélecteurs Fournisseur/Modèle agrandis pour s'aligner sur les autres contrôles
|
||||
(police 0,8 rem, hauteur 34 px, rayon 6 px).
|
||||
- [x] Menu contextuel de la **racine d'une vault** : ajout de « 🧠 BooksLM » (`frontend/js/ui.js`).
|
||||
|
||||
## I. Points d'attention
|
||||
- La table de capacités reste **statique** (`backend/model_capabilities.py`) : un modèle inconnu
|
||||
retombe sur le défaut du fournisseur.
|
||||
- Le select masqué est conservé uniquement pour la rétro-compatibilité ; ne pas le supprimer sans
|
||||
|
||||
+42
-1
@@ -109,6 +109,10 @@ class BooksLM {
|
||||
this._menuSeq = 0;
|
||||
// Timer that hides the "done" activity indicator after a short delay.
|
||||
this._activityHideTimer = null;
|
||||
// Cache of every indexed path per vault, backing instant `@` filtering.
|
||||
this._pathCache = null;
|
||||
this._pathCacheVault = null;
|
||||
this._pathCachePromise = null;
|
||||
}
|
||||
|
||||
_readAgentMode() {
|
||||
@@ -214,6 +218,8 @@ class BooksLM {
|
||||
|
||||
await this._loadContext();
|
||||
this._isLoading = false;
|
||||
// Warm the `@` path cache so the mention menu appears instantly.
|
||||
this._ensurePaths().catch(() => { /* best effort */ });
|
||||
}
|
||||
|
||||
/** Backwards-compatible helper: open the assistant on a directory. */
|
||||
@@ -349,6 +355,31 @@ class BooksLM {
|
||||
return (first && (first.name || first)) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch (once per vault) the flat list of indexed paths so the `@` menu can
|
||||
* filter files/directories instantly on the client. Falls back to an empty
|
||||
* list on failure; callers then use the tree-search endpoint.
|
||||
*/
|
||||
async _ensurePaths() {
|
||||
const vault = this._activeVault();
|
||||
if (!vault) return [];
|
||||
if (this._pathCache && this._pathCacheVault === vault) return this._pathCache;
|
||||
if (this._pathCachePromise && this._pathCacheVault === vault) return this._pathCachePromise;
|
||||
this._pathCacheVault = vault;
|
||||
this._pathCachePromise = api(`/api/vault/${encodeURIComponent(vault)}/paths?limit=5000`)
|
||||
.then((data) => {
|
||||
const paths = Array.isArray(data.results) ? data.results : [];
|
||||
this._pathCache = paths;
|
||||
return paths;
|
||||
})
|
||||
.catch(() => {
|
||||
this._pathCache = [];
|
||||
return [];
|
||||
})
|
||||
.finally(() => { this._pathCachePromise = null; });
|
||||
return this._pathCachePromise;
|
||||
}
|
||||
|
||||
/** Open a file mentioned in an assistant answer in the main viewer. */
|
||||
async _openFileLink(path) {
|
||||
const vault = this._activeVault();
|
||||
@@ -856,9 +887,19 @@ class BooksLM {
|
||||
if (!this._panel) return;
|
||||
const seq = ++this._menuSeq;
|
||||
const vault = this._activeVault();
|
||||
const q = normalizeSearch(query);
|
||||
try {
|
||||
let results = [];
|
||||
if (query) {
|
||||
// Fast path: filter the prefetched flat path list on the client so the
|
||||
// menu updates instantly while typing.
|
||||
const cached = await this._ensurePaths();
|
||||
if (seq !== this._menuSeq) return;
|
||||
if (cached.length) {
|
||||
const matches = q
|
||||
? cached.filter((p) => normalizeSearch(p.path).includes(q))
|
||||
: cached;
|
||||
results = matches.slice(0, 20);
|
||||
} else if (query) {
|
||||
const searchVault = vault || 'all';
|
||||
const data = await api(
|
||||
`/api/tree-search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(searchVault)}`
|
||||
|
||||
@@ -1705,6 +1705,8 @@ export const ContextMenuManager = {
|
||||
if (type === 'vault') {
|
||||
this._addItem('folder-plus', 'Nouveau dossier', () => this._createDirectory(), isReadonly);
|
||||
this._addItem('file-plus', 'Nouveau fichier', () => this._createFile(), isReadonly);
|
||||
this._addSeparator();
|
||||
this._addItem('brain', '🧠 BooksLM', () => { import('./bookslm.js').then(m => m.default.open(this._targetVault, '')); }, false);
|
||||
} else if (type === 'directory') {
|
||||
this._addItem('folder-plus', 'Nouveau sous-dossier', () => this._createDirectory(), isReadonly);
|
||||
this._addItem('file-plus', 'Nouveau fichier ici', () => this._createFile(), isReadonly);
|
||||
|
||||
+24
-19
@@ -9193,7 +9193,7 @@ body.popup-mode .content-area {
|
||||
text-transform: uppercase; letter-spacing: .04em; flex-shrink: 0; }
|
||||
.bookslm-toolbar .ai-picker { margin-left: 0; padding-left: 0; border-left: none;
|
||||
flex-wrap: wrap; row-gap: 4px; }
|
||||
.bookslm-toolbar .ai-picker select { max-width: 200px; }
|
||||
.bookslm-toolbar .ai-picker select { max-width: 220px; }
|
||||
.bookslm-panel.fullscreen { width: 100vw; }
|
||||
.bookslm-status { padding: 6px 16px; font-size: 12px; color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border); display: flex; gap: 12px; align-items: center; }
|
||||
@@ -9380,40 +9380,45 @@ body.bookslm-resizing { cursor: ew-resize; user-select: none; }
|
||||
.bookslm-modal-actions button { padding: 7px 14px; border-radius: 8px; border: 1px solid var(--border);
|
||||
background: var(--surface); color: var(--text-primary); cursor: pointer; font-size: 13px; }
|
||||
.bookslm-modal-actions button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
/* ── Provider/model picker (compact, shared by assistant + editor) ── */
|
||||
.ai-picker { display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 0.7rem; color: var(--text-secondary); flex-shrink: 0; position: relative; }
|
||||
/* ── Provider/model picker (shared by assistant + editor) ── */
|
||||
.ai-picker { display: inline-flex; align-items: center; gap: 8px;
|
||||
font-size: 0.8rem; color: var(--text-secondary); flex-shrink: 0; position: relative; }
|
||||
.ai-picker-label { white-space: nowrap; }
|
||||
.ai-picker select { font-size: 0.7rem; background: var(--surface); color: var(--text-primary);
|
||||
border: 1px solid var(--border); border-radius: 4px; padding: 2px 4px; cursor: pointer; }
|
||||
.ai-picker select { font-size: 0.8rem; background: var(--surface); color: var(--text-primary);
|
||||
border: 1px solid var(--border); border-radius: 6px; padding: 6px 10px; cursor: pointer;
|
||||
min-height: 34px; }
|
||||
.ai-picker select:hover { border-color: var(--accent); }
|
||||
.ai-picker select:focus { border-color: var(--accent); outline: none; }
|
||||
/* Searchable model combobox. */
|
||||
.ai-picker-model-combo { position: relative; display: inline-flex; }
|
||||
.ai-picker-model-trigger { display: inline-flex; align-items: center; gap: 4px; max-width: 220px;
|
||||
font-size: 0.7rem; background: var(--surface); color: var(--text-primary);
|
||||
border: 1px solid var(--border); border-radius: 4px; padding: 3px 6px; cursor: pointer; }
|
||||
.ai-picker-model-trigger:hover { border-color: var(--accent); }
|
||||
.ai-picker-model-trigger { display: inline-flex; align-items: center; justify-content: space-between;
|
||||
gap: 6px; max-width: 240px; min-width: 120px; min-height: 34px;
|
||||
font-size: 0.8rem; background: var(--surface); color: var(--text-primary);
|
||||
border: 1px solid var(--border); border-radius: 6px; padding: 6px 10px; cursor: pointer; }
|
||||
.ai-picker-model-trigger:hover, .ai-picker-model-trigger:focus { border-color: var(--accent); outline: none; }
|
||||
.ai-picker-model-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ai-picker-model-chevron { opacity: 0.6; font-size: 0.6rem; }
|
||||
.ai-picker-model-chevron { opacity: 0.6; font-size: 0.7rem; flex-shrink: 0; }
|
||||
.ai-picker-model-pop { position: absolute; top: calc(100% + 4px); right: 0; z-index: 60;
|
||||
min-width: 220px; width: 340px; max-width: calc(100vw - 24px); background: var(--bg-primary);
|
||||
border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.35);
|
||||
padding: 6px; }
|
||||
.ai-picker-model-pop.hidden { display: none; }
|
||||
.ai-picker-model-search { width: 100%; box-sizing: border-box; font-size: 0.72rem;
|
||||
.ai-picker-model-search { width: 100%; box-sizing: border-box; font-size: 0.8rem;
|
||||
background: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 5px 8px; margin-bottom: 4px; }
|
||||
.ai-picker-model-list { max-height: 240px; overflow-y: auto; display: flex; flex-direction: column; }
|
||||
.ai-picker-model-option { display: block; width: 100%; text-align: left; font-size: 0.78rem;
|
||||
line-height: 1.3; padding: 5px 8px; border-radius: 5px; background: none; border: none;
|
||||
border-radius: 6px; padding: 7px 10px; margin-bottom: 4px; }
|
||||
.ai-picker-model-search:focus { border-color: var(--accent); outline: none; }
|
||||
.ai-picker-model-list { max-height: 260px; overflow-y: auto; display: flex; flex-direction: column; }
|
||||
.ai-picker-model-option { display: block; width: 100%; text-align: left; font-size: 0.8rem;
|
||||
line-height: 1.35; padding: 7px 10px; border-radius: 5px; background: none; border: none;
|
||||
color: var(--text-primary); cursor: pointer; white-space: normal; overflow-wrap: anywhere; }
|
||||
.ai-picker-model-option:hover { background: var(--bg-hover); }
|
||||
.ai-picker-model-option.active { background: var(--bg-hover); box-shadow: inset 3px 0 0 var(--accent); }
|
||||
.ai-picker-model-empty { font-size: 0.7rem; color: var(--text-secondary); padding: 6px 8px; text-align: center; }
|
||||
.ai-picker-model-more { font-size: 0.7rem; color: var(--text-secondary); padding: 6px 8px; text-align: center; }
|
||||
.ai-picker-model-empty { font-size: 0.78rem; color: var(--text-secondary); padding: 8px 10px; text-align: center; }
|
||||
.ai-picker-model-more { font-size: 0.78rem; color: var(--text-secondary); padding: 8px 10px; text-align: center; }
|
||||
/* Capability info button (ⓘ) + informational bubble. */
|
||||
.ai-picker-caps-host { position: relative; display: inline-flex; }
|
||||
.ai-picker-info { background: none; border: none; cursor: pointer; color: var(--text-secondary);
|
||||
font-size: 0.85rem; line-height: 1; padding: 2px 4px; border-radius: 4px; }
|
||||
font-size: 1rem; line-height: 1; padding: 5px 7px; border-radius: 6px; }
|
||||
.ai-picker-info:hover, .ai-picker-info:focus { color: var(--accent); }
|
||||
.ai-picker-info.hidden { display: none; }
|
||||
.ai-picker-caps-pop { position: absolute; top: calc(100% + 4px); right: 0; z-index: 60;
|
||||
|
||||
@@ -1121,6 +1121,47 @@ async function main() {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
// ── 42c. Fast `@` menu: client-side filtering of the prefetched path index ──
|
||||
await test("mention menu filters the prefetched path index instantly", async () => {
|
||||
localStorage.clear();
|
||||
const calls = [];
|
||||
globalThis.fetch = async (url) => {
|
||||
const u = String(url);
|
||||
calls.push(u);
|
||||
if (u.includes("/paths")) {
|
||||
return {
|
||||
ok: true, status: 200,
|
||||
json: async () => ({ vault: "V", count: 3, results: [
|
||||
{ vault: "V", path: "notes/a.md", name: "a.md", type: "file" },
|
||||
{ vault: "V", path: "notes/b.md", name: "b.md", type: "file" },
|
||||
{ vault: "V", path: "projects", name: "projects", type: "directory" },
|
||||
] }),
|
||||
};
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({}) };
|
||||
};
|
||||
const b = new BooksLM();
|
||||
b._panel = b._render();
|
||||
document.body.appendChild(b._panel);
|
||||
b._vault = "V";
|
||||
await b._showMentionMenu("notes");
|
||||
const menu = b._panel.querySelector(".bookslm-mention-menu");
|
||||
assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 2, "client-side filter keeps notes/*");
|
||||
assert.ok(!calls.some((u) => u.includes("/api/tree-search")), "no server search once the path cache is warm");
|
||||
b._panel.remove();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
await test("vault root context menu exposes BooksLM", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const src = readFileSync(path.join(JS_DIR, "ui.js"), "utf-8");
|
||||
const vaultBranch = src.slice(
|
||||
src.indexOf("if (type === 'vault')"),
|
||||
src.indexOf("} else if (type === 'directory')")
|
||||
);
|
||||
assert.ok(vaultBranch.includes("BooksLM"), "BooksLM entry present in the vault root context menu");
|
||||
});
|
||||
|
||||
// ── Summary ──
|
||||
console.log(`\n${passCount}/${testCount} tests passed`);
|
||||
if (passCount !== testCount) {
|
||||
|
||||
@@ -312,6 +312,22 @@ class TestTreeSearch:
|
||||
data = resp.json()
|
||||
assert len(data["results"]) == 0
|
||||
|
||||
def test_vault_paths_returns_indexed_paths(self, client):
|
||||
"""Flat path index backs the AI assistant `@` mention menu."""
|
||||
resp = client.get("/api/vault/TestVault/paths")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["vault"] == "TestVault"
|
||||
paths = [p["path"] for p in data["results"]]
|
||||
assert "note1.md" in paths
|
||||
assert all("type" in p for p in data["results"])
|
||||
|
||||
def test_vault_paths_rejects_unknown_vault(self, client):
|
||||
resp = client.get("/api/vault/DoesNotExist/paths")
|
||||
assert resp.status_code in (200, 403, 404)
|
||||
if resp.status_code == 200:
|
||||
assert resp.json()["results"] == []
|
||||
|
||||
|
||||
class TestSearchReplace:
|
||||
def test_search_replace_invalid(self, client):
|
||||
|
||||
Reference in New Issue
Block a user