// settings-provider.js — 설정: 인증/로그아웃, 모델·프로바이더·크리덴셜, 오케스트레이션 프리셋/설정.
// extracted from app.js (v4.3.19-3 split).
const LAST_USER_KEY = 'homeclaw_last_user';
function clearLocalUserState(reason) {
try {
localStorage.removeItem(chatSessionsKey());
localStorage.removeItem(agentSessionKey());
} catch {}
try {
chatSessions = [];
activeChatSessionId = null;
} catch {}
setAgentSessionId(generateSessionId());
// Rebuild a fresh "New chat" + re-render so the UI does not briefly
// display the previous user's session list before the wipe takes effect.
try { if (typeof loadChatSessions === 'function') loadChatSessions(); } catch {}
try { if (typeof renderSessionsList === 'function') renderSessionsList(); } catch {}
try { if (typeof renderMessages === 'function') renderMessages(); } catch {}
console.log(`[auth] cleared local session state (${reason})`);
}
function applyUserBranding() {
userLogoUrl = `/api/branding/logo?_=${Date.now()}`;
brandName = (currentUser && currentUser.brandName) || 'HomeClaw';
const headerLogo = document.querySelector('header .logo img');
if (headerLogo) headerLogo.src = userLogoUrl;
document.querySelectorAll('.chat-welcome-icon img').forEach((img) => { img.src = userLogoUrl; });
const headerName = document.getElementById('header-brand-name');
if (headerName) headerName.textContent = brandName;
const welcomeName = document.getElementById('welcome-brand-name');
if (welcomeName) welcomeName.textContent = brandName;
if (typeof renderChatMessages === 'function') renderChatMessages();
}
async function checkAuth() {
try {
const res = await fetch('/api/auth/status', { headers: authHeaders() });
const data = await res.json();
if (data.enabled && !data.authenticated && !data.hasPassword) {
window.location.href='/html/login.html';
return;
}
if (data.enabled && !data.authenticated) {
window.location.href='/html/login.html';
return;
}
if (data.authenticated && data.username) {
currentUser = { username: data.username, role: data.role, brandName: data.brandName };
applyUserBranding();
// Reload chat sessions under the correct user-scoped key now that we know who is logged in.
// This also migrates any data from the old non-namespaced key on first load after server restart.
if (typeof loadChatSessions === 'function') loadChatSessions();
_fetchAppCtxMax();
// Multi-user safety: if a different user was logged in on this browser
// before, wipe the cached session list & active sessionId so the
// incoming user does not reuse the previous user's chat IDs.
const lastUser = (localStorage.getItem(LAST_USER_KEY) || '').trim();
if (lastUser && lastUser !== data.username) {
clearLocalUserState(`user changed: ${lastUser} -> ${data.username}`);
}
try { localStorage.setItem(LAST_USER_KEY, data.username); } catch {}
// Show user pill with username
const pill = document.getElementById('user-info-pill');
const nameEl = document.getElementById('user-info-name');
if (pill) pill.style.display = 'flex';
if (nameEl) nameEl.textContent = data.username;
const mobileLogout = document.getElementById('mob-tab-logout');
if (mobileLogout) mobileLogout.style.display = 'flex';
}
} catch (e) {
console.error('Auth check failed:', e);
}
}
async function logout() {
try {
await fetch('/api/auth/logout', { method: 'POST', headers: authHeaders() });
} catch (e) { /* ignore network errors */ }
// Wipe cached chat list / sessionId so a different user logging in next
// does not inherit any of this user's session IDs.
clearLocalUserState('logout');
try { localStorage.removeItem(LAST_USER_KEY); } catch {}
setAuthToken('');
window.location.href='/html/login.html';
}
async function loadSearchSettingsSummary() {
try {
const s = await api('/api/settings/search');
const el = document.getElementById('r-failed');
if (el) el.textContent = s.preferred_provider || 'ddg';
quickSearchRigor = s.search_rigor || 'verified';
updateQuickModeUI();
} catch {}
}
function updateQuickModeUI() {
const lbl = document.getElementById('quick-mode-label');
if (lbl) lbl.textContent = (quickSearchRigor || 'verified').replace(/^./, c => c.toUpperCase());
['fast', 'verified', 'strict'].forEach(v => {
const el = document.getElementById(`rigor-${v}`);
if (el) el.classList.toggle('active', quickSearchRigor === v);
});
['standard', 'extended'].forEach(v => {
const el = document.getElementById(`think-${v}`);
if (el) el.classList.toggle('active', quickThinkingEffort === v);
});
}
function toggleQuickModePopover() {
const pop = document.getElementById('quick-mode-popover');
if (!pop) return;
const nextOpen = !pop.classList.contains('open');
pop.classList.toggle('open', nextOpen);
if (nextOpen) {
pop.classList.remove('clamp-left');
requestAnimationFrame(() => {
const rect = pop.getBoundingClientRect();
if (rect.left < 8) pop.classList.add('clamp-left');
const after = pop.getBoundingClientRect();
if (after.top < 8) {
pop.style.bottom = 'auto';
pop.style.top = '34px';
} else {
pop.style.top = '';
pop.style.bottom = '34px';
}
});
}
updateQuickModeUI();
}
function toggleOllamaModelRow(provider) {
const row = document.getElementById('search-ollama-model-row');
if (row) row.style.display = provider === 'ollama' ? 'block' : 'none';
}
function toggleSearxngUrlRow(provider) {
const row = document.getElementById('search-searxng-url-row');
if (row) row.style.display = provider === 'searxng' ? 'block' : 'none';
}
async function setQuickSearchRigor(level) {
quickSearchRigor = level;
updateQuickModeUI();
try {
const s = await api('/api/settings/search');
const payload = {
preferred_provider: s.preferred_provider || 'ddg',
search_rigor: level,
tavily_api_key: s.tavily_api_key || '',
google_api_key: s.google_api_key || '',
google_cx: s.google_cx || '',
brave_api_key: s.brave_api_key || '',
};
await api('/api/settings/search', { method: 'POST', body: JSON.stringify(payload) });
addProcessEntry('info', `Search rigor set to ${level}.`);
await loadSearchSettingsSummary();
} catch (err) {
addProcessEntry('error', `Failed to set search rigor: ${err.message}`);
}
}
function setQuickThinkingEffort(level) {
quickThinkingEffort = level === 'extended' ? 'extended' : 'standard';
localStorage.setItem('homeclaw_quick_thinking_effort', quickThinkingEffort);
updateQuickModeUI();
addProcessEntry('info', `Thinking effort set to ${quickThinkingEffort} (UI preference).`);
}
function setSettingsTab(tab) {
settingsTab = tab;
const tabs = ['system', 'search', 'credentials', 'policy', 'security', 'users', 'models', 'agents', 'channels', 'integrations', 'voice', 'session', 'context-viewer'];
tabs.forEach(t => {
const btn = document.getElementById(`settings-tab-${t}`);
const panel = document.getElementById(`settings-panel-${t}`);
if (btn) {
btn.style.background = (t === tab) ? '#eaf2ff' : '#fff';
btn.style.borderColor = (t === tab) ? '#bdd3f6' : 'var(--line)';
btn.style.color = (t === tab) ? '#0d4faf' : 'var(--muted)';
}
if (panel) {
if (t === tab) {
panel.style.display = 'block';
if (t === 'channels') loadChannelsStatus();
if (t === 'models') loadModelSettings();
if (t === 'agents') loadAgentsTab();
if (t === 'integrations') loadIntegrationsTab();
if (t === 'credentials') loadCredentialsTab();
if (t === 'users') loadUsersTab();
if (t === 'voice') loadVoiceSettings();
if (t === 'session') loadSessionSettings();
if (t === 'context-viewer') { loadContextViewerTab(); loadBannedIpsTab(); }
} else {
panel.style.display = 'none';
}
}
});
}
// --- Credentials Tab ---------------------------------------------------------
async function loadCredentialsTab() {
await Promise.all([loadCredFields(), loadCredVaultStatus(), loadCredVaultLog(), loadCredIssuers()]);
}
async function loadCredFields() {
try {
const s = await api('/api/settings/search');
// Server returns '••••••••' if key is set, '' if not
const setField = (id, val) => {
const el = document.getElementById(id);
if (!el) return;
el.value = val || '';
el.placeholder = val ? '•••••••• (key stored — enter new value to replace)' : el.getAttribute('data-placeholder') || '';
};
setField('cred-tavily-key', s.tavily_api_key);
setField('cred-google-key', s.google_api_key);
setField('cred-brave-key', s.brave_api_key);
const cxEl = document.getElementById('cred-google-cx');
if (cxEl) cxEl.value = s.google_cx || '';
} catch(e) {
console.warn('loadCredFields:', e);
}
// OpenWeather key is stored separately
try {
const ow = await api('/api/settings/openweather');
const owEl = document.getElementById('cred-ow-key');
if (owEl) {
owEl.value = ow.api_key || '';
owEl.placeholder = ow.api_key ? '•••••••• (key stored — enter new value to replace)' : '';
}
} catch(e) {
console.warn('loadCredFields openweather:', e);
}
}
async function loadCredVaultStatus() {
const el = document.getElementById('cred-vault-status');
if (!el) return;
try {
const data = await api('/api/credentials/status');
const keys = data.keys || [];
if (!keys.length) {
el.innerHTML = '⚠ No credentials stored yet.';
return;
}
el.innerHTML = keys.map(k => {
const label = {
'search.tavily_api_key': 'Tavily API Key',
'search.google_api_key': 'Google API Key',
'search.brave_api_key': 'Brave API Key',
'llm.openai.api_key': 'OpenAI API Key',
'hooks.token': 'Webhook Token',
'channels.telegram.botToken': 'Telegram Token',
'channels.discord.botToken': 'Discord Token',
'openweather.api_key': 'OpenWeather API Key',
}[k] || k;
return `
✓
${label}
${k}
`;
}).join('');
} catch(e) {
el.innerHTML = `Could not load vault status: ${e.message}`;
}
}
// Where each stored credential is issued/renewed. Keys are opaque strings in the vault and the
// issuing site is nowhere in the UI, so renewing one meant hunting for the right console — and
// data.go.kr in particular needs a *per-service* 활용신청, which is easy to forget (the AirKorea
// 측정소정보 service 403'd for exactly that reason on 2026-08-09).
const CRED_ISSUERS = [
{ key: 'search.tavily_api_key', name: 'Tavily', url: 'https://app.tavily.com/home', note: '웹검색' },
{ key: 'search.google_api_key', name: 'Google API', url: 'https://console.cloud.google.com/apis/credentials', note: '검색(CSE)' },
{ key: 'search.brave_api_key', name: 'Brave Search', url: 'https://api-dashboard.search.brave.com/app/keys', note: '웹검색' },
{ key: 'search.ollama_api_key', name: 'Ollama', url: 'https://ollama.com/settings/keys', note: '클라우드 모델·웹검색' },
{ key: 'llm.google.api_key', name: 'Google AI Studio', url: 'https://aistudio.google.com/apikey', note: 'Gemini' },
{ key: 'anthropic.api_key', name: 'Anthropic', url: 'https://console.anthropic.com/settings/keys', note: 'Claude' },
{ key: 'llm.openai.api_key', name: 'OpenAI', url: 'https://platform.openai.com/api-keys', note: 'GPT' },
{ key: 'openweather.api_key', name: 'OpenWeather', url: 'https://home.openweathermap.org/api_keys', note: '날씨' },
{ key: 'kma.api_key', name: '공공데이터포털', url: 'https://www.data.go.kr/iim/api/selectAPIAcountView.do', note: '기상청·에어코리아 — 서비스별로 활용신청 필요' },
{ key: 'cds.api_key', name: 'Copernicus CDS', url: 'https://cds.climate.copernicus.eu/profile', note: '기후 재분석' },
{ key: 'news.newsdata_api_key', name: 'NewsData.io', url: 'https://newsdata.io/api-key', note: '뉴스' },
{ key: 'pubmed.api_key', name: 'NCBI PubMed', url: 'https://account.ncbi.nlm.nih.gov/settings/', note: '논문' },
{ key: 'traffic.its_api_key', name: 'ITS 국가교통정보', url: 'https://www.its.go.kr/opendata/', note: '교통' },
{ key: 'ppt.unsplash_key', name: 'Unsplash', url: 'https://unsplash.com/oauth/applications', note: 'PPT 이미지' },
{ key: 'ppt.pexels_key', name: 'Pexels', url: 'https://www.pexels.com/api/', note: 'PPT 이미지' },
{ key: 'ppt.pixabay_key', name: 'Pixabay', url: 'https://pixabay.com/api/docs/', note: 'PPT 이미지' },
{ key: 'channels.telegram.botToken', name: 'Telegram BotFather', url: 'https://t.me/BotFather', note: '텔레그램 봇' },
{ key: 'channels.kakao.appKey', name: 'Kakao Developers', url: 'https://developers.kakao.com/console/app', note: '카카오' },
{ key: 'writer.providers.aladin.apiKey', name: '알라딘 TTB', url: 'https://www.aladin.co.kr/ttb/wblog_manage.aspx', note: '도서 검색' },
];
async function loadCredIssuers() {
const el = document.getElementById('cred-issuers');
if (!el) return;
let stored = new Set();
try {
const data = await api('/api/credentials/status');
stored = new Set(data.keys || []);
} catch (e) {
// Listing the sites is still useful without the vault — just don't claim what is stored.
el.innerHTML = `저장 상태를 못 읽었습니다 (${escHtml(e.message)}) — 링크만 표시합니다.
`;
}
const rows = CRED_ISSUERS.map(s => {
const has = stored.has(s.key);
const mark = has
? '✓'
: '·';
return ``;
}).join('');
el.innerHTML = (el.innerHTML.includes('저장 상태를 못 읽었습니다') ? el.innerHTML : '') + rows;
}
async function loadCredVaultLog() {
const el = document.getElementById('cred-vault-log');
if (!el) return;
try {
const data = await api('/api/credentials/audit');
const lines = (data.lines || []).slice(-18).reverse();
if (!lines.length) { el.textContent = '감사 항목 없음.'; return; }
el.innerHTML = lines.map(l => {
const safe = l.replace(/&/g,'&').replace(//g,'>');
const color = l.includes('SET') ? 'var(--ok)' : l.includes('GET') ? 'var(--brand)' : l.includes('DEL') ? 'var(--err)' : 'var(--muted)';
return `${safe}
`;
}).join('');
} catch(e) {
el.textContent = '감사 로그를 불러올 수 없습니다.';
}
}
function toggleCredVis(inputId, btn) {
const el = document.getElementById(inputId);
if (!el) return;
if (el.type === 'password') {
el.type = 'text';
btn.textContent = '🙈';
// Auto-rehide after 8s
setTimeout(() => { el.type = 'password'; btn.textContent = '👁'; }, 8000);
} else {
el.type = 'password';
btn.textContent = '👁';
}
}
// --- Provider-aware Model Settings ------------------------------------------
const PROVIDER_IDS = ['ollama', 'ollama_local', 'llama_cpp', 'lm_studio', 'openai', 'openai_codex', 'anthropic', 'google'];
function onProviderChange() {
const provider = document.getElementById('settings-llm-provider').value;
// Show only the matching fields panel
PROVIDER_IDS.forEach(id => {
const el = document.getElementById('prov-fields-' + id);
if (el) el.style.display = id === provider ? 'block' : 'none';
});
if (provider === 'openai') {
refreshOpenAIModels(true).catch(() => {});
} else if (['ollama', 'lm_studio', 'llama_cpp'].includes(provider)) {
refreshProviderModels().catch(() => {});
}
syncOrchPreemptControls();
}
async function loadModelSettings() {
try {
const data = await api('/api/settings/provider');
const llm = data?.llm || { provider: 'ollama', providers: {} };
const prov = llm.provider || 'ollama';
const provSel = document.getElementById('settings-llm-provider');
if (provSel) provSel.value = prov;
// Only toggle visibility, skip model refresh here to avoid double-loading
PROVIDER_IDS.forEach(id => {
const el = document.getElementById('prov-fields-' + id);
if (el) el.style.display = id === prov ? 'block' : 'none';
});
const pc = llm.providers || {};
// Populate each provider's fields from saved config
const v = (id, val) => { const el = document.getElementById(id); if (el && val) el.value = val; };
v('settings-ollama-endpoint', pc.ollama?.endpoint);
v('settings-ollamalocal-endpoint', pc.ollama_local?.endpoint);
// 지서버 is deliberately NOT auto-refreshed on provider change (that would fire a WOL packet
// and wait ~45s just for picking an entry in a dropdown), so its