Skip to content

Commit 882bbb3

Browse files
authored
Merge pull request #1 from boettiger-lab/feat/user-api-key-ghpages
Add user-provided API key support and GitHub Pages example
2 parents 5da4c83 + c82a1ca commit 882bbb3

File tree

9 files changed

+641
-12
lines changed

9 files changed

+641
-12
lines changed

.github/workflows/gh-pages.yml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: Deploy to GitHub Pages
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- 'example-ghpages/**'
8+
- '.github/workflows/gh-pages.yml'
9+
10+
# Allow manual trigger
11+
workflow_dispatch:
12+
13+
permissions:
14+
contents: read
15+
pages: write
16+
id-token: write
17+
18+
concurrency:
19+
group: pages
20+
cancel-in-progress: true
21+
22+
jobs:
23+
deploy:
24+
environment:
25+
name: github-pages
26+
url: ${{ steps.deployment.outputs.page_url }}
27+
runs-on: ubuntu-latest
28+
steps:
29+
- uses: actions/checkout@v4
30+
31+
- name: Setup Pages
32+
uses: actions/configure-pages@v5
33+
34+
- name: Upload artifact
35+
uses: actions/upload-pages-artifact@v3
36+
with:
37+
path: example-ghpages
38+
39+
- name: Deploy to GitHub Pages
40+
id: deployment
41+
uses: actions/deploy-pages@v4

app/chat-ui.js

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@ export class ChatUI {
5151
this.agent.setModel(this.modelSelector.value);
5252
});
5353

54+
// If in user-provided API key mode, add settings button
55+
if (this.config._userProvidedMode) {
56+
this.initSettingsUI();
57+
// If no API key saved yet, show the setup prompt
58+
if (!this.config.llm_models?.length) {
59+
this.showSettingsPanel();
60+
}
61+
}
62+
5463
// Wire agent callbacks
5564
this.agent.onThinkingStart = () => this.showThinking();
5665
this.agent.onThinkingEnd = () => this.hideThinking();
@@ -75,6 +84,119 @@ export class ChatUI {
7584
}
7685
}
7786

