feat(agent): v4.11.0 - clés API par utilisateur, chargement dynamique modèles, commandes slash
FlowDeck CI / test (push) Failing after 7s
FlowDeck CI / docker (push) Skipped

This commit is contained in:
2026-09-05 11:47:58 -04:00
parent 1c11b9d351
commit 34368a4946
10 changed files with 772 additions and 24 deletions
+159 -6
View File
@@ -88,13 +88,13 @@
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div>
<div class="settings-nav-header">Features</div>
<div class="settings-nav-item" :class="{ active: activeSection==='features' }" @click="activeSection='features'">{{ fd_icon("link",14) }} Integrations</div>
<div class="settings-nav-item" :class="{ active: activeSection==='llm' }" @click="activeSection='llm'">{{ fd_icon("bot",14) }} Agent &amp; IA</div>
<!-- Admin nav: only visible to admins -->
<template x-if="userIsAdmin">
<div>
<div class="settings-nav-header">Admin</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="activeSection='admin-users'; loadAdminUsers()">{{ fd_icon("users",14) }} Users &amp; Roles</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="activeSection='admin-audit'; loadAdminAudit()">{{ fd_icon("file-text",14) }} Audit Log</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-llm' }" @click="activeSection='admin-llm'">{{ fd_icon("bot",14) }} Agent &amp; IA</div>
</div>
</template>
</div>
@@ -506,13 +506,68 @@
</div>
</div>
<!-- Admin: Agent & IA (LLM config) -->
<div x-show="activeSection==='admin-llm'">
<!-- Agent & IA: per-user provider keys + admin global default -->
<div x-show="activeSection==='llm'">
<h2>Agent &amp; IA</h2>
<p class="section-desc">Configurez le fournisseur LLM utilisé par l'agent FlowDeck. Les changements s'appliquent immédiatement, sans redémarrage.</p>
<p class="section-desc">Sauvegardez vos propres clés API par fournisseur : l'agent FlowDeck utilisera la clé du provider sélectionné dans chaque conversation. Les modèles disponibles sont chargés automatiquement depuis le fournisseur.</p>
<div class="setting-group">
<h3>Configuration LLM</h3>
<h3>Mes fournisseurs</h3>
<template x-for="p in llmProviders" :key="p.id">
<div style="border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:10px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<strong style="flex:1;font-size:13px;" x-text="p.name"></strong>
<span x-show="keyForm(p.id).has_key" style="font-size:11px;color:var(--toast-success-bg);">✓ clé enregistrée</span>
<span x-show="!keyForm(p.id).has_key" style="font-size:11px;color:var(--text-dim);">aucune clé enregistrée</span>
</div>
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
<div class="setting-label">
Clé API
<span style="font-size:11px;color:var(--text-dim);">(laisser vide pour conserver l'existante)</span>
</div>
<div class="pw-wrapper" style="max-width:340px;">
<input :type="keyForm(p.id).keyVisible ? 'text' : 'password'" class="settings-input"
x-model="keyForm(p.id).api_key" placeholder="sk-…" style="padding-right:36px;">
<button type="button" class="pw-toggle" @click="keyForm(p.id).keyVisible=!keyForm(p.id).keyVisible"
x-text="keyForm(p.id).keyVisible ? '{{ fd_icon('eye-off',14) }}' : '{{ fd_icon('eye',14) }}'" style="top:50%;"></button>
</div>
</div>
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
<div class="setting-label">URL API (optionnelle — Ollama, proxy OpenAI-compatible…)</div>
<input type="text" class="settings-input" x-model="keyForm(p.id).api_base" placeholder="https://…" style="max-width:340px;">
</div>
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
<div class="setting-label">Modèle par défaut</div>
<select class="settings-input" x-model="keyForm(p.id).model" style="max-width:340px;">
<template x-for="m in keyModels(p.id)" :key="m">
<option :value="m" x-text="m"></option>
</template>
</select>
</div>
<div style="display:flex;gap:8px;margin-top:10px;flex-wrap:wrap;">
<button class="btn" @click="loadProviderModels(p.id)" :disabled="keyForm(p.id).modelLoading" style="font-size:12px;padding:6px 12px;">
<span x-show="!keyForm(p.id).modelLoading">{{ fd_icon("refresh",14) }} Charger les modèles</span>
<span x-show="keyForm(p.id).modelLoading">Chargement…</span>
</button>
<button class="btn btn-primary" @click="saveUserKey(p.id)" :disabled="keyForm(p.id).saving" style="font-size:12px;padding:6px 12px;">
<span x-show="!keyForm(p.id).saving">{{ fd_icon("save",14) }} Enregistrer</span>
<span x-show="keyForm(p.id).saving">Enregistrement…</span>
</button>
<button class="btn" @click="deleteUserKey(p.id)" :disabled="keyForm(p.id).deleting" x-show="keyForm(p.id).has_key" style="font-size:12px;padding:6px 12px;color:var(--danger);">Supprimer</button>
</div>
<div style="margin-top:8px;font-size:12px;">
<span x-show="keyForm(p.id).msg" x-text="keyForm(p.id).msg" :style="{color: keyForm(p.id).ok ? 'var(--toast-success-bg)' : 'var(--danger)'}"></span>
</div>
</div>
</template>
<p style="font-size:12px;color:var(--text-dim);margin-top:6px;">
Sans clé enregistrée pour le provider choisi, l'agent utilise la configuration globale par défaut (ou le mode « offline mock »).
Activez un provider / modèle dans chaque conversation via les sélecteurs du panneau Agent (🤖 en bas à droite) ou la commande <code>/provider</code> et <code>/model</code>.
</p>
</div>
<div class="setting-group" x-show="userIsAdmin">
<h3>Configuration globale (défaut pour tous les utilisateurs)</h3>
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
<div class="setting-label">Provider</div>
<select class="settings-input" x-model="llmForm.provider" @change="onLlmProviderChange()" style="max-width:300px;">
@@ -552,7 +607,6 @@
<span x-show="llmTestMsg" x-text="llmTestMsg" :style="{color: llmTestOk ? 'var(--toast-success-bg)' : 'var(--danger)'}"></span>
</div>
<p style="font-size:12px;color:var(--text-dim);margin-top:14px;">
Tous les utilisateurs peuvent choisir un provider / modèle par conversation via le panneau Agent (🤖 en bas à droite).
Sans clé API, l'agent fonctionne en mode « offline mock » (plannificateur déterministe, aucun réseau).
</p>
</div>
@@ -610,12 +664,15 @@ document.addEventListener('alpine:init', function() {
llmMsg: '', llmMsgOk: false,
llmTestMsg: '', llmTestOk: false,
llmKeyVisible: false,
// Per-user provider keys (multi-providers)
keyForms: {},
async init() {
await this.loadTags();
await this.loadGiteaStatus();
await this.loadGithubStatus();
await this.loadNotifPrefs();
await this.loadUserLlm();
if (this.userIsAdmin) await this.loadLlmConfig();
},
@@ -779,6 +836,102 @@ document.addEventListener('alpine:init', function() {
},
// ── LLM / Agent ──
keyForm(id) {
if (!this.keyForms[id]) {
this.keyForms[id] = {api_key:'', api_base:'', model:'', models:[], has_key:false,
keyVisible:false, modelLoading:false, saving:false, deleting:false, msg:'', ok:false};
}
return this.keyForms[id];
},
keyModels(id) {
var p = this.llmProviders.find(x => x.id === id);
var base = ((p && p.models) || []).slice();
var f = this.keyForms[id];
if (f && f.models) f.models.forEach(m => { if (base.indexOf(m) === -1) base.push(m); });
return base;
},
async loadUserLlm() {
try {
var r = await fetch('/api/agent/providers');
var d = await r.json();
this.llmProviders = d.providers || [];
var kr = await fetch('/api/agent/keys');
var kd = await kr.json();
var keys = kd.keys || [];
this.llmProviders.forEach(p => {
var k = keys.find(x => x.provider === p.id);
this.keyForm(p.id).api_base = (k && k.api_base) || '';
this.keyForm(p.id).model = (k && k.default_model) || (p.default_model) || '';
this.keyForm(p.id).models = (k && k.models) || [];
this.keyForm(p.id).has_key = !!(k && k.has_key);
});
} catch(e) {}
},
async loadProviderModels(id) {
var f = this.keyForm(id);
if (f.modelLoading) return;
f.modelLoading = true; f.msg = ''; f.ok = false;
try {
var r = await fetch('/api/agent/keys/' + id + '/models', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({api_key: f.api_key, api_base: f.api_base})
});
var d = await r.json();
if (d.ok) {
f.models = d.models || [];
if (f.models.length) {
if (f.models.indexOf(f.model) === -1) f.model = f.models[0];
f.msg = f.models.length + ' modèles chargés';
f.ok = true;
} else {
f.msg = 'Aucun modèle renvoyé par le fournisseur';
}
} else {
f.msg = 'Erreur: ' + (d.error || 'échec de connexion');
}
} catch(e) { f.msg = 'Erreur réseau'; }
finally { f.modelLoading = false; }
},
async saveUserKey(id) {
var f = this.keyForm(id);
f.saving = true; f.msg = ''; f.ok = false;
try {
var body = {api_key: f.api_key, api_base: f.api_base, default_model: f.model};
if (f.models && f.models.length) body.models = f.models;
var r = await fetch('/api/agent/keys/' + id, {
method: 'PUT',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(body)
});
var d = await r.json();
if (r.ok) {
f.has_key = !!d.has_key;
f.api_key = '';
f.msg = 'Enregistré pour ' + id + (f.has_key ? ' — clé active ✓' : ' — aucune clé stockée');
f.ok = true;
} else {
f.msg = d.detail || 'Erreur lors de l\'enregistrement';
}
} catch(e) { f.msg = 'Erreur réseau'; }
finally { f.saving = false; }
},
async deleteUserKey(id) {
var f = this.keyForm(id);
f.deleting = true; f.msg = ''; f.ok = false;
try {
var r = await fetch('/api/agent/keys/' + id, { method: 'DELETE' });
var d = await r.json();
if (r.ok) {
f.has_key = false; f.api_key = ''; f.models = []; f.model = '';
f.msg = 'Clé supprimée pour ' + id;
f.ok = true;
} else {
f.msg = d.detail || 'Erreur lors de la suppression';
}
} catch(e) { f.msg = 'Erreur réseau'; }
finally { f.deleting = false; }
},
async loadLlmConfig() {
try {
var r = await fetch('/api/agent/providers');