feat(agent): v4.10.1 - config LLM dans l'UI (sélecteur provider/modèle, config runtime, admin + test connexion)
- 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:
@@ -28,6 +28,11 @@
|
||||
.fd-agent-send{background:var(--accent,#2383E2);border:none;color:#fff;border-radius:8px;padding:10px 14px;cursor:pointer;font-size:14px}
|
||||
.fd-agent-send:disabled{opacity:.5;cursor:not-allowed}
|
||||
.fd-agent-new{background:none;border:1px solid var(--border,rgba(255,255,255,.1));color:var(--text,#ddd);border-radius:8px;padding:4px 8px;font-size:12px;cursor:pointer}
|
||||
.fd-agent-config{display:flex;align-items:center;gap:6px;padding:6px 10px;border-bottom:1px solid var(--border,rgba(255,255,255,.06))}
|
||||
.fd-agent-select{flex:1;min-width:0;background:var(--bg-tertiary,#262626);border:1px solid var(--border,rgba(255,255,255,.1));border-radius:7px;color:var(--text,#fff);font-size:12px;padding:5px 8px;outline:none;font-family:inherit}
|
||||
.fd-agent-select:focus{border-color:var(--accent,#2383E2)}
|
||||
.fd-agent-gear{background:none;border:none;color:var(--text-dim,#999);font-size:14px;cursor:pointer;padding:2px 4px;border-radius:6px;text-decoration:none;line-height:1}
|
||||
.fd-agent-gear:hover{background:var(--bg-hover,#333);color:var(--text,#fff)}
|
||||
</style>
|
||||
|
||||
<button id="fd-agent-fab" title="FlowDeck Agent" onclick="document.dispatchEvent(new CustomEvent('fd-agent-toggle'))">🤖</button>
|
||||
@@ -39,6 +44,20 @@
|
||||
<button class="fd-agent-close" @click="close()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="fd-agent-config">
|
||||
<select class="fd-agent-select" x-model="llmProvider" @change="onProviderChange()" title="Provider">
|
||||
<template x-for="p in providers" :key="p.id">
|
||||
<option :value="p.id" x-text="p.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
<select class="fd-agent-select" x-model="llmModel" x-show="llmProvider!=='offline'" title="Modèle">
|
||||
<template x-for="m in providerModels" :key="m">
|
||||
<option :value="m" x-text="m"></option>
|
||||
</template>
|
||||
</select>
|
||||
<a href="/settings" target="_blank" class="fd-agent-gear" title="Configurer (clé API, admin)" @click.stop>⚙️</a>
|
||||
</div>
|
||||
|
||||
<div class="fd-agent-tabs">
|
||||
<button class="fd-agent-tab" :class="{active: tab=='chat'}" @click="tab='chat'">Conversation</button>
|
||||
<button class="fd-agent-tab" :class="{active: tab=='history'}" @click="tab='history'; loadConversations()">Historique</button>
|
||||
@@ -89,6 +108,7 @@
|
||||
agents: [], conversations: [], currentConv: null,
|
||||
messages: [], input: '', streaming: false, liveReasoning: '',
|
||||
provider: 'offline', providerAvailable: false, errorMsg: '',
|
||||
providers: [], providerModels: [], llmProvider: 'offline', llmModel: '',
|
||||
|
||||
init(){
|
||||
var self = this;
|
||||
@@ -107,8 +127,26 @@
|
||||
fetch('/api/agent/providers').then(function(r){return r.json()}).then(function(d){
|
||||
self.provider = d.provider || 'offline';
|
||||
self.providerAvailable = !!d.available;
|
||||
self.providers = d.providers || [];
|
||||
if(!self.llmProvider) self.llmProvider = d.provider || 'offline';
|
||||
if(!self.llmModel) self.llmModel = d.model || '';
|
||||
self.applyModels();
|
||||
}).catch(function(){});
|
||||
},
|
||||
applyModels(){
|
||||
var self = this;
|
||||
var p = self.providers.find(function(x){ return x.id === self.llmProvider; });
|
||||
self.providerModels = (p && p.models) || [];
|
||||
if(self.providerModels.length && self.providerModels.indexOf(self.llmModel) === -1){
|
||||
self.llmModel = (p && p.default_model) || self.providerModels[0];
|
||||
}
|
||||
},
|
||||
onProviderChange(){
|
||||
var self = this;
|
||||
var p = self.providers.find(function(x){ return x.id === self.llmProvider; });
|
||||
self.providerModels = (p && p.models) || [];
|
||||
self.llmModel = (p && p.default_model) || ((p && p.models && p.models[0]) || '');
|
||||
},
|
||||
fetchAgents(){
|
||||
var self = this;
|
||||
fetch('/api/agent').then(function(r){return r.json()}).then(function(d){
|
||||
@@ -132,11 +170,16 @@
|
||||
fetch('/api/agent/conversations/'+id).then(function(r){return r.json()}).then(function(d){
|
||||
self.currentConv = d.conversation;
|
||||
self.messages = (d.messages||[]).map(function(m){ return {id:m.id, role:m.role, content:m.content, model:m.model, tool_calls:m.tool_calls_json}; });
|
||||
if(d.conversation && d.conversation.provider){ self.llmProvider = d.conversation.provider; self.applyModels(); }
|
||||
if(d.conversation && d.conversation.model){ self.llmModel = d.conversation.model; }
|
||||
}).catch(function(){});
|
||||
},
|
||||
newConversation(){
|
||||
var self = this;
|
||||
fetch('/api/agent/conversations', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({title:'New conversation'})})
|
||||
var body = {title:'New conversation'};
|
||||
if(self.llmProvider) body.provider = self.llmProvider;
|
||||
if(self.llmModel) body.model = self.llmModel;
|
||||
fetch('/api/agent/conversations', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)})
|
||||
.then(function(r){return r.json()}).then(function(d){
|
||||
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
|
||||
self.conversations.unshift(conv); self.currentConv = conv;
|
||||
@@ -158,10 +201,13 @@
|
||||
self.scrollBottom();
|
||||
|
||||
var respBuf = '';
|
||||
var payload = {message: text};
|
||||
if(self.llmProvider) payload.provider = self.llmProvider;
|
||||
if(self.llmModel) payload.model = self.llmModel;
|
||||
return fetch('/api/agent/conversations/'+self.currentConv.id+'/run', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({message: text})
|
||||
body: JSON.stringify(payload)
|
||||
}).then(function(resp){
|
||||
if(!resp.ok){
|
||||
return resp.json().then(function(j){ throw new Error(j.detail || ('HTTP '+resp.status)); });
|
||||
|
||||
@@ -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 & 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 & IA</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -505,6 +506,58 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin: Agent & IA (LLM config) -->
|
||||
<div x-show="activeSection==='admin-llm'">
|
||||
<h2>Agent & 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 = '';
|
||||
|
||||
Reference in New Issue
Block a user