feat(agent): v4.11.0 - clés API par utilisateur, chargement dynamique modèles, commandes slash
This commit is contained in:
@@ -31,6 +31,12 @@
|
||||
.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-cmd-menu{position:absolute;bottom:100%;left:0;right:0;margin-bottom:6px;background:var(--bg-tertiary,#262626);border:1px solid var(--border,rgba(255,255,255,.1));border-radius:10px;max-height:180px;overflow-y:auto;box-shadow:0 -8px 24px rgba(0,0,0,.4);z-index:20;display:none}
|
||||
.fd-agent-cmd-menu.show{display:block}
|
||||
.fd-agent-cmd-item{padding:7px 12px;font-size:12px;color:var(--text,#eee);cursor:pointer;border-radius:6px;margin:2px;display:flex;gap:8px;align-items:baseline}
|
||||
.fd-agent-cmd-item:hover{background:var(--bg-hover,#333)}
|
||||
.fd-agent-cmd-item b{color:var(--accent,#2383E2);font-family:monospace;white-space:nowrap}
|
||||
.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)}
|
||||
</style>
|
||||
@@ -55,7 +61,7 @@
|
||||
<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>
|
||||
<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-tabs">
|
||||
@@ -94,7 +100,16 @@
|
||||
</div>
|
||||
|
||||
<div class="fd-agent-input-area">
|
||||
<textarea class="fd-agent-input" rows="2" placeholder="Demandez à l'agent (ex: crée une collection Projets)…" x-model="input" @keydown.enter.exact.prevent="send()"></textarea>
|
||||
<div style="position:relative;flex:1;">
|
||||
<div class="fd-agent-cmd-menu" :class="{show: commandOpen && commandSuggestions.length}">
|
||||
<template x-for="c in commandSuggestions" :key="c.cmd">
|
||||
<div class="fd-agent-cmd-item" @click="useSuggestion(c)">
|
||||
<b x-text="c.cmd"></b><span x-text="c.desc"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<textarea class="fd-agent-input" x-ref="cmdInput" rows="2" placeholder="Demandez à l'agent (ex: crée une collection Projets)… tapez / pour les commandes" x-model="input" @keydown.enter.exact.prevent="send()"></textarea>
|
||||
</div>
|
||||
<button class="fd-agent-send" @click="send()" :disabled="streaming || !input.trim()">▶</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,7 +123,7 @@
|
||||
agents: [], conversations: [], currentConv: null,
|
||||
messages: [], input: '', streaming: false, liveReasoning: '',
|
||||
provider: 'offline', providerAvailable: false, errorMsg: '',
|
||||
providers: [], providerModels: [], llmProvider: 'offline', llmModel: '',
|
||||
providers: [], providerModels: [], llmProvider: 'offline', llmModel: '', llmKeys: [],
|
||||
|
||||
init(){
|
||||
var self = this;
|
||||
@@ -128,6 +143,7 @@
|
||||
self.provider = d.provider || 'offline';
|
||||
self.providerAvailable = !!d.available;
|
||||
self.providers = d.providers || [];
|
||||
self.llmKeys = d.keys || [];
|
||||
if(!self.llmProvider) self.llmProvider = d.provider || 'offline';
|
||||
if(!self.llmModel) self.llmModel = d.model || '';
|
||||
self.applyModels();
|
||||
@@ -188,10 +204,100 @@
|
||||
},
|
||||
openAgent(){ this.open=true; this.loadConversations(); },
|
||||
close(){ this.open=false; },
|
||||
get commandOpen(){ return /^\s*\//.test(this.input) && !this.streaming; },
|
||||
get commandSuggestions(){
|
||||
var raw = (this.input||'').trim();
|
||||
if(!/^\//.test(raw)) return [];
|
||||
var q = raw.replace(/^\/+/, '').toLowerCase();
|
||||
var first = (q.split(/\s+/).filter(Boolean)[0] || '');
|
||||
var all = [
|
||||
{cmd:'/help', desc:'liste des commandes'},
|
||||
{cmd:'/providers', desc:'liste les fournisseurs et leurs clés'},
|
||||
{cmd:'/provider <nom>', desc:'changer de fournisseur LLM'},
|
||||
{cmd:'/model <nom>', desc:'changer de modèle LLM'},
|
||||
{cmd:'/keys', desc:'fournisseurs avec clé API enregistrée'}
|
||||
];
|
||||
if(!first) return all;
|
||||
return all.filter(function(c){ return c.cmd.toLowerCase().replace(/^\/+/, '').indexOf(first) === 0; });
|
||||
},
|
||||
useSuggestion(c){
|
||||
var v = c.cmd.replace(/\s*<[^>]*>\s*$/, ' ');
|
||||
this.input = v;
|
||||
if(this.$refs.cmdInput) this.$refs.cmdInput.focus();
|
||||
},
|
||||
echo(msg){
|
||||
this.messages.push({id:'cmd-'+Date.now()+Math.random(), role:'assistant', content: msg, model:''});
|
||||
this.scrollBottom();
|
||||
},
|
||||
patchConversation(){
|
||||
var self = this;
|
||||
if(!self.currentConv) return;
|
||||
var body = {};
|
||||
if(self.llmProvider) body.provider = self.llmProvider;
|
||||
if(self.llmModel) body.model = self.llmModel;
|
||||
fetch('/api/agent/conversations/'+self.currentConv.id, {
|
||||
method:'PATCH',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
}).catch(function(){});
|
||||
},
|
||||
handleCommand(text){
|
||||
var self = this;
|
||||
var parts = text.replace(/^\/+/, '').trim().split(/\s+/);
|
||||
var cmd = (parts.shift() || '').toLowerCase();
|
||||
var arg = parts.join(' ').trim();
|
||||
if(cmd === 'help' || cmd === 'aide' || cmd === '?'){
|
||||
this.echo('Commandes : /providers · /keys · /provider <nom> · /model <nom> · /help');
|
||||
return true;
|
||||
}
|
||||
if(cmd === 'providers'){
|
||||
var list = this.providers.map(function(x){ return x.id + (x.has_key ? ' ✓' : ''); }).join(', ');
|
||||
this.echo('Fournisseurs : ' + (list || 'aucun'));
|
||||
return true;
|
||||
}
|
||||
if(cmd === 'keys'){
|
||||
var saved = this.llmKeys.map(function(k){ return k.provider + ' ✓'; }).join(', ');
|
||||
this.echo('Clés enregistrées : ' + (saved || 'aucune — configurez-les dans Paramètres → Agent & IA'));
|
||||
return true;
|
||||
}
|
||||
if(cmd === 'provider'){
|
||||
var avail = this.providers.map(function(x){ return x.id; });
|
||||
if(!arg){
|
||||
this.echo('Utilisez : /provider <nom>. Fournisseurs : ' + avail.join(', '));
|
||||
return true;
|
||||
}
|
||||
var p = this.providers.find(function(x){ return x.id === arg.toLowerCase(); });
|
||||
if(!p){
|
||||
this.echo('Fournisseur inconnu : ' + arg + ' (disponibles : ' + avail.join(', ') + ')');
|
||||
return true;
|
||||
}
|
||||
this.llmProvider = p.id;
|
||||
this.onProviderChange();
|
||||
if(p.models && p.models.length && this.providerModels.indexOf(this.llmModel) === -1){
|
||||
this.llmModel = (p.default_model) || p.models[0];
|
||||
}
|
||||
this.patchConversation();
|
||||
this.echo('Provider activé : ' + p.id + ' · modèle : ' + (this.llmModel || 'défaut'));
|
||||
return true;
|
||||
}
|
||||
if(cmd === 'model'){
|
||||
if(!arg){
|
||||
this.echo('Utilisez : /model <nom>. Modèles pour ' + this.llmProvider + ' : ' + this.providerModels.join(', '));
|
||||
return true;
|
||||
}
|
||||
this.llmModel = arg;
|
||||
this.patchConversation();
|
||||
this.echo('Modèle activé : ' + arg + ' (' + this.llmProvider + ')');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
send(){
|
||||
var self = this;
|
||||
if(!self.currentConv || self.streaming || !self.input.trim()) return;
|
||||
if(!self.currentConv || self.streaming) return;
|
||||
var text = self.input.trim();
|
||||
if(!text) return;
|
||||
if(self.commandOpen && self.handleCommand(text)){ self.input=''; return; }
|
||||
self.input = '';
|
||||
self.errorMsg = '';
|
||||
self.streaming = true;
|
||||
@@ -257,7 +363,7 @@
|
||||
this.$nextTick(function(){ var el = document.querySelector('.fd-agent-body'); if(el) el.scrollTop = el.scrollHeight; });
|
||||
},
|
||||
get providerLabel(){
|
||||
if(this.providerAvailable) return this.provider;
|
||||
if(this.providerAvailable) return (this.llmProvider || this.provider);
|
||||
return 'offline mock';
|
||||
}
|
||||
};
|
||||
|
||||
+159
-6
@@ -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 & 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 & 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>
|
||||
@@ -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 & 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');
|
||||
|
||||
Reference in New Issue
Block a user