205205 </ div >
206206 </ div >
207207 </ div >
208+
209+ <!-- Spacer pushes Settings to the bottom of the sidebar (gear convention) -->
210+ < div class ="sidebar-spacer " aria-hidden ="true "> </ div >
211+ < button class ="sidebar-btn "
212+ :class ="{ active: $store.nav.is('settings') } "
213+ @click ="$store.nav.go('settings') ">
214+ < svg width ="20 " height ="20 " viewBox ="0 0 24 24 " fill ="none " stroke ="currentColor " stroke-width ="2 "> < circle cx ="12 " cy ="12 " r ="3 "/> < path d ="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z "/> </ svg >
215+ < span > Settings</ span >
216+ </ button >
208217 </ nav >
209218
210219 <!-- Content area -->
@@ -1279,6 +1288,87 @@ <h2>Start a Study Session</h2>
12791288 </ div >
12801289 </ div >
12811290
1291+ <!-- ============================================================ -->
1292+ <!-- SETTINGS VIEW — LLM Providers admin -->
1293+ <!-- ============================================================ -->
1294+ < div x-show ="$store.nav.is('settings') " x-cloak
1295+ x-data ="settingsPanel() " x-init ="init() ">
1296+ < div class ="settings-panel ">
1297+ < h2 > Settings</ h2 >
1298+ < section class ="settings-section ">
1299+ < h3 > LLM Providers</ h3 >
1300+ < p class ="settings-blurb ">
1301+ Manage credentials for content generation. Keys are encrypted at
1302+ rest in < code > ~/.config/studyloop/</ code > and never leave this machine.
1303+ </ p >
1304+
1305+ < template x-for ="p in providers " :key ="p.slug ">
1306+ < div class ="provider-row ">
1307+ < div class ="provider-row-header ">
1308+ < span class ="provider-label " x-text ="p.label "> </ span >
1309+ < span class ="provider-status "
1310+ :class ="p.available ? 'status-ok' : 'status-missing' "
1311+ x-text ="p.available ? 'configured' : 'not configured' "> </ span >
1312+ </ div >
1313+
1314+ <!-- API-key providers -->
1315+ < div x-show ="p.auth_kind === 'api_key' " class ="provider-controls ">
1316+ < input type ="password " x-model ="inputs[p.slug] "
1317+ :placeholder ="p.available ? '••••••••• (stored — type to replace)' : 'Paste API key' "
1318+ autocomplete ="off " spellcheck ="false " />
1319+ < button type ="button " @click ="saveKey(p.slug) "
1320+ :disabled ="busy === p.slug || !inputs[p.slug]?.trim() "
1321+ x-text ="busy === p.slug ? 'Testing…' : 'Test & save' "> </ button >
1322+ < button type ="button " class ="danger-btn " x-show ="p.available "
1323+ @click ="deleteKey(p.slug) "> Delete</ button >
1324+ < button type ="button " @click ="testOnly(p.slug) "
1325+ :disabled ="busy === p.slug || !p.available "> Test</ button >
1326+ </ div >
1327+
1328+ <!-- Bedrock bearer token -->
1329+ < div x-show ="p.auth_kind === 'bedrock_bearer' " class ="provider-controls ">
1330+ < input type ="password " x-model ="inputs['bedrock'] "
1331+ placeholder ="Paste AWS bearer token (optional) "
1332+ autocomplete ="off " spellcheck ="false " />
1333+ < button type ="button " @click ="saveBearer() "
1334+ :disabled ="busy === 'bedrock' || !inputs['bedrock']?.trim() "
1335+ x-text ="busy === 'bedrock' ? 'Testing…' : 'Test & save' "> </ button >
1336+ < button type ="button " class ="danger-btn " x-show ="p.available "
1337+ @click ="deleteBearer() "> Delete token</ button >
1338+ < button type ="button " @click ="testOnly('bedrock') "
1339+ :disabled ="busy === 'bedrock' "> Test AWS creds</ button >
1340+ < small class ="settings-hint ">
1341+ Bearer token (< code > AWS_BEARER_TOKEN_BEDROCK</ code > ) for accounts that
1342+ use token auth. Leave empty to use your AWS profile / IAM role instead.
1343+ </ small >
1344+ </ div >
1345+
1346+ <!-- Ollama (local, keyless) -->
1347+ < div x-show ="p.auth_kind === 'local_keyless' " class ="provider-controls ">
1348+ < input type ="text " x-model ="inputs['ollama'] "
1349+ :placeholder ="p.base_url || 'http://localhost:11434' "
1350+ autocomplete ="off " spellcheck ="false " />
1351+ < button type ="button " @click ="saveOllamaUrl() "
1352+ :disabled ="busy === 'ollama' || !inputs['ollama']?.trim() "> Save URL</ button >
1353+ < button type ="button " @click ="testOnly('ollama') "
1354+ :disabled ="busy === 'ollama' "
1355+ x-text ="busy === 'ollama' ? 'Testing…' : 'Test connection' "> </ button >
1356+ < small class ="settings-hint ">
1357+ Local & keyless. Test runs a real generation against a recommended
1358+ model — may take 10–30s on first call while the model loads.
1359+ </ small >
1360+ </ div >
1361+
1362+ < small x-show ="status[p.slug]?.error " class ="key-error "
1363+ x-text ="status[p.slug]?.error "> </ small >
1364+ < small x-show ="status[p.slug]?.ok " class ="key-ok "
1365+ x-text ="status[p.slug]?.ok "> </ small >
1366+ </ div >
1367+ </ template >
1368+ </ section >
1369+ </ div >
1370+ </ div >
1371+
12821372 </ div > <!-- .content-area -->
12831373 </ div > <!-- .app-layout -->
12841374
@@ -1293,7 +1383,7 @@ <h2>Start a Study Session</h2>
12931383
12941384 init ( ) {
12951385 const hash = window . location . hash . slice ( 1 ) ;
1296- const valid = [ 'flashcards' , 'quizzes' , 'generate' , 'body-double' , 'study-session' ] ;
1386+ const valid = [ 'flashcards' , 'quizzes' , 'generate' , 'body-double' , 'study-session' , 'settings' ] ;
12971387 if ( hash && valid . includes ( hash ) ) this . current = hash ;
12981388 } ,
12991389
@@ -1640,6 +1730,112 @@ <h2>Start a Study Session</h2>
16401730 } ;
16411731 }
16421732
1733+ /* ------------------------------------------------------------------
1734+ * Settings panel — LLM Providers admin (add/change/delete/test keys)
1735+ * ------------------------------------------------------------------ */
1736+ function settingsPanel ( ) {
1737+ return {
1738+ providers : [ ] ,
1739+ inputs : { } , // { slug: string } live input values
1740+ status : { } , // { slug: { ok: string, error: string } }
1741+ busy : '' , // slug whose request is in flight
1742+
1743+ async init ( ) {
1744+ await this . refreshProviders ( ) ;
1745+ } ,
1746+
1747+ async refreshProviders ( ) {
1748+ try {
1749+ const r = await fetch ( '/api/content/providers' ) ;
1750+ if ( r . ok ) this . providers = await r . json ( ) ;
1751+ } catch { /* leave list as-is */ }
1752+ } ,
1753+
1754+ _ok ( slug , msg ) { this . status = { ...this . status , [ slug ] : { ok : msg , error : '' } } ; } ,
1755+ _err ( slug , msg ) { this . status = { ...this . status , [ slug ] : { ok : '' , error : msg } } ; } ,
1756+ _clear ( slug ) { this . status = { ...this . status , [ slug ] : { ok : '' , error : '' } } ; } ,
1757+
1758+ async _postSecret ( storeName , value , uiSlug ) {
1759+ // Test + store via the secrets route; refresh on success.
1760+ this . _clear ( uiSlug ) ;
1761+ try {
1762+ const r = await fetch ( '/api/content/secrets' , {
1763+ method : 'POST' ,
1764+ headers : { 'Content-Type' : 'application/json' } ,
1765+ body : JSON . stringify ( { provider : storeName , key : value } ) ,
1766+ } ) ;
1767+ if ( r . ok ) {
1768+ this . inputs [ uiSlug ] = '' ;
1769+ this . _ok ( uiSlug , 'Verified and saved.' ) ;
1770+ await this . refreshProviders ( ) ;
1771+ } else {
1772+ let detail = 'Could not verify the value.' ;
1773+ try { detail = ( await r . json ( ) ) . detail || detail ; } catch { }
1774+ this . _err ( uiSlug , detail ) ;
1775+ }
1776+ } catch { this . _err ( uiSlug , 'Network error.' ) ; }
1777+ } ,
1778+
1779+ async _deleteSecret ( storeName , uiSlug , okMsg ) {
1780+ this . _clear ( uiSlug ) ;
1781+ try {
1782+ const r = await fetch ( `/api/content/secrets/${ storeName } ` , { method : 'DELETE' } ) ;
1783+ if ( r . ok ) { this . _ok ( uiSlug , okMsg ) ; await this . refreshProviders ( ) ; }
1784+ else { this . _err ( uiSlug , 'Delete failed.' ) ; }
1785+ } catch { this . _err ( uiSlug , 'Network error.' ) ; }
1786+ } ,
1787+
1788+ async saveKey ( slug ) {
1789+ const v = this . inputs [ slug ] ?. trim ( ) ;
1790+ if ( ! v || this . busy ) return ;
1791+ this . busy = slug ;
1792+ await this . _postSecret ( slug , v , slug ) ;
1793+ this . busy = '' ;
1794+ } ,
1795+
1796+ async deleteKey ( slug ) { await this . _deleteSecret ( slug , slug , 'Key deleted.' ) ; } ,
1797+
1798+ async saveBearer ( ) {
1799+ const v = this . inputs [ 'bedrock' ] ?. trim ( ) ;
1800+ if ( ! v || this . busy ) return ;
1801+ this . busy = 'bedrock' ;
1802+ await this . _postSecret ( 'bedrock_bearer_token' , v , 'bedrock' ) ;
1803+ this . busy = '' ;
1804+ } ,
1805+
1806+ async deleteBearer ( ) {
1807+ await this . _deleteSecret ( 'bedrock_bearer_token' , 'bedrock' , 'Bearer token deleted.' ) ;
1808+ } ,
1809+
1810+ async saveOllamaUrl ( ) {
1811+ const v = this . inputs [ 'ollama' ] ?. trim ( ) ;
1812+ if ( ! v || this . busy ) return ;
1813+ this . busy = 'ollama' ;
1814+ // ollama_base_url is a config value — stored without an auth-test.
1815+ await this . _postSecret ( 'ollama_base_url' , v , 'ollama' ) ;
1816+ this . busy = '' ;
1817+ } ,
1818+
1819+ async testOnly ( slug ) {
1820+ if ( this . busy ) return ;
1821+ this . busy = slug ;
1822+ this . _clear ( slug ) ;
1823+ try {
1824+ const key = slug === 'bedrock' ? ( this . inputs [ 'bedrock' ] ?. trim ( ) || '' ) : '' ;
1825+ const r = await fetch ( `/api/content/providers/${ slug } /test` , {
1826+ method : 'POST' ,
1827+ headers : { 'Content-Type' : 'application/json' } ,
1828+ body : JSON . stringify ( { key } ) ,
1829+ } ) ;
1830+ const data = await r . json ( ) ;
1831+ if ( data . ok ) this . _ok ( slug , data . message || 'Credentials valid.' ) ;
1832+ else this . _err ( slug , data . message || 'Test failed.' ) ;
1833+ } catch { this . _err ( slug , 'Network error.' ) ; }
1834+ this . busy = '' ;
1835+ } ,
1836+ } ;
1837+ }
1838+
16431839 /* ------------------------------------------------------------------
16441840 * Session timer component (from session.html)
16451841 * ------------------------------------------------------------------ */
0 commit comments