87+
/* ------------------------------------------------------------------ */
88+
/* Settings panel (user-provided API key mode) */
89+
/* ------------------------------------------------------------------ */
90+
91+
initSettingsUI() {
92+
const footer = document.getElementById('chat-footer');
93+
if (!footer) return;
94+
95+
const btn = document.createElement('button');
96+
btn.id = 'settings-btn';
97+
btn.title = 'API settings';
98+
btn.textContent = '\u2699';
99+
btn.addEventListener('click', () => this.toggleSettingsPanel());
100+
footer.prepend(btn);
101+
}
102+
103+
toggleSettingsPanel() {
104+
const existing = document.getElementById('api-settings-panel');
105+
if (existing) {
106+
existing.remove();
107+
return;
108+
}
109+
this.showSettingsPanel();
110+
}
111+
112+
showSettingsPanel() {
113+
// Remove any existing panel
114+
document.getElementById('api-settings-panel')?.remove();
115+
116+
const llmConfig = this.config.llm || {};
117+
const savedKey = localStorage.getItem('geo-agent-api-key') || '';
118+
const savedEndpoint = localStorage.getItem('geo-agent-endpoint')
119+
|| llmConfig.default_endpoint || 'https://openrouter.ai/api/v1';
120+
121+
const panel = document.createElement('div');
122+
panel.id = 'api-settings-panel';
123+
panel.innerHTML = `
124+
<div class="settings-title">API Settings</div>
125+
<label class="settings-label" for="settings-endpoint">Endpoint</label>
126+
<input id="settings-endpoint" type="url" value="${this.escapeHtml(savedEndpoint)}"
127+
placeholder="https://openrouter.ai/api/v1" spellcheck="false">
128+
<label class="settings-label" for="settings-api-key">API Key</label>
129+
<input id="settings-api-key" type="password" value="${savedKey ? '••••••••' : ''}"
130+
placeholder="sk-..." spellcheck="false"
131+
onfocus="if(this.value.startsWith('••'))this.value=''">
132+
<div class="settings-actions">
133+
<button id="settings-save" class="settings-save-btn">Save</button>
134+
<button id="settings-cancel" class="settings-cancel-btn">Cancel</button>
135+
</div>
136+
<div class="settings-hint">
137+
Keys are stored in your browser only and never sent to this server.
138+
</div>
139+
`;
140+
141+
// Insert before messages area
142+
this.messagesEl.parentNode.insertBefore(panel, this.messagesEl);
143+
144+
// Wire buttons
145+
panel.querySelector('#settings-save').addEventListener('click', () => {
146+
const endpoint = panel.querySelector('#settings-endpoint').value.trim();
147+
const apiKey = panel.querySelector('#settings-api-key').value.trim();
148+
149+
if (!apiKey || apiKey.startsWith('\u2022')) {
150+
// No change to key if user didn't type a new one
151+
if (!savedKey) {
152+
panel.querySelector('#settings-api-key').style.borderColor = '#dc3545';
153+
return;
154+
}
155+
} else {
156+
localStorage.setItem('geo-agent-api-key', apiKey);
157+
}
158+
if (endpoint) {
159+
localStorage.setItem('geo-agent-endpoint', endpoint);
160+
}
161+
162+
// Rebuild LLM models from new settings
163+
this.applyUserLLMConfig();
164+
panel.remove();
165+
});
166+
167+
panel.querySelector('#settings-cancel').addEventListener('click', () => {
168+
panel.remove();
169+
});
170+
}
171+
172+
/**
173+
* Rebuild llm_models from localStorage and update the agent.
174+
*/
175+
applyUserLLMConfig() {
176+
const llmConfig = this.config.llm || {};
177+
const apiKey = localStorage.getItem('geo-agent-api-key');
178+
const endpoint = localStorage.getItem('geo-agent-endpoint')
179+
|| llmConfig.default_endpoint || 'https://openrouter.ai/api/v1';
180+
181+
if (!apiKey) return;
182+
183+
const models = (llmConfig.models || []).map(m => ({
184+
...m,
185+
endpoint,
186+
api_key: apiKey,
187+
}));
188+
189+
if (models.length === 0) {
190+
models.push({ value: 'auto', label: 'Auto', endpoint, api_key: apiKey });
191+
}
192+
193+
this.config.llm_models = models;
194+
this.config.llm_model = models[0]?.value;
195+
this.agent.config = this.config;
196+
this.agent.selectedModel = this.config.llm_model;
197+
this.populateModelSelector();
198+
}
199+
78200
/* ------------------------------------------------------------------ */
79201
/* Send handler */
80202
/* ------------------------------------------------------------------ */
@@ -83,6 +205,12 @@ export class ChatUI {
83205
const text = this.inputEl.value.trim();
84206
if (!text || this.busy) return;
85207

208+
// In user-provided mode, check for API key before sending
209+
if (this.config._userProvidedMode && !localStorage.getItem('geo-agent-api-key')) {
210+
this.showSettingsPanel();
211+
return;
212+
}
213+
86214
this.busy = true;
87215
this.sendBtn.disabled = true;
88216
this.inputEl.value = '';

app/chat.css

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,4 +418,107 @@ details[open]>.query-summary-btn::before {
418418
.approve-btn:disabled {
419419
opacity: 0.5;
420420
cursor: not-allowed;
421+
}
422+
423+
/* ── Settings panel (user-provided API key mode) ────────── */
424+
425+
#settings-btn {
426+
background: none;
427+
border: none;
428+
font-size: 14px;
429+
cursor: pointer;
430+
color: rgba(0, 0, 0, 0.4);
431+
padding: 2px 6px;
432+
line-height: 1;
433+
opacity: 0.6;
434+
transition: opacity 0.15s;
435+
}
436+
437+
#settings-btn:hover {
438+
opacity: 1;
439+
color: rgba(0, 0, 0, 0.7);
440+
}
441+
442+
#api-settings-panel {
443+
padding: 14px;
444+
background: rgba(255, 255, 255, 0.75);
445+
backdrop-filter: blur(8px);
446+
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
447+
}
448+
449+
.settings-title {
450+
font-size: 13px;
451+
font-weight: 600;
452+
color: #333;
453+
margin-bottom: 10px;
454+
}
455+
456+
.settings-label {
457+
display: block;
458+
font-size: 11px;
459+
font-weight: 500;
460+
color: #555;
461+
margin-bottom: 3px;
462+
text-transform: uppercase;
463+
letter-spacing: 0.3px;
464+
}
465+
466+
#api-settings-panel input {
467+
width: 100%;
468+
padding: 7px 10px;
469+
margin-bottom: 10px;
470+
border: 1px solid rgba(0, 0, 0, 0.15);
471+
border-radius: 5px;
472+
font-size: 13px;
473+
font-family: 'SF Mono', 'Fira Code', monospace;
474+
background: rgba(255, 255, 255, 0.6);
475+
color: #333;
476+
box-sizing: border-box;
477+
}
478+
479+
#api-settings-panel input:focus {
480+
outline: none;
481+
border-color: rgba(0, 122, 255, 0.5);
482+
}
483+
484+
.settings-actions {
485+
display: flex;
486+
gap: 8px;
487+
margin-top: 4px;
488+
}
489+
490+
.settings-save-btn {
491+
padding: 6px 16px;
492+
background: rgba(0, 122, 255, 0.8);
493+
color: #fff;
494+
border: none;
495+
border-radius: 5px;
496+
cursor: pointer;
497+
font-size: 13px;
498+
font-weight: 500;
499+
}
500+
501+
.settings-save-btn:hover {
502+
background: rgba(0, 122, 255, 1);
503+
}
504+
505+
.settings-cancel-btn {
506+
padding: 6px 16px;
507+
background: rgba(0, 0, 0, 0.06);
508+
color: #555;
509+
border: none;
510+
border-radius: 5px;
511+
cursor: pointer;
512+
font-size: 13px;
513+
}
514+
515+
.settings-cancel-btn:hover {
516+
background: rgba(0, 0, 0, 0.12);
517+
}
518+
519+
.settings-hint {
520+
margin-top: 8px;
521+
font-size: 10px;
522+
color: rgba(0, 0, 0, 0.4);
523+
font-style: italic;
421524
}

