fix(ai): la liste des fournisseurs de l'assistant suit la configuration (BUG-043)
Le picker lit /api/ai/status une seule fois, à sa construction, et le panneau de l'assistant est un singleton monté pour toute la session : ajouter ou supprimer une clé API dans la configuration du projet laissait la liste des fournisseurs figée jusqu'à un rechargement de page. - ai.js : nouveau refreshAIPickers() qui reconstruit chaque picker monté dans son emplacement .ai-picker-slot (constante PICKER_SLOT_CLASS) ; le slot est conservé même sans fournisseur configuré, donc le premier fournisseur ajouté s'y monte aussi ; une sélection persistée dont le fournisseur n'est plus configuré est purgée de obsigate_ai_picker (retour au défaut, plus de modèle fantôme dans le déclencheur). - bookslm.js : l'emplacement .bookslm-picker-host porte la classe ai-picker-slot et reste dans la barre (replaceChildren au lieu de replaceWith). - config.js : refreshAIPickers() après saveAIKeys() et deleteAIKey(). - tests : +4 tests JSDOM (ajout/retrait dans la barre, premier montage dans un slot vide, purge de la sélection orpheline, câblage save/delete). Vérifié : tests frontend 66/66 (IA) + 9 suites JSDOM, validate-imports 36 modules, pytest 963 passed / 6 skipped, ruff 0, et contrôle navigateur (Playwright) sur l'instance de test — ajout de nvidia visible sans rechargement, retrait effectif + sélection réinitialisée.
This commit is contained in:
@@ -1173,6 +1173,112 @@ async function main() {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
// ── 42c. BUG-043: the provider list follows the project AI configuration ──
|
||||
const providerOptions = (root) =>
|
||||
Array.from(root.querySelectorAll(".ai-picker-select option"))
|
||||
.map((o) => o.value)
|
||||
.filter(Boolean);
|
||||
|
||||
const providerStatusMock = (getProviders, models = ["deepseek-chat"]) => async (url) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/api/ai/status")) {
|
||||
return { ok: true, status: 200, json: async () => ({ configured: true, providers: getProviders() }) };
|
||||
}
|
||||
if (u.includes("/api/config/ai-models")) {
|
||||
return { ok: true, status: 200, json: async () => ({ models, source: "api" }) };
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({}) };
|
||||
};
|
||||
|
||||
await test("assistant sidebar lists a provider added or removed in the project config", async () => {
|
||||
localStorage.clear();
|
||||
let providers = { deepseek: { available: true, model: "deepseek-chat" } };
|
||||
globalThis.fetch = providerStatusMock(() => providers);
|
||||
|
||||
const b = new BooksLM();
|
||||
b._panel = b._render();
|
||||
document.body.appendChild(b._panel);
|
||||
await sleep(30);
|
||||
const host = b._panel.querySelector(".bookslm-picker-host");
|
||||
assert.ok(host, "picker host lives in the toolbar");
|
||||
assert.deepEqual(providerOptions(host), ["deepseek"], "only the configured provider is listed");
|
||||
|
||||
// Admin saves a second provider key in the project config.
|
||||
providers = {
|
||||
deepseek: { available: true, model: "deepseek-chat" },
|
||||
nvidia: { available: true, model: "meta/llama-3.1-405b-instruct" },
|
||||
};
|
||||
await ai.refreshAIPickers();
|
||||
await sleep(10);
|
||||
assert.deepEqual(providerOptions(host), ["deepseek", "nvidia"], "added provider shows up without a reload");
|
||||
assert.ok(host.querySelector(".ai-picker"), "picker rebuilt inside its host slot");
|
||||
assert.equal(b._panel.querySelectorAll(".ai-picker").length, 1, "no duplicate picker mount");
|
||||
|
||||
// Admin deletes the key again.
|
||||
providers = { deepseek: { available: true, model: "deepseek-chat" } };
|
||||
await ai.refreshAIPickers();
|
||||
await sleep(10);
|
||||
assert.deepEqual(providerOptions(host), ["deepseek"], "removed provider disappears");
|
||||
|
||||
b._panel.remove();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
await test("refreshAIPickers mounts the first provider into an empty slot", async () => {
|
||||
localStorage.clear();
|
||||
let providers = {};
|
||||
globalThis.fetch = providerStatusMock(() => providers);
|
||||
|
||||
const b = new BooksLM();
|
||||
b._panel = b._render();
|
||||
document.body.appendChild(b._panel);
|
||||
await sleep(30);
|
||||
const host = b._panel.querySelector(".bookslm-picker-host");
|
||||
assert.equal(host.querySelector(".ai-picker"), null, "no picker without a configured provider");
|
||||
|
||||
providers = { deepseek: { available: true, model: "deepseek-chat" } };
|
||||
await ai.refreshAIPickers();
|
||||
await sleep(10);
|
||||
assert.ok(host.querySelector(".ai-picker"), "picker mounted into the kept slot");
|
||||
assert.deepEqual(providerOptions(host), ["deepseek"]);
|
||||
|
||||
b._panel.remove();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
await test("a provider whose key was deleted is dropped from the stored selection", async () => {
|
||||
localStorage.clear();
|
||||
globalThis.fetch = providerStatusMock(() => ({ deepseek: { available: true } }));
|
||||
|
||||
localStorage.setItem(
|
||||
"obsigate_ai_picker",
|
||||
JSON.stringify({ provider: "nvidia", model: "meta/llama-3.1-405b-instruct" })
|
||||
);
|
||||
const slot = document.createElement("span");
|
||||
slot.className = "ai-picker-slot";
|
||||
document.body.appendChild(slot);
|
||||
slot.replaceChildren(await ai.buildAIPickerUI());
|
||||
await sleep(20);
|
||||
|
||||
assert.equal(slot.querySelector(".ai-picker-select").value, "", "provider falls back to the default");
|
||||
assert.equal(slot.querySelector(".ai-picker-model").value, "", "no stale model kept");
|
||||
const stored = JSON.parse(localStorage.getItem("obsigate_ai_picker"));
|
||||
assert.equal(stored.provider, null, "unconfigured provider cleared from storage");
|
||||
assert.equal(stored.model, null, "its model cleared from storage");
|
||||
|
||||
slot.remove();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
await test("config save/delete rebuilds the assistant provider picker", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const src = readFileSync(path.join(JS_DIR, "config.js"), "utf-8");
|
||||
const save = src.slice(src.indexOf("async function saveAIKeys()"), src.indexOf("async function deleteAIKey("));
|
||||
const del = src.slice(src.indexOf("async function deleteAIKey("), src.indexOf("async function testAIKeys("));
|
||||
assert.ok(save.includes("refreshAIPickers()"), "saving a provider key refreshes the picker");
|
||||
assert.ok(del.includes("refreshAIPickers()"), "deleting a provider key refreshes the picker");
|
||||
});
|
||||
|
||||
// ── 42b. Assistant activity indicator (#82) ──
|
||||
await test("activity indicator reflects the request workflow", () => {
|
||||
const b = new BooksLM();
|
||||
|
||||
Reference in New Issue
Block a user