feat(agent): v4.10.1 - config LLM dans l'UI (sélecteur provider/modèle, config runtime, admin + test connexion)
FlowDeck CI / test (push) Failing after 7s
FlowDeck CI / docker (push) Skipped

- Panneau agent : selects provider/modèles connus persistés par conversation
  (colonnes agent_conversations.provider/model, migration idempotente)
- GET /api/agent/providers enrichi (liste providers + modèles + requires_key)
- PATCH /api/agent/providers (admin) : config runtime DB-backed (table llm_config)
- POST /api/agent/providers/test : test connexion via LLMClient.ping() sans fallback mock
- Settings > Admin > Agent & IA : provider, modèle, clé API, URL API, save + test
- VERSION -> 4.10.1, CHANGELOG + ROADMAP mis à jour, 232 tests verts
This commit is contained in:
2026-09-05 10:58:09 -04:00
parent e752e46583
commit 3cb9edc1f3
11 changed files with 546 additions and 19 deletions
+126
View File
@@ -94,6 +94,7 @@
<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>
@@ -505,6 +506,58 @@
</div>
</div>
<!-- Admin: Agent & IA (LLM config) -->
<div x-show="activeSection==='admin-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>
<div class="setting-group">
<h3>Configuration LLM</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;">
<template x-for="p in llmProviders" :key="p.id">
<option :value="p.id" x-text="p.name + (p.requires_key ? ' (clé API requise)' : '')"></option>
</template>
</select>
</div>
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
<div class="setting-label">Modèle</div>
<select class="settings-input" x-model="llmForm.model" style="max-width:300px;">
<template x-for="m in llmProviderModels" :key="m">
<option :value="m" x-text="m"></option>
</template>
</select>
</div>
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
<div class="setting-label">Clé API <span x-show="llmHasKey" style="font-size:11px;color:var(--toast-success-bg);margin-left:6px;">✓ configurée</span></div>
<div class="pw-wrapper" style="max-width:300px;">
<input :type="llmKeyVisible ? 'text' : 'password'" class="settings-input" x-model="llmForm.api_key" placeholder="sk-… (laisser vide pour conserver l'existante)" style="padding-right:36px;">
<button type="button" class="pw-toggle" @click="llmKeyVisible=!llmKeyVisible" x-text="llmKeyVisible ? '{{ 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)</div>
<input type="text" class="settings-input" x-model="llmForm.api_base" placeholder="https://… (Ollama, proxy OpenAI-compatible…)" style="max-width:300px;">
</div>
<div style="display:flex;gap:10px;margin-top:14px;">
<button class="btn btn-primary" @click="saveLlmConfig()" :disabled="llmSaving" style="font-size:13px;">
<span x-show="!llmSaving">{{ fd_icon("save",14) }} Save configuration</span>
<span x-show="llmSaving">Saving…</span>
</button>
<button class="btn" @click="testLlm()" :disabled="llmTesting" style="font-size:13px;">🔌 Test connection</button>
</div>
<div style="margin-top:12px;font-size:13px;">
<span x-show="llmMsg" x-text="llmMsg" :style="{color: llmMsgOk ? 'var(--toast-success-bg)' : 'var(--danger)'}"></span>
<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>
</div>
</div>
</div>
</div>
@@ -547,12 +600,23 @@ document.addEventListener('alpine:init', function() {
newUser: {login:'',name:'',email:'',password:'',is_admin:false},
editingUser: null,
editUserForm: {login:'',name:'',email:'',password:'',is_admin:false,is_active:true},
// LLM / Agent config
llmProviders: [],
llmProviderModels: [],
llmForm: {provider:'offline', model:'', api_key:'', api_base:''},
llmHasKey: false,
llmSaving: false,
llmTesting: false,
llmMsg: '', llmMsgOk: false,
llmTestMsg: '', llmTestOk: false,
llmKeyVisible: false,
async init() {
await this.loadTags();
await this.loadGiteaStatus();
await this.loadGithubStatus();
await this.loadNotifPrefs();
if (this.userIsAdmin) await this.loadLlmConfig();
},
async loadNotifPrefs() {
@@ -714,6 +778,68 @@ document.addEventListener('alpine:init', function() {
} catch(e) {}
},
// ── LLM / Agent ──
async loadLlmConfig() {
try {
var r = await fetch('/api/agent/providers');
var d = await r.json();
this.llmProviders = d.providers || [];
this.llmHasKey = !!d.has_api_key;
this.llmForm = {provider: d.provider || 'offline', model: d.model || '', api_key: '', api_base: d.api_base || ''};
this.applyLlmModels();
} catch(e) {}
},
applyLlmModels() {
var p = this.llmProviders.find(x => x.id === this.llmForm.provider);
this.llmProviderModels = p ? (p.models || []) : [];
if (this.llmProviderModels.length && this.llmProviderModels.indexOf(this.llmForm.model) === -1) {
this.llmForm.model = (p && p.default_model) || this.llmProviderModels[0];
}
},
onLlmProviderChange() { this.applyLlmModels(); },
async saveLlmConfig() {
this.llmSaving = true;
this.llmMsg = ''; this.llmMsgOk = false;
try {
var r = await this.adminFetch('/api/agent/providers', {
method: 'PATCH',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({provider: this.llmForm.provider, model: this.llmForm.model,
api_key: this.llmForm.api_key, api_base: this.llmForm.api_base})
});
var d = await r.json();
if (r.ok) {
this.llmHasKey = !!d.has_api_key;
this.llmMsgOk = true;
this.llmMsg = 'Configuration enregistrée : ' + d.provider + ' / ' + d.model + (d.available ? ' — disponible ✓' : ' — clé manquante pour ce provider');
this.llmForm.api_key = '';
} else {
this.llmMsg = d.detail || 'Erreur lors de l\'enregistrement';
}
} catch(e) { this.llmMsg = 'Erreur réseau'; }
finally { this.llmSaving = false; }
},
async testLlm() {
if (!this.llmForm.provider) return;
this.llmTesting = true;
this.llmTestMsg = 'Test en cours…'; this.llmTestOk = false;
try {
var r = await this.adminFetch('/api/agent/providers/test', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({provider: this.llmForm.provider, model: this.llmForm.model,
api_key: this.llmForm.api_key, api_base: this.llmForm.api_base})
});
var d = await r.json();
this.llmTestOk = !!d.ok;
this.llmTestMsg = d.ok
? ('✓ ' + (d.model || '') + ' : ' + (d.reply || 'réponse reçue'))
: ('✗ ' + (d.error || 'échec de connexion'));
} catch(e) { this.llmTestOk = false; this.llmTestMsg = 'Erreur réseau'; }
finally { this.llmTesting = false; }
},
getCompletedLlmTest() { return this.llmTestMsg; },
async saveAccount() {
this.accountSaving = true;
this.accountMsg = '';