app/main.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,17 @@ async function main() {
3030
if (runtimeConfig.llm_model) appConfig.llm_model = runtimeConfig.llm_model;
3131
if (runtimeConfig.mcp_server_url) appConfig.mcp_url = runtimeConfig.mcp_server_url;
3232
}
33+
34+
// If no server-provided LLM config, check for user-provided key mode
35+
if (!appConfig.llm_models && appConfig.llm?.user_provided) {
36+
const saved = loadUserLLMConfig(appConfig.llm);
37+
if (saved) {
38+
appConfig.llm_models = saved.llm_models;
39+
appConfig.llm_model = saved.llm_models[0]?.value;
40+
}
41+
// Flag for ChatUI to show settings button
42+
appConfig._userProvidedMode = true;
43+
}
3344
console.log('[main] Config loaded');
3445

3546
/* ── 2. Build dataset catalog from STAC ────────────────────────────── */
@@ -148,6 +159,40 @@ async function main() {
148159

149160
/* ── Helpers ────────────────────────────────────────────────────────────── */
150161

162+
const STORAGE_KEY_API = 'geo-agent-api-key';
163+
const STORAGE_KEY_ENDPOINT = 'geo-agent-endpoint';
164+
165+
/**
166+
* Build llm_models array from localStorage + app llm config.
167+
* Returns null if no saved API key.
168+
*/
169+
function loadUserLLMConfig(llmConfig) {
170+
const apiKey = localStorage.getItem(STORAGE_KEY_API);
171+
if (!apiKey) return null;
172+
173+
const endpoint = localStorage.getItem(STORAGE_KEY_ENDPOINT)
174+
|| llmConfig.default_endpoint
175+
|| 'https://openrouter.ai/api/v1';
176+
177+
const models = (llmConfig.models || []).map(m => ({
178+
...m,
179+
endpoint,
180+
api_key: apiKey,
181+
}));
182+
183+
// If no models configured, create a generic one
184+
if (models.length === 0) {
185+
models.push({
186+
value: 'auto',
187+
label: 'Auto',
188+
endpoint,
189+
api_key: apiKey,
190+
});
191+
}
192+
193+
return { llm_models: models };
194+
}
195+
151196
async function fetchJson(url) {
152197
const res = await fetch(url);
153198
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`);

0 commit comments

Comments
 (0)