web: panneau Acces OpenAI (endpoint /v1 + cle API masquee, gestion depuis le web)

Nouvel endpoint /api/apikey (GET etat + POST generate/set/clear -> restart).
Section sidebar affichant l'URL compatible OpenAI et la cle API masquee par
defaut (reveler/copier/generer/definir/retirer). Refactor config.go: helpers
genAPIKey/writeAPIKey/maskAPIKey partages CLI + web.
This commit is contained in:
nathaninline committed 2026-07-13 12:39:37 +02:00
1 parent d2e31ad6ee
commit 9a93702313
3 files changed
+135 -12

No files matched your search

+36 -11
View File
@@ -31,6 +31,40 @@ func authHeader(req *http.Request) {
}
}
// genAPIKey returns a fresh random OpenAI-style completion key.
func genAPIKey() string {
buf := make([]byte, 24)
_, _ = rand.Read(buf)
return "sk-jean-" + hex.EncodeToString(buf)
}
// writeAPIKey persists (key != "") or clears (key == "") the completion API key
// in $JEAN_HOME/.api_key. It also wipes any residual API_KEY in config.env to
// avoid ambiguity. It does NOT restart the service — callers decide when to
// apply (llama-server only reads --api-key at launch).
func writeAPIKey(key string) error {
_ = SetConfigKey("API_KEY", "")
if key == "" {
if err := os.Remove(apiKeyPath()); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
return os.WriteFile(apiKeyPath(), []byte(key+"\n"), 0o600)
}
// maskAPIKey renders a key for display: keep the "sk-jean-" prefix and last 4
// chars, elide the middle. Empty in → empty out.
func maskAPIKey(k string) string {
if k == "" {
return ""
}
if len(k) <= 12 {
return "…" + k[len(k)-2:]
}
return k[:8] + "…" + k[len(k)-4:]
}
// cmdSetAPIKey sets (or clears) the API key stored in $JEAN_HOME/.api_key. When
// exposed on the internet, llama-server requires "Authorization: Bearer <clé>"
// for every call.
@@ -42,11 +76,7 @@ func cmdSetAPIKey(args []string) error {
var key string
switch {
case len(args) == 0:
buf := make([]byte, 24)
if _, err := rand.Read(buf); err != nil {
return err
}
key = "sk-jean-" + hex.EncodeToString(buf)
key = genAPIKey()
fmt.Printf("%s clé générée : %s\n", green("[ok]"), bold(key))
case args[0] == "" || args[0] == "off" || args[0] == "none":
key = ""
@@ -55,12 +85,7 @@ func cmdSetAPIKey(args []string) error {
}
// Stocke dans le fichier dédié (survit aux switches de preset). On nettoie
// aussi un éventuel API_KEY résiduel dans config.env pour éviter la confusion.
_ = SetConfigKey("API_KEY", "")
if key == "" {
if err := os.Remove(apiKeyPath()); err != nil && !os.IsNotExist(err) {
return err
}
} else if err := os.WriteFile(apiKeyPath(), []byte(key+"\n"), 0o600); err != nil {
if err := writeAPIKey(key); err != nil {
return err
}
if key == "" {
+54 -1
View File
@@ -329,6 +329,25 @@ button:disabled{opacity:.5;cursor:not-allowed}
<details><summary>System prompt</summary>
<textarea id="sysprompt" placeholder="(optionnel) ex: tu es un assistant concis en français" oninput="saveSys()" style="width:100%;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px;font:inherit;min-height:70px;resize:vertical"></textarea>
</details>
<details><summary>Accès OpenAI</summary>
<div class="subhead">Endpoint compatible OpenAI<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Branche n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor…) sur cette URL. Modèle : <code>jean</code>. Si une clé est définie, le client doit envoyer <code>Authorization: Bearer &lt;clé&gt;</code>.</span></span></div>
<div class="row" style="margin-top:4px">
<input id="oai-url" readonly style="flex:1;min-width:0;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px;font:inherit;font-size:12px">
<button onclick="copyText(document.getElementById('oai-url').value,'URL copiée')" title="copier">⧉</button>
</div>
<div class="muted" style="font-size:11px;margin-top:3px">modèle : <code>jean</code></div>
<div class="subhead" style="margin-top:12px">Clé API<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Protège l'endpoint des complétions (llama-server). Indispensable avant d'exposer le serveur sur internet. Modifier la clé <b>redémarre</b> le service.</span></span></div>
<div class="row" style="margin-top:4px">
<input id="oai-key" readonly style="flex:1;min-width:0;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px;font:inherit;font-size:12px">
<button id="oai-key-eye" onclick="toggleKeyReveal()" title="afficher / masquer">👁</button>
<button onclick="copyApiKey()" title="copier la clé">⧉</button>
</div>
<div class="row" style="margin-top:6px">
<button onclick="apiKeyAction('generate')">générer</button>
<button onclick="apiKeySet()">définir…</button>
<button onclick="apiKeyAction('clear')">retirer</button>
</div>
</details>
<details><summary>Service</summary>
<div class="row">
<button onclick="act('start')">start</button>
@@ -726,6 +745,40 @@ function renderInternet(s){
else { st.innerHTML='⚠️ serveur injoignable — les outils web ne seront pas proposés'; }
}
async function loadInternet(){ renderInternet(await jget('/api/internet')); }
// --- Accès OpenAI (endpoint /v1 + clé API des complétions) -----------------
let OAI_KEY='', OAI_REVEAL=false;
async function copyText(txt, msg){
if(!txt){ toast('rien à copier'); return; }
try{ await navigator.clipboard.writeText(txt); }
catch(_){ const ta=document.createElement('textarea'); ta.value=txt; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); }
toast(msg||'copié');
}
function copyApiKey(){ copyText(OAI_KEY, OAI_KEY?'clé copiée':'aucune clé'); }
function renderApiKey(d){
OAI_KEY = d.key || '';
// URL de l'endpoint : llama-server tourne sur d.port (≠ port de l'UI web).
const proto = location.protocol==='https:' ? 'https:' : 'http:';
document.getElementById('oai-url').value = proto+'//'+location.hostname+':'+d.port+'/v1';
const inp=document.getElementById('oai-key');
if(!d.set){ inp.value='(aucune clé — serveur ouvert)'; inp.style.opacity=.6; }
else { inp.style.opacity=1; inp.value = OAI_REVEAL ? OAI_KEY : d.masked; }
document.getElementById('oai-key-eye').style.display = d.set ? '' : 'none';
}
async function loadApiKey(){ renderApiKey(await jget('/api/apikey')); }
function toggleKeyReveal(){ OAI_REVEAL=!OAI_REVEAL; const inp=document.getElementById('oai-key'); if(OAI_KEY) inp.value = OAI_REVEAL ? OAI_KEY : (OAI_KEY.slice(0,8)+'…'+OAI_KEY.slice(-4)); }
async function apiKeyAction(action){
if(action==='clear' && !await askConfirm('Retirer la clé rend l\'endpoint OpenAI accessible SANS authentification. Le service va redémarrer.', {title:'Retirer la clé API ?', okText:'Retirer'})) return;
if(action==='generate' && OAI_KEY && !await askConfirm('Générer une nouvelle clé invalide l\'ancienne (les clients devront être mis à jour). Le service va redémarrer.', {title:'Régénérer la clé ?', okText:'Générer'})) return;
OAI_REVEAL=(action==='generate'); // révèle la clé fraîche pour qu'on puisse la copier
toast('application…');
renderApiKey(await jpost('/api/apikey', {action}));
}
async function apiKeySet(){
const k = await askPrompt('Colle ta clé API (ou laisse vide pour annuler) :', {title:'Définir la clé API', placeholder:'sk-…'});
if(!k || !k.trim()) return;
OAI_REVEAL=true; toast('application…');
renderApiKey(await jpost('/api/apikey', {action:'set', key:k.trim()}));
}
async function toggleInternet(){
const on=document.getElementById('internet-toggle').checked;
const url=document.getElementById('crawl-url').value.trim();
@@ -736,7 +789,7 @@ async function saveCrawlUrl(){
const url=document.getElementById('crawl-url').value.trim();
renderInternet(await jpost('/api/internet',{url}));
}
async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadCfg(),loadPresets(),loadAgent(),loadInternet()]); }
async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadApiKey()]); }
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
function openBenchModal(){ document.getElementById('bench-modal').style.display = 'flex'; }
function closeBenchModal(){ document.getElementById('bench-modal').style.display = 'none'; }
+45
View File
@@ -86,6 +86,7 @@ func newWebMux() *http.ServeMux {
api("/api/agent", handleAgent)
api("/api/agent/toggle", handleAgentToggle)
api("/api/agent/tool-limit", handleToolLimitToggle)
api("/api/apikey", handleAPIKey)
api("/api/internet", handleInternet)
api("/api/memory", handleMemoryMode)
// Alias rétro-compat : l'ancien portail ajean.link (dépôt jean-relay) pilote
@@ -505,6 +506,50 @@ func handleAgentToggle(w http.ResponseWriter, r *http.Request) {
//
// GET → {enabled, url, reachable}
// POST {enabled, url} → enregistre CRAWL4AI_URL + le drapeau .internet_enabled
// handleAPIKey expose et pilote la clé d'accès à l'endpoint compatible OpenAI
// (llama-server /v1). GET renvoie l'état ; POST {action:"generate"|"set"|"clear",
// key?} l'écrit puis redémarre le service (llama-server lit --api-key au lancement).
func handleAPIKey(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
var req struct {
Action string `json:"action"`
Key string `json:"key"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
var key string
switch req.Action {
case "generate":
key = genAPIKey()
case "set":
key = strings.TrimSpace(req.Key)
case "clear":
key = ""
default:
sendJSON(w, 400, map[string]any{"ok": false, "error": "action inconnue"})
return
}
if err := writeAPIKey(key); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
// La clé n'est appliquée qu'au (re)démarrage de llama-server.
if serviceIsActive() {
_ = serviceAction("restart")
}
}
k := readAPIKey()
sendJSON(w, 200, map[string]any{
"ok": true,
"set": k != "",
"key": k,
"masked": maskAPIKey(k),
"port": LLMPort(),
})
}
func handleInternet(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
var req struct {