New Providers tab in Settings lets users add/update/remove API keys without editing .env. Six review fixes applied. 18 tests.
This commit is contained in:
@@ -276,6 +276,26 @@ const LOCALES = {
|
||||
password_placeholder: 'Enter new password…',
|
||||
disable_auth: 'Disable Auth',
|
||||
sign_out: 'Sign Out',
|
||||
// Providers panel
|
||||
providers_tab_title: 'Providers',
|
||||
providers_section_title: 'Providers',
|
||||
providers_section_meta: 'Manage API keys for AI providers. Changes take effect immediately.',
|
||||
providers_status_configured: 'API key configured',
|
||||
providers_status_not_configured: 'No API key',
|
||||
providers_status_oauth: 'OAuth',
|
||||
providers_status_api_key: 'API key',
|
||||
providers_status_not_configured_label: 'Not configured',
|
||||
providers_oauth_hint: 'Authenticated via OAuth. No API key needed.',
|
||||
providers_save: 'Save',
|
||||
providers_remove: 'Remove',
|
||||
providers_saving: 'Saving…',
|
||||
providers_removing: 'Removing…',
|
||||
providers_enter_key: 'Please enter an API key',
|
||||
providers_empty: 'No configurable providers found.',
|
||||
providers_key_updated: 'API key saved',
|
||||
providers_key_removed: 'API key removed',
|
||||
providers_key_placeholder_new: 'sk-...',
|
||||
providers_key_placeholder_replace: 'Enter new key to replace…',
|
||||
cancel: 'Cancel',
|
||||
create_job: 'Create job',
|
||||
save_skill: 'Save skill',
|
||||
|
||||
@@ -459,6 +459,10 @@
|
||||
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>
|
||||
<span class="settings-tab-title">Preferences</span>
|
||||
</button>
|
||||
<button class="settings-tab" id="settingsTabProviders" type="button" role="tab" aria-selected="false" aria-controls="settingsPaneProviders" onclick="switchSettingsSection('providers')">
|
||||
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
|
||||
<span class="settings-tab-title" data-i18n="providers_tab_title">Providers</span>
|
||||
</button>
|
||||
<button class="settings-tab" id="settingsTabSystem" type="button" role="tab" aria-selected="false" aria-controls="settingsPaneSystem" onclick="switchSettingsSection('system')">
|
||||
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><line x1="6" y1="7" x2="6.01" y2="7"/><line x1="6" y1="17" x2="6.01" y2="17"/></svg>
|
||||
<span class="settings-tab-title">System</span>
|
||||
@@ -622,6 +626,20 @@
|
||||
</div>
|
||||
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600" data-i18n="settings_save_btn">Save Settings</button>
|
||||
</div>
|
||||
<div class="settings-pane" id="settingsPaneProviders" role="tabpanel" aria-labelledby="settingsTabProviders">
|
||||
<div class="settings-section-head">
|
||||
<div>
|
||||
<div class="settings-section-title" data-i18n="providers_section_title">Providers</div>
|
||||
<div class="settings-section-meta" data-i18n="providers_section_meta">Manage API keys for AI providers. Changes take effect immediately.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="providersList" style="display:flex;flex-direction:column;gap:6px;margin-top:4px">
|
||||
<!-- Populated dynamically by loadProvidersPanel() -->
|
||||
</div>
|
||||
<div id="providersEmpty" style="display:none;text-align:center;padding:32px 0;color:var(--muted);font-size:13px" data-i18n="providers_empty">
|
||||
No configurable providers found.
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-pane" id="settingsPaneSystem" role="tabpanel" aria-labelledby="settingsTabSystem">
|
||||
<div class="settings-section-head">
|
||||
<div>
|
||||
|
||||
153
static/panels.js
153
static/panels.js
@@ -1128,10 +1128,10 @@ let _settingsHermesDefaultModelOnOpen = '';
|
||||
let _settingsSection = 'conversation';
|
||||
|
||||
function switchSettingsSection(name){
|
||||
const section=(name==='appearance'||name==='preferences'||name==='system')?name:'conversation';
|
||||
const section=(name==='appearance'||name==='preferences'||name==='providers'||name==='system')?name:'conversation';
|
||||
_settingsSection=section;
|
||||
const map={conversation:'Conversation',appearance:'Appearance',preferences:'Preferences',system:'System'};
|
||||
['conversation','appearance','preferences','system'].forEach(key=>{
|
||||
const map={conversation:'Conversation',appearance:'Appearance',preferences:'Preferences',providers:'Providers',system:'System'};
|
||||
['conversation','appearance','preferences','providers','system'].forEach(key=>{
|
||||
const tab=$('settingsTab'+map[key]);
|
||||
const pane=$('settingsPane'+map[key]);
|
||||
const active=key===section;
|
||||
@@ -1141,6 +1141,8 @@ function switchSettingsSection(name){
|
||||
}
|
||||
if(pane) pane.classList.toggle('active',active);
|
||||
});
|
||||
// Lazy-load providers when the tab is opened
|
||||
if(section==='providers') loadProvidersPanel();
|
||||
}
|
||||
|
||||
function _syncHermesPanelSessionActions(){
|
||||
@@ -1348,12 +1350,157 @@ async function loadSettingsPanel(){
|
||||
_setSettingsAuthButtonsVisible(!!authStatus.auth_enabled);
|
||||
}catch(e){}
|
||||
_syncHermesPanelSessionActions();
|
||||
loadProvidersPanel(); // load provider cards in background
|
||||
switchSettingsSection(_settingsSection);
|
||||
}catch(e){
|
||||
showToast(t('settings_load_failed')+e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Providers panel ───────────────────────────────────────────────────────
|
||||
|
||||
const _providerCardEls = new Map(); // providerId → {card, statusDot, input, saveBtn, removeBtn}
|
||||
|
||||
async function loadProvidersPanel(){
|
||||
const list=$('providersList');
|
||||
const empty=$('providersEmpty');
|
||||
if(!list) return;
|
||||
try{
|
||||
const data=await api('/api/providers');
|
||||
const providers=(data.providers||[]).filter(p=>p.configurable);
|
||||
list.innerHTML='';
|
||||
_providerCardEls.clear();
|
||||
if(providers.length===0){
|
||||
list.style.display='none';
|
||||
if(empty) empty.style.display='';
|
||||
return;
|
||||
}
|
||||
if(empty) empty.style.display='none';
|
||||
list.style.display='';
|
||||
for(const p of providers){
|
||||
list.appendChild(_buildProviderCard(p));
|
||||
}
|
||||
}catch(e){
|
||||
list.innerHTML='<div style="color:var(--error);padding:12px;font-size:13px">Failed to load providers: '+e.message+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function _buildProviderCard(p){
|
||||
const card=document.createElement('div');
|
||||
card.className='provider-card';
|
||||
card.dataset.provider=p.id;
|
||||
const isOauth=p.key_source==='oauth';
|
||||
const statusColor=p.has_key?'var(--ok, #4ade80)':'var(--muted)';
|
||||
const statusTitle=p.has_key?t('providers_status_configured'):t('providers_status_not_configured');
|
||||
|
||||
// Header row
|
||||
const header=document.createElement('div');
|
||||
header.className='provider-card-header';
|
||||
const info=document.createElement('div');
|
||||
info.className='provider-card-info';
|
||||
info.style.cssText='display:flex;align-items:center;gap:8px;';
|
||||
const nameEl=document.createElement('span');
|
||||
nameEl.className='provider-card-name';
|
||||
nameEl.style.cssText='font-weight:600;font-size:13px;';
|
||||
nameEl.textContent=p.display_name;
|
||||
const dot=document.createElement('span');
|
||||
dot.className='provider-card-dot';
|
||||
dot.title=statusTitle;
|
||||
dot.style.cssText='width:8px;height:8px;border-radius:50%;background:'+statusColor+';display:inline-block;flex-shrink:0';
|
||||
const sourceEl=document.createElement('span');
|
||||
sourceEl.className='provider-card-source';
|
||||
sourceEl.style.cssText='font-size:11px;color:var(--muted)';
|
||||
sourceEl.textContent=isOauth?t('providers_status_oauth'):(p.has_key?t('providers_status_api_key'):t('providers_status_not_configured_label'));
|
||||
info.appendChild(nameEl);
|
||||
info.appendChild(dot);
|
||||
info.appendChild(sourceEl);
|
||||
header.appendChild(info);
|
||||
card.appendChild(header);
|
||||
|
||||
if(isOauth){
|
||||
const hint=document.createElement('div');
|
||||
hint.style.cssText='font-size:11px;color:var(--muted);margin-top:4px;padding-left:2px';
|
||||
hint.textContent=t('providers_oauth_hint');
|
||||
card.appendChild(hint);
|
||||
}else{
|
||||
const actions=document.createElement('div');
|
||||
actions.className='provider-card-actions';
|
||||
actions.style.cssText='margin-top:6px;display:flex;gap:6px;align-items:center';
|
||||
const input=document.createElement('input');
|
||||
input.type='password';
|
||||
input.placeholder=p.has_key?t('providers_key_placeholder_replace'):t('providers_key_placeholder_new');
|
||||
input.style.cssText='flex:1;padding:6px 8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:12px;font-family:monospace';
|
||||
input.autocomplete='off';
|
||||
const saveBtn=document.createElement('button');
|
||||
saveBtn.className='sm-btn provider-save-btn';
|
||||
saveBtn.style.cssText='padding:5px 12px;font-size:12px;white-space:nowrap';
|
||||
saveBtn.textContent=t('providers_save');
|
||||
saveBtn.onclick=()=>_saveProviderKey(p.id);
|
||||
actions.appendChild(input);
|
||||
actions.appendChild(saveBtn);
|
||||
if(p.has_key){
|
||||
const removeBtn=document.createElement('button');
|
||||
removeBtn.className='sm-btn';
|
||||
removeBtn.style.cssText='padding:5px 10px;font-size:12px;color:var(--error);border-color:rgba(233,69,96,.25);white-space:nowrap';
|
||||
removeBtn.textContent=t('providers_remove');
|
||||
removeBtn.onclick=()=>_removeProviderKey(p.id);
|
||||
actions.appendChild(removeBtn);
|
||||
}
|
||||
card.appendChild(actions);
|
||||
_providerCardEls.set(p.id,{card,input,saveBtn,hasKey:p.has_key});
|
||||
input.addEventListener('input',()=>{saveBtn.disabled=!input.value.trim();});
|
||||
saveBtn.disabled=true;
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
async function _saveProviderKey(providerId){
|
||||
const els=_providerCardEls.get(providerId);
|
||||
if(!els) return;
|
||||
const key=els.input.value.trim();
|
||||
if(!key){
|
||||
showToast(t('providers_enter_key'));
|
||||
return;
|
||||
}
|
||||
els.saveBtn.disabled=true;
|
||||
els.saveBtn.textContent=t('providers_saving');
|
||||
try{
|
||||
const res=await api('/api/providers',{method:'POST',body:JSON.stringify({provider:providerId,api_key:key})});
|
||||
if(res.ok){
|
||||
showToast(res.provider+' key '+res.action);
|
||||
els.input.value='';
|
||||
await loadProvidersPanel(); // refresh list
|
||||
}else{
|
||||
showToast(res.error||'Failed to save key');
|
||||
els.saveBtn.disabled=false;
|
||||
els.saveBtn.textContent=t('providers_save');
|
||||
}
|
||||
}catch(e){
|
||||
showToast('Error: '+e.message);
|
||||
els.saveBtn.disabled=false;
|
||||
els.saveBtn.textContent=t('providers_save');
|
||||
}
|
||||
}
|
||||
|
||||
async function _removeProviderKey(providerId){
|
||||
const els=_providerCardEls.get(providerId);
|
||||
if(!els) return;
|
||||
if(els.saveBtn){els.saveBtn.disabled=true;els.saveBtn.textContent=t('providers_removing');}
|
||||
try{
|
||||
const res=await api('/api/providers/delete',{method:'POST',body:JSON.stringify({provider:providerId})});
|
||||
if(res.ok){
|
||||
showToast(res.provider+' key '+t('providers_key_removed').toLowerCase());
|
||||
await loadProvidersPanel(); // refresh list
|
||||
}else{
|
||||
showToast(res.error||'Failed to remove key');
|
||||
if(els.saveBtn){els.saveBtn.disabled=false;els.saveBtn.textContent=t('providers_save');}
|
||||
}
|
||||
}catch(e){
|
||||
showToast('Error: '+e.message);
|
||||
if(els.saveBtn){els.saveBtn.disabled=false;els.saveBtn.textContent=t('providers_save');}
|
||||
}
|
||||
}
|
||||
|
||||
function _setSettingsAuthButtonsVisible(active){
|
||||
const signOutBtn=$('btnSignOut');
|
||||
if(signOutBtn) signOutBtn.style.display=active?'':'none';
|
||||
|
||||
@@ -1465,6 +1465,12 @@ body.resizing{user-select:none;cursor:col-resize;}
|
||||
.settings-panel .settings-btn{background:var(--accent);color:#fff;border:none;border-radius:6px;padding:8px 16px;cursor:pointer;font-weight:600;font-size:13px;}
|
||||
.settings-panel .settings-btn:hover{opacity:.9;}
|
||||
|
||||
/* ── Provider cards (settings panel) ── */
|
||||
.provider-card{padding:10px 12px;border:1px solid var(--border);border-radius:8px;background:var(--code-bg);transition:border-color .15s;}
|
||||
.provider-card:hover{border-color:var(--border2);}
|
||||
.provider-card-name{font-weight:600;font-size:13px;}
|
||||
.provider-card .sm-btn:disabled{opacity:.4;cursor:not-allowed;}
|
||||
|
||||
/* ── Session pin indicator (inline, only when pinned) ── */
|
||||
.session-pin-indicator{flex-shrink:0;color:#f5c542;line-height:1;display:flex;align-items:center;}
|
||||
.session-pin-indicator svg{width:10px;height:10px;}
|
||||
|
||||
Reference in New Issue
Block a user