feat(agent): v4.12.0 - refonte config 'Agent & IA', fournisseurs actifs/testes dans le panneau, contexte document + guide d'utilisation
FlowDeck CI / test (push) Failing after 9s
FlowDeck CI / docker (push) Skipped

This commit is contained in:
2026-09-05 22:47:16 -04:00
parent bd109c273a
commit c322f30801
10 changed files with 798 additions and 150 deletions
+105 -12
View File
@@ -39,6 +39,10 @@
.fd-agent-cmd-item span{color:var(--text-dim,#999);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.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)}
.fd-agent-noconfig{padding:8px 10px;font-size:12px;color:var(--text-dim,#ccc);background:rgba(227,98,98,.10);border-bottom:1px solid var(--border,rgba(255,255,255,.06));display:flex;gap:6px;align-items:center;flex-wrap:wrap}
.fd-agent-noconfig a{color:var(--accent,#2383E2);text-decoration:none;font-weight:600}
.fd-agent-ctx{font-size:11px;color:var(--text-dim,#999);padding:5px 12px;border-bottom:1px solid var(--border,rgba(255,255,255,.06));display:flex;gap:6px;align-items:center;background:rgba(35,131,226,.08)}
</style>
<button id="fd-agent-fab" title="FlowDeck Agent" onclick="document.dispatchEvent(new CustomEvent('fd-agent-toggle'))">🤖</button>
@@ -53,7 +57,7 @@
<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>
<option :value="p.id" x-text="p.id==='offline' ? 'Hors-ligne (mock)' : p.name"></option>
</template>
</select>
<select class="fd-agent-select" x-model="llmModel" x-show="llmProvider!=='offline'" title="Modèle">
@@ -64,6 +68,17 @@
<a href="/settings" target="_blank" class="fd-agent-gear" title="Configurer (clés API, Settings → Agent & IA)" @click.stop>⚙️</a>
</div>
<div class="fd-agent-noconfig" x-show="!hasActiveProvider">
<span>⚠️ Aucun fournisseur activé et fonctionnel.</span>
<a href="/settings" target="_blank">Configurer → Agent &amp; IA</a>
<template x-if="unverifiedCount">
<span style="width:100%;color:var(--text-dim,#bbb)">🔑 <span x-text="unverifiedCount"></span> clé(s) enregistrée(s) à tester : ouvrez Paramètres puis « Tester ».</span>
</template>
</div>
<div class="fd-agent-ctx" x-show="chatContextActive" x-cloak>
<span>📄 Document ouvert : l'agent peut interagir avec cette page.</span>
</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>
@@ -123,7 +138,8 @@
agents: [], conversations: [], currentConv: null,
messages: [], input: '', streaming: false, liveReasoning: '',
provider: 'offline', providerAvailable: false, errorMsg: '',
providers: [], providerModels: [], llmProvider: 'offline', llmModel: '', llmKeys: [],
allProviders: [], providers: [], providerModels: [], llmProvider: 'offline',
llmModel: '', llmKeys: [], defaultProvider: 'offline', ctxDetected: false,
init(){
var self = this;
@@ -131,6 +147,7 @@
window.addEventListener('fd-agent-toggle', function(e){
self.open = !self.open;
if(e && e.detail && e.detail.agentId){ self.agentId = e.detail.agentId; }
self.detectCtx();
self.loadConversations();
});
this.fetchProviders();
@@ -148,14 +165,25 @@
};
},
// Only providers *configured and functional* (verified) are selectable,
// plus the built-in "offline" mock so the chat always stays usable.
fetchProviders(){
var self = this;
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 || [];
self.allProviders = d.providers || [];
self.llmKeys = d.keys || [];
if(!self.llmProvider) self.llmProvider = d.provider || 'offline';
self.defaultProvider = d.default_provider || d.provider || 'offline';
self.providers = self.allProviders.filter(function(p){ return p.functional; });
if(!self.providers.some(function(x){ return x.id === 'offline'; })){
self.providers.push({id:'offline', name:'Hors-ligne', functional:true, verified:true,
models: [], default_model:'', requires_key:false});
}
var want = self.defaultProvider;
if(!self.providers.some(function(x){ return x.id === want; })) want = 'offline';
if(!self.providers.some(function(x){ return x.id === self.llmProvider; })) self.llmProvider = want;
if(!self.llmProvider) self.llmProvider = want;
if(!self.llmModel) self.llmModel = d.model || '';
self.applyModels();
}).catch(function(){});
@@ -174,6 +202,32 @@
self.providerModels = (p && p.models) || [];
self.llmModel = (p && p.default_model) || ((p && p.models && p.models[0]) || '');
},
get hasActiveProvider(){
return (this.providers || []).some(function(p){ return p.id !== 'offline'; });
},
get unverifiedCount(){
return (this.allProviders || []).filter(function(p){
return p.configured && !p.verified && p.id !== 'offline';
}).length;
},
get chatContextActive(){ return this.ctxDetected; },
detectCtx(){
var has = false;
try{ has = !!(window.fdGetPageContext && String(window.fdGetPageContext()).trim()); }catch(e){ has = false; }
this.ctxDetected = has;
},
docContext(){
try{
if(window.fdGetPageContext && typeof window.fdGetPageContext === 'function'){
var c = window.fdGetPageContext();
if(c && c.trim()) return String(c).slice(0, 9000);
}
}catch(e){}
try{
if(window.fdPageContext && String(window.fdPageContext).trim()) return String(window.fdPageContext).slice(0, 9000);
}catch(e){}
return '';
},
fetchAgents(){
var self = this;
fetch('/api/agent').then(function(r){return r.json()}).then(function(d){
@@ -197,8 +251,13 @@
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; }
var prov = d.conversation && d.conversation.provider;
// Reuse the conversation's provider only while it stays functional.
if(prov && (!self.providers.length || self.providers.some(function(x){ return x.id === prov; }))){
self.llmProvider = prov;
}
self.applyModels();
if(d.conversation && d.conversation.model && self.providerModels.indexOf(d.conversation.model) !== -1){ self.llmModel = d.conversation.model; }
}).catch(function(){});
},
newConversation(){
@@ -237,7 +296,7 @@
});
return self._ensuring;
},
openAgent(){ this.open=true; this.loadConversations(); },
openAgent(){ this.open=true; this.detectCtx(); this.loadConversations(); },
close(){ this.open=false; },
get commandOpen(){ return /^\s*\//.test(this.input) && !this.streaming; },
get commandSuggestions(){
@@ -286,12 +345,13 @@
return true;
}
if(cmd === 'providers'){
var list = this.providers.map(function(x){ return x.id + (x.has_key ? ' ✓' : ''); }).join(', ');
this.echo('Fournisseurs : ' + (list || 'aucun'));
var list = this.providers.filter(function(x){ return x.id !== 'offline'; })
.map(function(x){ return x.id + (x.verified ? ' ✓' : ''); }).join(', ');
this.echo('Fournisseurs actifs : ' + (list || 'aucun — configurez-en un dans Paramètres → Agent & IA'));
return true;
}
if(cmd === 'keys'){
var saved = this.llmKeys.map(function(k){ return k.provider + ' ✓'; }).join(', ');
var saved = this.llmKeys.map(function(k){ return k.provider + (k.verified ? ' ✓' : ' (à tester)'); }).join(', ');
this.echo('Clés enregistrées : ' + (saved || 'aucune — configurez-les dans Paramètres → Agent & IA'));
return true;
}
@@ -346,6 +406,10 @@
var payload = {message: text};
if(self.llmProvider) payload.provider = self.llmProvider;
if(self.llmModel) payload.model = self.llmModel;
// Attach the currently open document/page so the agent can interact
// with it; empty on general screens (then the agent guides on the app).
var docCtx = self.docContext();
if(docCtx) payload.context = docCtx;
return fetch('/api/agent/conversations/'+self.currentConv.id+'/run', {
method:'POST',
headers:{'Content-Type':'application/json'},
@@ -402,14 +466,43 @@
this.$nextTick(function(){ var el = document.querySelector('.fd-agent-body'); if(el) el.scrollTop = el.scrollHeight; });
},
get providerLabel(){
if(this.providerAvailable) return (this.llmProvider || this.provider);
return 'offline mock';
var p = this.llmProvider || this.provider || 'offline';
return p === 'offline' ? 'offline · mock' : p;
}
};
}
document.addEventListener('alpine:init', function(){
Alpine.data('agentPanel', agentPanel);
});
// ── Context reader ──
// Reads the document currently open in the page editor so the Agent can
// interact with it. Returns '' outside of a document (workspace, dashboard…):
// the agent then answers general "how do I use FlowDeck ?" questions.
window.fdReadPageContext = function(){
try{
var tEl = document.getElementById('_titleEl');
if(!tEl) return '';
var title = (tEl.textContent || '').trim();
var ct = document.getElementById('_blocksCt');
var text = '';
if(ct){
var kids = ct.children || [];
for(var i = 0; i < kids.length; i++){
var el = kids[i];
if(!el || !el.textContent) continue;
var t = el.textContent.replace(/\s+/g, ' ').trim();
if(t) text += t + '\n';
}
text = text.trim();
}
if(!title && !text) return '';
var out = title ? ('Titre du document : ' + title) : 'Document ouvert';
if(text) out += '\n\n' + text;
return out.slice(0, 9000);
}catch(e){ return ''; }
};
if(!window.fdGetPageContext) window.fdGetPageContext = window.fdReadPageContext;
window.agentPanel = agentPanel;
})();
</script>
+309 -108
View File
@@ -72,6 +72,33 @@
/* Close button */
.settings-close{position:absolute;top:12px;right:12px;width:32px;height:32px;background:none;border:none;color:var(--text-dim);font-size:20px;cursor:pointer;border-radius:6px;display:flex;align-items:center;justify-content:center;}
.settings-close:hover{background:var(--bg-hover);color:var(--text);}
/* Agent & IA — status summary + provider cards */
.llm-summary{display:flex;gap:14px;align-items:flex-start;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:12px;padding:14px 16px;margin-bottom:22px;flex-wrap:wrap;}
.llm-summary-icon{font-size:26px;line-height:1;flex-shrink:0;}
.llm-summary-body{flex:1;min-width:220px;}
.llm-summary-title{font-size:14px;font-weight:600;color:var(--text);}
.llm-summary-desc{font-size:12.5px;color:var(--text-dim);margin-top:2px;}
.llm-summary-side{display:flex;align-items:center;gap:8px;flex-shrink:0;}
.llm-badge{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:600;padding:3px 10px;border-radius:99px;white-space:nowrap;}
.llm-badge.ok{background:rgba(46,160,67,.15);color:#2ea043;}
.llm-badge.warn{background:rgba(217,115,13,.16);color:#d9730d;}
.llm-badge.err{background:rgba(224,62,62,.14);color:#e03e3e;}
.llm-badge.off{background:rgba(120,119,116,.15);color:var(--text-dim);}
.prov-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:12px;}
.prov-card{border:1px solid var(--border);border-radius:12px;padding:12px 14px;background:var(--bg-secondary);display:flex;flex-direction:column;gap:4px;}
.prov-card-head{display:flex;align-items:center;gap:8px;margin-bottom:2px;flex-wrap:wrap;}
.prov-card-name{font-size:13.5px;font-weight:600;color:var(--text);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;}
.prov-card-sub{font-size:11.5px;color:var(--text-dim);margin-bottom:8px;line-height:1.4;}
.prov-field-row{display:flex;flex-direction:column;gap:4px;margin-top:2px;}
.prov-field-label{font-size:12px;color:var(--text-secondary);display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
.prov-field-label .muted{font-size:10.5px;color:var(--text-dim);font-weight:400;}
.prov-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px;}
.prov-card input.settings-input,.prov-card select.settings-input{width:100%;box-sizing:border-box;}
.prov-card .pw-wrapper{width:100%;}
.prov-card .pw-wrapper .settings-input{padding-right:36px;}
.prov-msg{font-size:12px;margin-top:8px;min-height:0;word-break:break-word;}
.llm-hint{font-size:12px;color:var(--text-secondary);background:rgba(35,131,226,.07);border:1px solid rgba(35,131,226,.18);border-radius:8px;padding:8px 12px;margin-top:14px;line-height:1.5;}
</style>
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="window.history.back()">
@@ -509,106 +536,132 @@
<!-- Agent & IA: per-user provider keys + admin global default -->
<div x-show="activeSection==='llm'">
<h2>Agent &amp; IA</h2>
<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>
<p class="section-desc">Connectez des fournisseurs d'IA pour piloter l'agent FlowDeck. Un fournisseur doit être enregistré <b>et</b> avoir réussi son test de connexion pour devenir disponible dans le panneau Agent (🤖 en bas à droite). Sans fournisseur actif, l'agent fonctionne en mode hors-ligne (mock).</p>
<div class="setting-group">
<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>
<!-- Status summary -->
<div class="llm-summary">
<div class="llm-summary-icon">🤖</div>
<div class="llm-summary-body">
<div class="llm-summary-title" x-text="llmSummaryTitle"></div>
<div class="llm-summary-desc" x-text="llmSummaryDesc"></div>
</div>
<div class="llm-summary-side" x-show="llmActiveCount">
<span class="llm-badge ok">✓ <span x-text="llmActiveCount"></span>&nbsp;actif(s)</span>
</div>
</div>
<!-- Admin: workspace default provider -->
<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;">
<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>
<h3>Fournisseur par défaut de l'espace (tous les utilisateurs)</h3>
<div class="prov-card">
<div class="prov-card-head">
<span class="prov-card-name">Configuration globale</span>
<span class="llm-badge" :class="llmGlobalBadgeClass()" x-text="llmGlobalBadge()"></span>
</div>
<div class="prov-field-row" style="margin-top:2px;">
<div class="prov-field-label">Fournisseur</div>
<select class="settings-input" x-model="llmForm.provider" @change="onLlmProviderChange()" style="max-width:320px;">
<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="prov-field-row" x-show="llmForm.provider !== 'offline'">
<div class="prov-field-label">Modèle par défaut</div>
<select class="settings-input" x-model="llmForm.model" style="max-width:320px;">
<template x-for="m in llmProviderModels" :key="m">
<option :value="m" x-text="m"></option>
</template>
</select>
</div>
<div class="prov-field-row" x-show="globalNeedsKey()">
<div class="prov-field-label">Clé API <span class="muted" x-text="llmHasKey ? '(déjà configurée — laisser vide pour conserver)' : '(requise pour ce fournisseur)'"></span></div>
<div class="pw-wrapper" style="max-width:320px;">
<input :type="llmKeyVisible ? 'text' : 'password'" class="settings-input" x-model="llmForm.api_key" placeholder="sk-…" 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="prov-field-row">
<div class="prov-field-label">URL API <span class="muted">(optionnelle — Ollama, proxy OpenAI-compatible…)</span></div>
<input type="text" class="settings-input" x-model="llmForm.api_base" placeholder="https://…" style="max-width:320px;">
</div>
<div style="display:flex;gap:10px;margin-top:14px;flex-wrap:wrap;">
<button class="btn btn-primary" @click="saveLlmConfig()" :disabled="llmSaving" style="font-size:13px;">
<span x-show="!llmSaving">{{ fd_icon("save",14) }} Enregistrer</span>
<span x-show="llmSaving">Enregistrement…</span>
</button>
<button class="btn" @click="testLlm()" :disabled="llmTesting" style="font-size:13px;">
<span x-show="!llmTesting">🔌 Tester la connexion</span>
<span x-show="llmTesting">Test…</span>
</button>
</div>
<div style="margin-top:10px;font-size:12.5px;">
<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:11.5px;color:var(--text-dim);margin:10px 0 0;">💡 Après un changement de fournisseur ou de clé, lancez « Tester la connexion » : si le test réussit, ce fournisseur devient disponible pour tous dans le panneau Agent.</p>
</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>
<!-- My providers (per-user API keys) -->
<div class="setting-group">
<h3>Mes fournisseurs</h3>
<p style="font-size:12px;color:var(--text-dim);margin:0 0 12px;">Enregistrez vos propres clés : elles sont utilisées quand vous choisissez ce fournisseur dans le panneau Agent, et vous rendent le fournisseur disponible (après test réussi).</p>
<div class="prov-grid">
<template x-for="p in userProviders()" :key="p.id">
<div class="prov-card">
<div class="prov-card-head">
<span class="prov-card-name" x-text="p.name"></span>
<span class="llm-badge" :class="llmBadgeClass(p)" x-text="llmBadge(p)"></span>
</div>
<div class="prov-card-sub" x-show="keyForm(p.id).last_error" style="color:var(--danger);" x-text="'Dernier test : ' + keyForm(p.id).last_error"></div>
<div class="prov-field-row">
<div class="prov-field-label">Clé API <span class="muted" x-text="keyForm(p.id).has_key ? '(enregistrée — laisser vide pour conserver)' : ''"></span></div>
<div class="pw-wrapper" style="max-width:100%;">
<input :type="keyForm(p.id).keyVisible ? 'text' : 'password'" class="settings-input"
x-model="keyForm(p.id).api_key" placeholder="sk-…" style="padding-right:36px;box-sizing:border-box;">
<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="prov-field-row">
<div class="prov-field-label">URL API <span class="muted">(optionnelle)</span></div>
<input type="text" class="settings-input" x-model="keyForm(p.id).api_base" placeholder="https://…" style="box-sizing:border-box;">
</div>
<div class="prov-field-row">
<div class="prov-field-label">Modèle par défaut</div>
<select class="settings-input" x-model="keyForm(p.id).model" style="box-sizing:border-box;">
<template x-for="m in keyModels(p.id)" :key="m">
<option :value="m" x-text="m"></option>
</template>
</select>
</div>
<div class="prov-actions">
<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 &amp; tester</span>
<span x-show="keyForm(p.id).saving">Enregistrement…</span>
</button>
<button class="btn" @click="testUserKey(p.id)" :disabled="keyForm(p.id).testing" style="font-size:12px;padding:6px 12px;">
<span x-show="!keyForm(p.id).testing">🔌 Tester</span>
<span x-show="keyForm(p.id).testing">Test…</span>
</button>
<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) }} Modèles</span>
<span x-show="keyForm(p.id).modelLoading">Chargement…</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 class="prov-msg">
<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>
</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 class="llm-hint">
💡 <b>Comment activer un fournisseur ?</b> Enregistrez votre clé puis cliquez « Enregistrer &amp; tester » (ou « Tester ») : dès que la connexion réussit, le fournisseur et ses modèles apparaissent dans le sélecteur du panneau Agent.
Pour Ollama, aucun fournisseur distant : renseignez l'URL locale (ex. <code>http://localhost:11434/v1</code>) puis testez.
En mode hors-ligne (aucun fournisseur actif), l'agent reste utilisable mais génère du contenu local, sans appel réseau.
</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;">
Sans clé API, l'agent fonctionne en mode « offline mock » (plannificateur déterministe, aucun réseau).
</p>
</div>
</div>
@@ -664,6 +717,11 @@ document.addEventListener('alpine:init', function() {
llmMsg: '', llmMsgOk: false,
llmTestMsg: '', llmTestOk: false,
llmKeyVisible: false,
llmDefaultProvider: 'offline',
llmActiveCount: 0,
llmDefaultFunctional: false,
llmSummaryTitle: '',
llmSummaryDesc: '',
// Per-user provider keys (multi-providers)
keyForms: {},
@@ -839,32 +897,112 @@ document.addEventListener('alpine:init', function() {
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};
keyVisible:false, modelLoading:false, saving:false, testing:false,
deleting:false, last_error:'', msg:'', ok:false};
}
return this.keyForms[id];
},
keyModels(id) {
var p = this.llmProviders.find(x => x.id === id);
var p = this.providerById(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;
},
providerById(id) {
return (this.llmProviders || []).find(x => x.id === id) || null;
},
userProviders() {
return (this.llmProviders || []).filter(x => x.id !== 'offline');
},
// Set the verified/last_error state on the shared provider entry + its keyForm
_setProviderState(id, ok, error) {
var p = this.providerById(id);
var f = this.keyForm(id);
if (p) {
p.verified = !!ok;
p.last_error = ok ? '' : (error || '');
p.functional = p.id === 'offline' ? true : !!ok;
if (!ok) { p.configured = p.has_key || !p.requires_key; }
}
f.last_error = ok ? '' : (error || '');
this.syncLlmStatus();
},
syncLlmStatus() {
var list = this.llmProviders || [];
var active = list.filter(p => p.verified && p.id !== 'offline');
this.llmActiveCount = active.length;
var def = list.find(p => p.id === this.llmDefaultProvider) || null;
this.llmDefaultFunctional = !!(def && def.functional && def.id !== 'offline');
if (active.length) {
if (this.llmDefaultFunctional && def) {
this.llmSummaryTitle = 'L\u2019agent est prêt à l\u2019emploi';
this.llmSummaryDesc = 'Le fournisseur par défaut « ' + def.name + ' » est activé et fonctionnel : vos conversations l\u2019utiliseront automatiquement.';
} else {
this.llmSummaryTitle = this.llmActiveCount + ' fournisseur(s) fonctionnel(s) pour votre compte';
this.llmSummaryDesc = 'Choisissez-en un dans le sélecteur du panneau Agent (🤖 en bas à droite) pour chaque conversation' +
(this.userIsAdmin ? ' — ou activez le fournisseur par défaut de l\u2019espace ci-dessous.' : '.');
}
} else {
this.llmSummaryTitle = 'Agent en mode hors-ligne (mock)';
this.llmSummaryDesc = 'Aucun fournisseur activé et fonctionnel. Enregistrez une clé ci-dessous puis lancez « Tester » : dès le test réussi, le fournisseur et ses modèles apparaissent dans le panneau Agent.';
}
},
llmBadge(p) {
if (!p) return '';
if (p.id === 'offline') return 'Toujours dispo (mock)';
if (p.verified) return p.source === 'global' ? 'Actif (défaut espace)' : 'Activé et fonctionnel';
if (p.source === 'global') return 'Défaut espace — à tester (admin)';
if (!p.requires_key) return 'Local — à tester';
if (p.configured) return 'Configuré — à tester';
return 'Non configuré';
},
llmBadgeClass(p) {
if (!p) return 'off';
if (p.id === 'offline') return 'off';
if (p.verified) return 'ok';
if (p.configured) return 'warn';
return 'off';
},
globalNeedsKey() {
var cur = this.providerById(this.llmForm.provider);
return !!(cur && cur.requires_key);
},
llmGlobalBadge() {
var cur = this.providerById(this.llmForm.provider);
if (!cur) return '—';
if (cur.id === 'offline') return 'Mode hors-ligne';
if (cur.id !== this.llmDefaultProvider) return 'Changement non enregistré';
if (cur.verified) return 'Actif pour tous';
if (this.llmHasKey) return 'Configuré — à tester';
return 'Non configuré';
},
llmGlobalBadgeClass() {
var cur = this.providerById(this.llmForm.provider);
if (!cur || cur.id === 'offline') return 'off';
if (cur.id !== this.llmDefaultProvider) return 'warn';
if (cur.verified) return 'ok';
return 'off';
},
async loadUserLlm() {
try {
var r = await fetch('/api/agent/providers');
var d = await r.json();
this.llmProviders = d.providers || [];
this.llmDefaultProvider = d.default_provider || d.provider || 'offline';
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);
var f = this.keyForm(p.id);
f.api_base = (k && k.api_base) || '';
f.model = (k && k.default_model) || (p.default_model) || '';
f.models = (k && k.models) || [];
f.has_key = !!(k && k.has_key);
f.last_error = (k && k.last_error) || p.last_error || '';
});
this.syncLlmStatus();
} catch(e) {}
},
async loadProviderModels(id) {
@@ -882,10 +1020,12 @@ document.addEventListener('alpine:init', function() {
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.msg = f.models.length + ' modèles chargés depuis le fournisseur';
f.ok = true;
// Une récupération réussie avec la clé stockée prouve la connexion.
if (!f.api_key) this._setProviderState(id, true, '');
} else {
f.msg = 'Aucun modèle renvoyé par le fournisseur';
f.msg = 'Connexion OK, mais aucun modèle renvoyé par le fournisseur';
}
} else {
f.msg = 'Erreur: ' + (d.error || 'échec de connexion');
@@ -908,14 +1048,54 @@ document.addEventListener('alpine:init', function() {
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;
if (f.has_key || !this.globalNeedsKeyFor(id)) {
f.msg = 'Clé enregistrée — test de connexion en cours…';
f.ok = true;
await this.testUserKey(id);
} else {
f.msg = 'Enregistré pour ' + id + ' — aucune clé stockée';
f.ok = true;
}
var p = this.providerById(id);
if (p) { p.has_key = f.has_key; p.configured = f.has_key || !p.requires_key; }
} else {
f.msg = d.detail || 'Erreur lors de l\'enregistrement';
}
} catch(e) { f.msg = 'Erreur réseau'; }
finally { f.saving = false; }
},
globalNeedsKeyFor(id) {
var p = this.providerById(id);
return !!(p && p.requires_key);
},
async testUserKey(id) {
var f = this.keyForm(id);
if (f.testing) return;
f.testing = true; f.msg = ''; f.ok = false;
try {
var body = {api_base: f.api_base, model: f.model};
if (f.api_key) body.api_key = f.api_key;
var r = await fetch('/api/agent/keys/' + id + '/test', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(body)
});
var d = await r.json();
if (d.ok) {
this._setProviderState(id, true, '');
f.msg = '✓ ' + id + ' fonctionnel' + (d.model ? ' (' + d.model + ')' : '') + (d.reply ? ' — ' + d.reply : '');
f.ok = true;
if (f.models.indexOf(d.model) === -1 && d.model) f.models.push(d.model);
} else {
this._setProviderState(id, false, d.error || 'échec de connexion');
f.msg = '✗ Test échoué : ' + (d.error || 'échec de connexion');
}
} catch(e) {
this._setProviderState(id, false, 'Erreur réseau');
f.msg = 'Erreur réseau';
}
finally { f.testing = false; }
},
async deleteUserKey(id) {
var f = this.keyForm(id);
f.deleting = true; f.msg = ''; f.ok = false;
@@ -923,8 +1103,11 @@ document.addEventListener('alpine:init', function() {
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.has_key = false; f.api_key = ''; f.models = []; f.model = ''; f.last_error = '';
var p = this.providerById(id);
if (p) { p.has_key = false; p.configured = !p.requires_key; }
this._setProviderState(id, false, '');
f.msg = 'Clé supprimée pour ' + id + ' — le fournisseur n\u2019est plus disponible dans le panneau Agent.';
f.ok = true;
} else {
f.msg = d.detail || 'Erreur lors de la suppression';
@@ -937,19 +1120,25 @@ document.addEventListener('alpine:init', function() {
var r = await fetch('/api/agent/providers');
var d = await r.json();
this.llmProviders = d.providers || [];
this.llmDefaultProvider = d.default_provider || d.provider || 'offline';
this.llmHasKey = !!d.has_api_key;
this.llmForm = {provider: d.provider || 'offline', model: d.model || '', api_key: '', api_base: d.api_base || ''};
this.applyLlmModels();
this.syncLlmStatus();
} catch(e) {}
},
applyLlmModels() {
var p = this.llmProviders.find(x => x.id === this.llmForm.provider);
var p = this.providerById(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(); },
onLlmProviderChange() {
this.applyLlmModels();
var cur = this.providerById(this.llmForm.provider);
if (cur) this.llmForm.model = (cur.default_model || this.llmProviderModels[0] || '');
},
async saveLlmConfig() {
this.llmSaving = true;
this.llmMsg = ''; this.llmMsgOk = false;
@@ -963,9 +1152,15 @@ document.addEventListener('alpine:init', function() {
var d = await r.json();
if (r.ok) {
this.llmHasKey = !!d.has_api_key;
this.llmDefaultProvider = d.provider || this.llmDefaultProvider;
this.llmMsgOk = true;
this.llmMsg = 'Configuration enregistrée : ' + d.provider + ' / ' + d.model + (d.available ? ' — disponible ✓' : ' — clé manquante pour ce provider');
this.llmMsg = 'Configuration enregistrée : ' + d.provider + ' / ' + d.model +
(d.available ? ' — lancez « Tester la connexion » pour l\u2019activer pour tous.' : ' — clé manquante pour ce provider');
// Re-évalue le badge du fournisseur par défaut localement.
var cur = this.providerById(d.provider);
if (cur) { cur.configured = !!d.has_api_key || !cur.requires_key; }
this.llmForm.api_key = '';
this.syncLlmStatus();
} else {
this.llmMsg = d.detail || 'Erreur lors de l\'enregistrement';
}
@@ -986,8 +1181,14 @@ document.addEventListener('alpine:init', function() {
var d = await r.json();
this.llmTestOk = !!d.ok;
this.llmTestMsg = d.ok
? ('✓ ' + (d.model || '') + ' : ' + (d.reply || 'réponse reçue'))
? ('✓ ' + (d.model || '') + ' fonctionnel' + (d.reply ? ' — ' + d.reply : ''))
: ('✗ ' + (d.error || 'échec de connexion'));
// Reflète l'état sur le fournisseur courant du formulaire global.
var cur = this.providerById(this.llmForm.provider);
if (cur && this.llmForm.provider === this.llmDefaultProvider) {
this._setProviderState(this.llmForm.provider, d.ok, d.error || '');
if (d.ok) cur.verified_model = d.model || '';
}
} catch(e) { this.llmTestOk = false; this.llmTestMsg = 'Erreur réseau'; }
finally { this.llmTesting = false; }
},