add openrouter as provider alternative to openai; fix node selection scroll
@@ -2,7 +2,7 @@ # Study Lab
Study Lab is a local-first, branching AI chat for exploring technical concepts. Every question becomes a node in a concept map. Select any earlier node to continue from that point, or select text inside an answer and ask a focused follow-up. -The app is intentionally frontend-only: there is no build step, package manager, backend, or database. Conversations and settings persist in the browser's `localStorage`, while responses stream directly from OpenAI's Responses API. +The app is intentionally frontend-only: there is no build step, package manager, backend, or database. Conversations and settings persist in the browser's `localStorage`, while responses stream directly from either OpenAI or OpenRouter's Responses API. ## Features@@ -10,7 +10,9 @@ - can branch off any previous answer
- select a chunk of text from an answer and ask about it - vim-like shortcuts - plain js + html + css -- session info (api token & history) stays local on your browser, questions go through openai's api +- provider API keys and history stay local in your browser; questions go directly to the selected provider +- the composer shows the active model and provider at a glance +- each generated answer records and displays the model and provider that produced it - you see token usage and cost estimations other:@@ -30,13 +32,13 @@ - per-map PDF context for books and papers, uploaded once through the Files API
- debounced search across map titles, questions, answers, and PDF names - collapsible, zoomable concept trees with active-path highlighting - vim-style keyboard navigation for nodes, maps, search, and the composer -- large-answer optimizations: throttled stream painting and `content-visibility` +- large-answer optimizations through throttled stream painting  ## Run locally -You need a modern browser, an internet connection, and an OpenAI API key. A ChatGPT subscription/password is not an API key; create a project key in the [OpenAI dashboard](https://platform.openai.com/api-keys). +You need a modern browser, an internet connection, and either an [OpenAI API key](https://platform.openai.com/api-keys) or an [OpenRouter API key](https://openrouter.ai/settings/keys). A ChatGPT subscription/password is not an OpenAI API key. From this directory, start any static file server. Python is commonly preinstalled:@@ -55,13 +57,16 @@
Open the local URL printed by `serve`. 1. Choose **Settings**. -2. Paste an OpenAI API key. -3. Keep `gpt-5-mini`, or enter another model available to your API project. -4. Save and ask a question. +2. Select **OpenAI** or **OpenRouter**. +3. Paste that provider's API key. +4. Choose a suggested model or enter any model ID available to your account. OpenRouter model IDs use the `provider/model` form, such as `openai/gpt-5-mini`. +5. Save and ask a question. -To ask about a book or paper, choose **+ PDF** beside the composer and select one or more PDFs. The files are attached to the current map and included with every new question in that map, including questions asked from older branches. Each file must be under 50 MB, and all attached files in a map must total under 50 MB. +Settings places the active-provider dropdown at the top and shows separate OpenAI and OpenRouter configuration cards at the same time. Each card stores its own API key, model, and endpoint, so switching providers does not require re-entering its configuration. -The endpoint is configurable under **Advanced**, which is useful for an OpenAI-compatible Responses API. Compatibility depends on whether that provider implements the Responses API and browser CORS. +To ask about a book or paper, choose **+ PDF** beside the composer and select one or more PDFs. The files are attached to the current map and included with questions sent through the same provider, including questions asked from older branches. Each file must be under 50 MB, and files attached through one provider in a map must total under 50 MB. + +The endpoint for each provider is configurable under **Advanced**. OpenAI defaults to `https://api.openai.com/v1/responses`; OpenRouter defaults to `https://openrouter.ai/api/v1/responses`. ## How branching works@@ -101,7 +106,9 @@ 
## PDF context -PDFs are uploaded directly from the browser to the Files endpoint derived from the configured Responses API URL. Only their file IDs, names, and sizes are stored in `localStorage`; the PDF bytes are not copied into browser storage. Page images use low visual detail to reduce token use, while extracted PDF text remains available to the model. Use a vision-capable model that supports PDF inputs. +PDFs are uploaded directly from the browser to the selected provider's Files endpoint, derived from its configured Responses API URL. Only their file IDs, provider, names, and sizes are stored in `localStorage`; the PDF bytes are not copied into browser storage. Page images use low visual detail to reduce token use, while extracted PDF text remains available to the model. Use a model that supports PDF inputs. + +File IDs belong to the provider that received the upload. If you switch providers, PDFs uploaded to the other provider remain visible but are dimmed and are not sent. Upload the PDF again while the new provider is selected to use it there. PDF parsing adds extracted text and page images to the prompt, so large books can consume substantial input tokens on every question. The header usage tracker includes tokens reported for these requests. For very large collections or frequent querying, OpenAI recommends retrieval with File Search; this lightweight local app intentionally uses direct PDF input and does not create vector stores.@@ -112,14 +119,17 @@
The browser stores these local keys: - `study-lab:v1` — multi-map conversation workspace, PDF file references, collapsed branches, and active-map selection -- `study-lab:settings:v1` — API key, model, endpoint, and system instructions +- `study-lab:settings:v1` — selected provider, per-provider API keys/models/endpoints, and system instructions - `study-lab:usage:v1` — token usage and estimated request costs recorded by this browser +- `study-lab:openrouter-prices:v1` — a bounded cache of recently used OpenRouter model rates - `study-lab:theme` — light/dark theme preference - `study-lab:tree-zoom` — concept-map zoom preference Data saved by releases before the rename is copied into the new storage namespace automatically on first load. -The usage button in the header counts tokens reported by completed Responses API requests made from this browser. Its estimated USD amount uses a local pricing table and is not an invoice: it cannot see other browsers or applications, historical requests made before tracking was added, credits, taxes, or special pricing. Use the linked OpenAI usage dashboard as the source of truth for billing. +The usage button in the header counts tokens reported by completed Responses API requests made from this browser. OpenAI requests use a local pricing table. For OpenRouter requests, Study Lab looks up the model returned by the response through OpenRouter's single-model API and caches its prompt, completion, cache-read, and request rates for 24 hours. Each usage record retains the exact rate snapshot used for its USD estimate. + +These values are estimates, not invoices. OpenRouter's model endpoint reports its current model price structure, which can differ from the final routed-provider charge or charges for non-token features. The tracker also cannot see other browsers or applications, older untracked requests, credits, taxes, or special pricing. Use the linked dashboard for the selected provider as the source of truth for billing. @@ -127,7 +137,7 @@ This design is convenient for a private tool on your own laptop, but browser storage is not a secure secret vault. Any JavaScript executing on the same origin can read it. Therefore:
- Run the app only from files you trust. - Do not publish or host this version as a public multi-user site. -- Prefer a restricted OpenAI project key with appropriate spend limits. +- Prefer restricted provider keys with appropriate spend limits. - Use **Settings → Forget key** before sharing the browser profile or local origin. - Using a different port creates a different browser origin and therefore a separate session.@@ -135,7 +145,7 @@ For a public deployment, put API calls behind an authenticated backend and keep the key in a server environment variable.
## Performance notes -Only the active branch is added to the main document; sibling branches remain compact data in `localStorage`. Streaming UI updates are batched roughly every 70 ms instead of rerendering for every token. Off-screen messages use CSS `content-visibility`, and the concept map is built with a single document-fragment update. +Only the active branch is added to the main document; sibling branches remain compact data in `localStorage`. Streaming UI updates are batched roughly every 70 ms instead of rerendering for every token, and the concept map is built with a single document-fragment update. Very large sessions are ultimately limited by the browser's `localStorage` quota (usually a few megabytes). Start a new map when the saved tree becomes unusually large.@@ -145,4 +155,4 @@ Use a current version of Chrome, Edge, Firefox, or Safari. LaTeX loads from the jsDelivr CDN; if it cannot load, equations remain visible as plain source text. The OpenAI API must allow requests from your local browser origin. If a corporate browser policy or custom endpoint blocks CORS, a small local proxy backend would be required.
## API reference -The app uses `POST /v1/responses` with `stream: true` and reads Server-Sent Events. See the official [OpenAI streaming Responses documentation](https://developers.openai.com/api/docs/guides/streaming-responses). +The app uses the selected provider's Responses endpoint with `stream: true` and reads Server-Sent Events. See the official [OpenAI streaming Responses documentation](https://developers.openai.com/api/docs/guides/streaming-responses) and [OpenRouter Responses documentation](https://openrouter.ai/docs/api_reference/responses/overview).
@@ -5,9 +5,21 @@ const APP_PREFIX = 'study-lab';
const STORAGE_KEY = `${APP_PREFIX}:v1`; const SETTINGS_KEY = `${APP_PREFIX}:settings:v1`; const USAGE_KEY = `${APP_PREFIX}:usage:v1`; + const OPENROUTER_PRICES_KEY = `${APP_PREFIX}:openrouter-prices:v1`; const TREE_ZOOM_KEY = `${APP_PREFIX}:tree-zoom`; const THEME_KEY = `${APP_PREFIX}:theme`; const PDF_REQUEST_LIMIT = 50 * 1024 * 1024; + const PRICE_CACHE_TTL = 24 * 60 * 60 * 1000; + const PROVIDERS = { + openai: { + label: 'OpenAI', apiUrl: 'https://api.openai.com/v1/responses', model: 'gpt-5-mini', + models: ['gpt-5-mini', 'gpt-5', 'gpt-4.1-mini', 'gpt-4.1'] + }, + openrouter: { + label: 'OpenRouter', apiUrl: 'https://openrouter.ai/api/v1/responses', model: 'openai/gpt-5-mini', + models: ['openai/gpt-5-mini', 'anthropic/claude-sonnet-4', 'google/gemini-2.5-pro', 'deepseek/deepseek-r1'] + } + }; // USD per million tokens. Last checked against OpenAI Docs on 2026-08-17. const MODEL_PRICES = [ { match: /^gpt-5\.6-terra(?:-|$)/, input: 2, cached: .2, output: 12 },@@ -24,9 +36,14 @@ { match: /^gpt-4\.1(?:-20\d{2}|$)/, input: 2, cached: .5, output: 8 },
{ match: /^gpt-4o-mini(?:-|$)/, input: .15, cached: .075, output: .6 } ]; const DEFAULT_SETTINGS = { + provider: 'openai', apiKey: '', model: 'gpt-5-mini', apiUrl: 'https://api.openai.com/v1/responses', + profiles: { + openai: { apiKey: '', model: 'gpt-5-mini', apiUrl: 'https://api.openai.com/v1/responses' }, + openrouter: { apiKey: '', model: 'openai/gpt-5-mini', apiUrl: 'https://openrouter.ai/api/v1/responses' } + }, systemPrompt: 'You are a patient technical tutor. Use clear Markdown and LaTeX where useful. Use mathematical language, assume graduate level knowledge. Build on the conversation context and explain assumptions.' };@@ -37,23 +54,29 @@ prompt: $('#prompt'), send: $('#send'), tree: $('#tree'), mapEmpty: $('#map-empty'),
nodeCount: $('#node-count'), branchCount: $('#branch-count'), branchContext: $('#branch-context'), branchLabel: $('#branch-label'), selectionMenu: $('#selection-menu'), selectionPreview: $('#selection-preview'), selectionAction: $('#selection-action'), selectionCustom: $('#selection-custom'), settings: $('#settings-dialog'), - apiKey: $('#api-key'), model: $('#model'), apiUrl: $('#api-url'), systemPrompt: $('#system-prompt'), + provider: $('#provider'), openaiApiKey: $('#openai-api-key'), openrouterApiKey: $('#openrouter-api-key'), + openaiModel: $('#openai-model'), openrouterModel: $('#openrouter-model'), + openaiModelOptions: $('#openai-model-options'), openrouterModelOptions: $('#openrouter-model-options'), + openaiApiUrl: $('#openai-api-url'), openrouterApiUrl: $('#openrouter-api-url'), systemPrompt: $('#system-prompt'), keyStatus: $('#key-status'), toast: $('#toast'), usageDialog: $('#usage-dialog'), monthTokens: $('#month-tokens'), usageMonth: $('#usage-month'), usageTotal: $('#usage-total'), usageCost: $('#usage-cost'), usageRequests: $('#usage-requests'), usageInput: $('#usage-input'), usageCached: $('#usage-cached'), usageOutput: $('#usage-output'), usageModels: $('#usage-models'), - usageRecent: $('#usage-recent'), usageNote: $('#usage-note'), historyList: $('#history-list'), + usageRecent: $('#usage-recent'), usageNote: $('#usage-note'), usageDashboard: $('#usage-dashboard'), historyList: $('#history-list'), historyEmpty: $('#history-empty'), historyToggle: $('#toggle-history'), attachments: $('#attachments'), uploadPDF: $('#upload-pdf'), pdfInput: $('#pdf-input'), + activeConnection: $('#active-connection'), activeModel: $('#active-model'), activeProvider: $('#active-provider'), conversationSearch: $('#conversation-search'), searchResults: $('#search-results'), treeZoom: $('#tree-zoom'), shortcutsDialog: $('#shortcuts-dialog') }; migrateLegacyStorage(); - let settings = loadJSON(SETTINGS_KEY, DEFAULT_SETTINGS); + let settings = normalizeSettings(loadJSON(SETTINGS_KEY, DEFAULT_SETTINGS)); + let settingsDraftProfiles = null; let workspace = loadWorkspace(); let state = currentMap(); let usageRecords = loadUsage(); + let openRouterPrices = loadOpenRouterPrices(); let activeRequest = null; let uploadingPDFs = false; let uploadTargetMapId = null;@@ -67,10 +90,47 @@ let pendingGTimer = 0;
let treeZoom = loadTreeZoom(); function loadJSON(key, fallback) { - try { return { ...fallback, ...JSON.parse(localStorage.getItem(key) || '{}') }; } + try { return JSON.parse(localStorage.getItem(key) || 'null') || fallback; } catch { return { ...fallback }; } } + function normalizeSettings(value = {}) { + const provider = PROVIDERS[value.provider] ? value.provider : 'openai'; + const profiles = {}; + for (const [id, defaults] of Object.entries(PROVIDERS)) { + const saved = value.profiles?.[id] || {}; + profiles[id] = { + apiKey: String(saved.apiKey || ''), + model: String(saved.model || defaults.model), + apiUrl: String(saved.apiUrl || defaults.apiUrl) + }; + } + // Settings saved before providers were introduced belong to OpenAI. + if (!value.profiles) { + profiles.openai = { + apiKey: String(value.apiKey || ''), + model: String(value.model || PROVIDERS.openai.model), + apiUrl: String(value.apiUrl || PROVIDERS.openai.apiUrl) + }; + } + const active = profiles[provider]; + return { + provider, profiles, apiKey: active.apiKey, model: active.model, apiUrl: active.apiUrl, + systemPrompt: String(value.systemPrompt ?? DEFAULT_SETTINGS.systemPrompt) + }; + } + + function providerLabel(provider = settings.provider) { + return PROVIDERS[provider]?.label || 'API provider'; + } + + function requestHeaders(json = false, connection = settings) { + const headers = { 'Authorization': `Bearer ${connection.apiKey}` }; + if (json) headers['Content-Type'] = 'application/json'; + if (connection.provider === 'openrouter') headers['X-OpenRouter-Title'] = 'Study Lab'; + return headers; + } + function migrateLegacyStorage() { const legacyPrefix = ['study', 'pal'].join('-'); const suffixes = [':v1', ':settings:v1', ':usage:v1', ':theme', ':tree-zoom'];@@ -92,6 +152,21 @@ return Array.isArray(records) ? records.filter(record => record && Number.isFinite(record.timestamp)) : [];
} catch { return []; } } + function loadOpenRouterPrices() { + try { + const prices = JSON.parse(localStorage.getItem(OPENROUTER_PRICES_KEY) || '{}'); + return prices && typeof prices === 'object' && !Array.isArray(prices) ? prices : {}; + } catch { return {}; } + } + + function persistOpenRouterPrices() { + const recent = Object.entries(openRouterPrices) + .sort((a, b) => (Number(b[1]?.checkedAt) || 0) - (Number(a[1]?.checkedAt) || 0)) + .slice(0, 40); + openRouterPrices = Object.fromEntries(recent); + try { localStorage.setItem(OPENROUTER_PRICES_KEY, JSON.stringify(openRouterPrices)); } catch {} + } + function loadTreeZoom() { try { return Math.max(.8, Math.min(1.4, Number(localStorage.getItem(TREE_ZOOM_KEY)) || 1)); } catch { return 1; }@@ -115,7 +190,8 @@ const attachments = Array.isArray(value.attachments) ? value.attachments
.filter(file => file && file.id && file.name) .map(file => ({ id: String(file.id), name: String(file.name), size: Number(file.size) || 0, - createdAt: Number(file.createdAt) || Date.now(), detail: file.detail === 'high' ? 'high' : 'low' + createdAt: Number(file.createdAt) || Date.now(), detail: file.detail === 'high' ? 'high' : 'low', + provider: PROVIDERS[file.provider] ? file.provider : 'openai' })) : []; const createdAt = Number(value.createdAt) || Number(normalized.nodes[0]?.createdAt) || Date.now(); return {@@ -268,7 +344,13 @@ if (node.status === 'error') return `<p class="error-text">${escapeHTML(node.error || 'The request failed.')}</p>`;
return markdown(node.answer || ''); } + function answerAttribution(node) { + if (!node.model || !node.provider || !node.answer) return ''; + return `<div class="answer-attribution" title="Generated by ${escapeHTML(node.model)} through ${escapeHTML(providerLabel(node.provider))}"><span>${escapeHTML(node.model)}</span><small>${escapeHTML(providerLabel(node.provider))}</small></div>`; + } + function renderConversation({ scroll = false } = {}) { + const previousScroll = scroll ? null : { left: window.scrollX, top: window.scrollY }; const path = pathTo(state.activeId); els.welcome.hidden = path.length > 0; els.conversation.hidden = path.length === 0;@@ -279,7 +361,7 @@ article.className = 'message';
article.dataset.nodeId = node.id; article.innerHTML = ` <div class="question-row"><div class="question-bubble">${escapeHTML(node.question)}</div></div> - <div class="answer"><span class="answer-mark" aria-hidden="true">S</span><div class="answer-body">${answerHTML(node)}</div></div> + <div class="answer"><span class="answer-mark" aria-hidden="true">S</span><div class="answer-content"><div class="answer-body">${answerHTML(node)}</div>${answerAttribution(node)}</div></div> <div class="answer-actions"> <button class="mini-action copy-answer" type="button">Copy answer</button> <button class="mini-action branch-here" type="button">Branch here</button>@@ -288,14 +370,24 @@ </div>`;
fragment.append(article); } els.conversation.replaceChildren(fragment); + if (previousScroll && (window.scrollX !== previousScroll.left || window.scrollY !== previousScroll.top)) { + window.scrollTo({ ...previousScroll, behavior: 'auto' }); + } renderBranchContext(); if (scroll && state.activeId) requestAnimationFrame(() => { if (scroll === 'bottom') { window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' }); return; } - const answer = $(`.message[data-node-id="${CSS.escape(state.activeId)}"] .answer`, els.conversation); - answer?.scrollIntoView({ block: 'start', behavior: 'smooth' }); + const targetId = state.activeId; + requestAnimationFrame(() => { + if (state.activeId !== targetId) return; + const message = $(`.message[data-node-id="${CSS.escape(targetId)}"]`, els.conversation); + if (!message) return; + const headerHeight = $('.topbar')?.getBoundingClientRect().height || 68; + const top = window.scrollY + message.getBoundingClientRect().top - headerHeight - 14; + window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' }); + }); }); }@@ -511,8 +603,10 @@ const fragment = document.createDocumentFragment();
for (const file of state.attachments) { const chip = document.createElement('span'); chip.className = 'attachment-chip'; - chip.title = `${file.name}${file.size ? ` · ${formatFileSize(file.size)}` : ''} · Included with every question in this map`; - chip.innerHTML = `<span class="pdf-badge" aria-hidden="true">PDF</span><span class="attachment-name">${escapeHTML(file.name)}</span><button class="remove-attachment" type="button" data-file-id="${escapeHTML(file.id)}" aria-label="Remove ${escapeHTML(file.name)} from this map">×</button>`; + const available = file.provider === settings.provider; + chip.classList.toggle('inactive-provider', !available); + chip.title = `${file.name}${file.size ? ` · ${formatFileSize(file.size)}` : ''} · Uploaded to ${providerLabel(file.provider)}${available ? ' · Included with every question' : ' · Not sent with the selected provider'}`; + chip.innerHTML = `<span class="pdf-badge" aria-hidden="true">PDF</span><span class="attachment-name">${escapeHTML(file.name)}</span>${available ? '' : `<span class="attachment-provider">${escapeHTML(providerLabel(file.provider))}</span>`}<button class="remove-attachment" type="button" data-file-id="${escapeHTML(file.id)}" aria-label="Remove ${escapeHTML(file.name)} from this map">×</button>`; fragment.append(chip); } if (uploadingPDFs && state.id === uploadTargetMapId) {@@ -531,23 +625,40 @@
function updateStreamedNode(node) { if (state.activeId !== node.id || !state.nodes.includes(node)) return; const body = $(`.message[data-node-id="${CSS.escape(node.id)}"] .answer-body`, els.conversation); - if (body) body.innerHTML = answerHTML(node); + if (body) { + const previousScroll = { left: window.scrollX, top: window.scrollY }; + body.innerHTML = answerHTML(node); + if (window.scrollX !== previousScroll.left || window.scrollY !== previousScroll.top) { + window.scrollTo({ ...previousScroll, behavior: 'auto' }); + } + } } - function inputFor(map, parentId, question) { + function inputFor(map, parentId, question, provider = settings.provider) { const messages = []; for (const node of pathTo(parentId, map)) { - messages.push({ role: 'user', content: node.question }); - if (node.answer) messages.push({ role: 'assistant', content: node.answer }); + if (provider === 'openrouter') { + messages.push({ type: 'message', role: 'user', content: [{ type: 'input_text', text: node.question }] }); + if (node.answer) messages.push({ + type: 'message', role: 'assistant', id: `msg_${String(node.id).replace(/[^a-zA-Z0-9_-]/g, '')}`, + status: 'completed', content: [{ type: 'output_text', text: node.answer, annotations: [] }] + }); + } else { + messages.push({ role: 'user', content: node.question }); + if (node.answer) messages.push({ role: 'assistant', content: node.answer }); + } } - const content = map.attachments.map(file => ({ type: 'input_file', file_id: file.id, detail: file.detail || 'low' })); + const content = map.attachments + .filter(file => file.provider === provider) + .map(file => ({ type: 'input_file', file_id: file.id, detail: file.detail || 'low' })); content.push({ type: 'input_text', text: question }); - messages.push({ role: 'user', content: map.attachments.length ? content : question }); + const structured = provider === 'openrouter' || content.length > 1; + messages.push(structured ? { type: 'message', role: 'user', content } : { role: 'user', content: question }); return messages; } - function filesEndpoint() { - const url = new URL(settings.apiUrl); + function filesEndpoint(apiUrl = settings.apiUrl) { + const url = new URL(apiUrl); if (!/\/responses\/?$/.test(url.pathname)) throw new Error('The configured endpoint is not a Responses API URL, so its Files endpoint cannot be derived.'); url.pathname = url.pathname.replace(/\/responses\/?$/, '/files'); url.search = '';@@ -559,28 +670,29 @@ async function uploadPDFs(fileList) {
const files = [...fileList]; if (!files.length) return; if (activeRequest || uploadingPDFs) { showToast('Finish the current operation first'); return; } - if (!settings.apiKey) { openSettings(); showToast('Add an OpenAI API key before uploading'); return; } + if (!settings.apiKey) { openSettings(); showToast(`Add a ${providerLabel()} API key before uploading`); return; } const valid = files.filter(file => file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')); if (valid.length !== files.length) { showToast('Only PDF files can be added'); return; } if (valid.some(file => file.size >= PDF_REQUEST_LIMIT)) { showToast('Each PDF must be smaller than 50 MB'); return; } - const existingBytes = state.attachments.reduce((total, file) => total + file.size, 0); + const existingBytes = state.attachments.filter(file => file.provider === settings.provider).reduce((total, file) => total + file.size, 0); const addedBytes = valid.reduce((total, file) => total + file.size, 0); if (existingBytes + addedBytes >= PDF_REQUEST_LIMIT) { showToast('PDFs in one map must total less than 50 MB'); return; } const targetMap = state; + const connection = { ...settings }; uploadingPDFs = true; uploadTargetMapId = targetMap.id; renderAttachments(); let uploaded = 0; try { - const endpoint = filesEndpoint(); + const endpoint = filesEndpoint(connection.apiUrl); for (const file of valid) { const form = new FormData(); - form.append('purpose', 'user_data'); + if (connection.provider === 'openai') form.append('purpose', 'user_data'); form.append('file', file, file.name); const response = await fetch(endpoint, { - method: 'POST', headers: { 'Authorization': `Bearer ${settings.apiKey}` }, body: form + method: 'POST', headers: requestHeaders(false, connection), body: form }); if (!response.ok) { let message = `PDF upload failed (${response.status})`;@@ -591,7 +703,8 @@ const data = await response.json();
if (!data.id) throw new Error('The Files API did not return a file ID.'); targetMap.attachments.push({ id: String(data.id), name: String(data.filename || file.name), - size: Number(data.bytes) || file.size, createdAt: Date.now(), detail: 'low' + size: Number(data.bytes ?? data.size_bytes) || file.size, createdAt: Date.now(), detail: 'low', + provider: connection.provider }); uploaded += 1; touchMap(targetMap);@@ -625,10 +738,14 @@ question = question.trim();
if (!question) return; if (activeRequest) { showToast('Stop or finish the current response first'); return; } if (uploadingPDFs) { showToast('Wait for the PDF upload to finish'); return; } - if (!settings.apiKey) { openSettings(); showToast('Add an OpenAI API key to begin'); return; } + if (!settings.apiKey) { openSettings(); showToast(`Add a ${providerLabel()} API key to begin`); return; } const requestMap = state; - const node = { id: uid(), parentId: parentId || null, question, answer: '', status: 'loading', createdAt: Date.now() }; + const connection = { ...settings }; + const node = { + id: uid(), parentId: parentId || null, question, answer: '', status: 'loading', createdAt: Date.now(), + provider: connection.provider, model: connection.model + }; requestMap.nodes.push(node); requestMap.activeId = node.id; revealNode(requestMap, node.id);@@ -642,10 +759,10 @@ activeRequest = controller;
let completedUsage = null; try { - const response = await fetch(settings.apiUrl, { + const response = await fetch(connection.apiUrl, { method: 'POST', - headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${settings.apiKey}` }, - body: JSON.stringify({ model: settings.model, instructions: settings.systemPrompt, input: inputFor(requestMap, parentId, question), stream: true }), + headers: requestHeaders(true, connection), + body: JSON.stringify({ model: connection.model, instructions: connection.systemPrompt, input: inputFor(requestMap, parentId, question, connection.provider), stream: true }), signal: controller.signal }); if (!response.ok) {@@ -655,14 +772,15 @@ throw new Error(message);
} if (!response.body) throw new Error('This browser did not provide a streaming response body.'); await consumeSSE(response.body, event => { - if (event.type === 'response.output_text.delta') node.answer += event.delta || ''; + if (event.type === 'response.output_text.delta' || event.type === 'response.content_part.delta') node.answer += event.delta || ''; if (event.type === 'response.completed' || event.type === 'response.done') { + node.model = String(event.response?.model || node.model); completedUsage = event.response?.usage || event.usage || completedUsage; } if (event.type === 'response.failed') throw new Error(event.response?.error?.message || 'The model response failed.'); }, () => updateStreamedNode(node)); node.status = 'complete'; - if (completedUsage) recordUsage(node.id, settings.model, settings.apiUrl, completedUsage); + if (completedUsage) await recordUsage(node.id, node.model || connection.model, connection.apiUrl, completedUsage, connection.provider); } catch (error) { node.status = 'error'; node.error = error.name === 'AbortError' ? 'Response stopped.' : error.message;@@ -714,12 +832,46 @@ els.uploadPDF.disabled = busy || uploadingPDFs;
} function openSettings() { - els.apiKey.value = settings.apiKey || ''; - els.model.value = settings.model; - els.apiUrl.value = settings.apiUrl; + settingsDraftProfiles = structuredClone(settings.profiles); + els.provider.value = settings.provider; + els.openaiApiKey.value = settingsDraftProfiles.openai.apiKey || ''; + els.openrouterApiKey.value = settingsDraftProfiles.openrouter.apiKey || ''; + els.openaiModel.value = settingsDraftProfiles.openai.model || PROVIDERS.openai.model; + els.openrouterModel.value = settingsDraftProfiles.openrouter.model || PROVIDERS.openrouter.model; + els.openaiApiUrl.value = settingsDraftProfiles.openai.apiUrl || PROVIDERS.openai.apiUrl; + els.openrouterApiUrl.value = settingsDraftProfiles.openrouter.apiUrl || PROVIDERS.openrouter.apiUrl; + fillModelOptions(els.openaiModelOptions, PROVIDERS.openai.models); + fillModelOptions(els.openrouterModelOptions, PROVIDERS.openrouter.models); + updateProviderCards(); els.systemPrompt.value = settings.systemPrompt; els.settings.showModal(); - setTimeout(() => els.apiKey.focus(), 50); + setTimeout(() => (settings.provider === 'openrouter' ? els.openrouterApiKey : els.openaiApiKey).focus(), 50); + } + + function fillModelOptions(list, models) { + list.replaceChildren(...models.map(model => Object.assign(document.createElement('option'), { value: model }))); + } + + function captureProviderForms() { + if (!settingsDraftProfiles) return; + settingsDraftProfiles = { + openai: { + apiKey: els.openaiApiKey.value.trim(), + model: els.openaiModel.value.trim() || PROVIDERS.openai.model, + apiUrl: els.openaiApiUrl.value.trim() || PROVIDERS.openai.apiUrl + }, + openrouter: { + apiKey: els.openrouterApiKey.value.trim(), + model: els.openrouterModel.value.trim() || PROVIDERS.openrouter.model, + apiUrl: els.openrouterApiUrl.value.trim() || PROVIDERS.openrouter.apiUrl + } + }; + } + + function updateProviderCards() { + document.querySelectorAll('[data-provider-config]').forEach(card => { + card.classList.toggle('active', card.dataset.providerConfig === els.provider.value); + }); } function showToast(message) {@@ -729,7 +881,16 @@ clearTimeout(toastTimer);
toastTimer = setTimeout(() => els.toast.classList.remove('show'), 2200); } - function updateKeyStatus() { els.keyStatus.classList.toggle('ready', Boolean(settings.apiKey)); } + function updateKeyStatus() { + els.keyStatus.classList.toggle('ready', Boolean(settings.apiKey)); + els.activeModel.textContent = settings.model; + els.activeProvider.textContent = providerLabel(); + els.activeConnection.title = `${providerLabel()} · ${settings.model} — open settings`; + els.activeConnection.setAttribute('aria-label', `Current model: ${settings.model} through ${providerLabel()}. Open API settings`); + const button = $('#open-settings'); + button.title = `${providerLabel()} · ${settings.model}`; + button.setAttribute('aria-label', `API settings — ${providerLabel()}, ${settings.model}`); + } function updateThemeButton() { const dark = document.documentElement.dataset.theme === 'dark';@@ -756,20 +917,56 @@ try { return new URL(apiUrl).hostname === 'api.openai.com'; }
catch { return false; } } - function recordUsage(nodeId, model, apiUrl, rawUsage) { + async function openRouterPriceForModel(model) { + const key = String(model || '').trim(); + if (!key.includes('/')) return null; + const cached = openRouterPrices[key]; + const validCached = cached && Number.isFinite(Number(cached.input)) && Number.isFinite(Number(cached.output)); + if (validCached && Date.now() - Number(cached.checkedAt) < PRICE_CACHE_TTL) return cached; + try { + const path = key.split('/').map(encodeURIComponent).join('/'); + const response = await fetch(`https://openrouter.ai/api/v1/model/${path}`, { + headers: { 'Accept': 'application/json' } + }); + if (!response.ok) return validCached ? cached : null; + const pricing = (await response.json()).data?.pricing; + const prompt = pricing?.prompt == null ? NaN : Number(pricing.prompt); + const completion = pricing?.completion == null ? NaN : Number(pricing.completion); + const cacheRead = pricing?.input_cache_read == null ? NaN : Number(pricing.input_cache_read); + if (!Number.isFinite(prompt) || !Number.isFinite(completion)) return validCached ? cached : null; + const price = { + input: prompt * 1_000_000, + cached: (Number.isFinite(cacheRead) ? cacheRead : prompt) * 1_000_000, + output: completion * 1_000_000, + request: Number(pricing?.request) || 0, + checkedAt: Date.now(), + source: 'openrouter' + }; + openRouterPrices[key] = price; + persistOpenRouterPrices(); + return price; + } catch { return validCached ? cached : null; } + } + + async function recordUsage(nodeId, model, apiUrl, rawUsage, provider) { if (usageRecords.some(record => record.nodeId === nodeId)) return; const inputTokens = Number(rawUsage.input_tokens) || 0; const outputTokens = Number(rawUsage.output_tokens) || 0; const cachedTokens = Math.min(inputTokens, Number(rawUsage.input_tokens_details?.cached_tokens) || 0); const totalTokens = Number(rawUsage.total_tokens) || inputTokens + outputTokens; - const price = isOpenAIEndpoint(apiUrl) ? priceForModel(model) : null; + const price = provider === 'openrouter' + ? await openRouterPriceForModel(model) + : (isOpenAIEndpoint(apiUrl) ? priceForModel(model) : null); const costUSD = price - ? (((inputTokens - cachedTokens) * price.input) + (cachedTokens * price.cached) + (outputTokens * price.output)) / 1_000_000 + ? ((((inputTokens - cachedTokens) * price.input) + (cachedTokens * price.cached) + (outputTokens * price.output)) / 1_000_000) + (Number(price.request) || 0) : null; usageRecords.push({ id: uid(), nodeId, timestamp: Date.now(), model: String(model || 'unknown'), inputTokens, cachedTokens, outputTokens, totalTokens, costUSD, - rates: price ? { input: price.input, cached: price.cached, output: price.output } : null + rates: price ? { + input: price.input, cached: price.cached, output: price.output, + request: Number(price.request) || 0, source: price.source || 'openai', checkedAt: price.checkedAt || Date.now() + } : null }); // Keep bounded history while retaining roughly a year of normal personal use. usageRecords = usageRecords.slice(-2000);@@ -852,12 +1049,15 @@ <span class="row-cost">${formatCost(record.costUSD)}</span>
</div>`).join('') : '<p class="usage-empty">Completed requests will appear here.</p>'; els.usageNote.textContent = unpriced - ? `${unpriced} request${unpriced === 1 ? '' : 's'} could not be estimated because the model or endpoint has no local price entry.` - : 'Estimate uses standard OpenAI text-token prices recorded when each request completed. Pricing table checked August 17, 2026.'; + ? `${unpriced} request${unpriced === 1 ? '' : 's'} could not be estimated because its model price could not be resolved.` + : 'Estimates use the rate recorded when each request completed: the local OpenAI table or OpenRouter\'s model-pricing API.'; } function openUsage() { renderUsage(); + const openRouter = settings.provider === 'openrouter'; + els.usageDashboard.href = openRouter ? 'https://openrouter.ai/activity' : 'https://platform.openai.com/usage'; + els.usageDashboard.textContent = `${openRouter ? 'OpenRouter' : 'OpenAI'} dashboard ↗`; els.usageDialog.showModal(); }@@ -1150,6 +1350,7 @@ });
$('#close-selection').addEventListener('click', hideSelectionMenu); window.addEventListener('scroll', hideSelectionMenu, { passive: true }); $('#open-settings').addEventListener('click', openSettings); + els.activeConnection.addEventListener('click', openSettings); $('#open-usage').addEventListener('click', openUsage); $('#theme-toggle').addEventListener('click', toggleTheme); $('#toggle-history').addEventListener('click', toggleHistory);@@ -1183,14 +1384,34 @@ renderUsage();
showToast('Local usage history reset'); } }); - $('#toggle-key').addEventListener('click', event => { const show = els.apiKey.type === 'password'; els.apiKey.type = show ? 'text' : 'password'; event.target.textContent = show ? 'Hide' : 'Show'; }); + document.querySelectorAll('.toggle-key').forEach(button => button.addEventListener('click', event => { + const input = document.getElementById(event.currentTarget.dataset.keyInput); + const show = input.type === 'password'; + input.type = show ? 'text' : 'password'; + event.currentTarget.textContent = show ? 'Hide' : 'Show'; + event.currentTarget.setAttribute('aria-label', `${show ? 'Hide' : 'Show'} ${input.id === 'openrouter-api-key' ? 'OpenRouter' : 'OpenAI'} API key`); + })); + els.provider.addEventListener('change', updateProviderCards); $('#settings-form').addEventListener('submit', event => { event.preventDefault(); - settings = { apiKey: els.apiKey.value.trim(), model: els.model.value.trim() || DEFAULT_SETTINGS.model, apiUrl: els.apiUrl.value.trim() || DEFAULT_SETTINGS.apiUrl, systemPrompt: els.systemPrompt.value.trim() }; + captureProviderForms(); + settings = normalizeSettings({ provider: els.provider.value, profiles: settingsDraftProfiles, systemPrompt: els.systemPrompt.value.trim() }); + localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); + settingsDraftProfiles = null; + updateKeyStatus(); renderAttachments(); els.settings.close(); showToast(`${providerLabel()} settings saved`); + }); + $('#forget-key').addEventListener('click', () => { + const provider = els.provider.value; + const input = provider === 'openrouter' ? els.openrouterApiKey : els.openaiApiKey; + input.value = ''; + settingsDraftProfiles[provider].apiKey = ''; + const profiles = structuredClone(settings.profiles); + profiles[provider].apiKey = ''; + settings = normalizeSettings({ provider: settings.provider, profiles, systemPrompt: settings.systemPrompt }); localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); - updateKeyStatus(); els.settings.close(); showToast('Settings saved'); + updateKeyStatus(); + showToast(`${providerLabel(provider)} API key removed`); }); - $('#forget-key').addEventListener('click', () => { settings.apiKey = ''; els.apiKey.value = ''; localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); updateKeyStatus(); showToast('API key removed'); }); $('#clear-history').addEventListener('click', () => clearChatHistory()); $('#clear-branch').addEventListener('click', () => { state.activeId = state.nodes.at(-1)?.id || null; revealNode(state, state.activeId); persistSoon(); renderAll({ scroll: true }); }); $('#collapse-map').addEventListener('click', () => { document.body.classList.remove('map-open'); document.body.classList.add('map-collapsed'); $('#expand-map').hidden = false; });@@ -1205,5 +1426,5 @@ updateHistoryToggle();
updateTreeZoom(); state.activeId = state.nodes.at(-1)?.id || null; revealNode(state, state.activeId); - renderAll({ scroll: state.activeId ? 'bottom' : false }); + renderAll(); })();
@@ -105,6 +105,10 @@ <div class="composer-tools">
<button id="upload-pdf" class="upload-pdf" type="button" title="Add PDFs to this map" aria-label="Add PDFs to this map"> <span aria-hidden="true">+</span> PDF </button> + <button id="active-connection" class="active-connection" type="button" aria-label="Open API settings"> + <span id="active-model">gpt-5-mini</span> + <small id="active-provider">OpenAI</small> + </button> <span class="hint"><kbd>Enter</kbd> send · <kbd>Shift</kbd> + <kbd>Enter</kbd> new line</span> </div> <button id="send" class="send" type="submit" aria-label="Send message">↑</button>@@ -172,33 +176,78 @@ <h2>API settings</h2>
</div> <button id="close-settings" class="icon-button" type="button" aria-label="Close settings">×</button> </div> - <p class="settings-copy">Use an OpenAI API key (not a ChatGPT password). It is stored only in this browser and sent directly to the configured API.</p> - <label> - API key - <div class="secret-field"> - <input id="api-key" type="password" placeholder="sk-…" autocomplete="off" spellcheck="false"> - <button id="toggle-key" type="button" aria-label="Show API key">Show</button> - </div> + <p class="settings-copy">Choose the active provider, then configure both connections below. Each provider keeps its own key, model, and endpoint.</p> + <label class="active-provider-field"> + Active provider + <select id="provider"> + <option value="openai">OpenAI</option> + <option value="openrouter">OpenRouter</option> + </select> </label> - <label> - Model - <input id="model" type="text" value="gpt-5-mini" placeholder="gpt-5-mini" spellcheck="false"> - </label> - <details> - <summary>Advanced</summary> - <label> - Responses API endpoint - <input id="api-url" type="url" value="https://api.openai.com/v1/responses" spellcheck="false"> - </label> + <div class="provider-configs"> + <section class="provider-config" data-provider-config="openai"> + <div class="provider-config-heading"> + <div><span class="provider-logo">OAI</span><h3>OpenAI</h3></div> + <span class="provider-active">Selected</span> + </div> + <label> + API key + <div class="secret-field"> + <input id="openai-api-key" type="password" placeholder="sk-…" autocomplete="off" spellcheck="false"> + <button class="toggle-key" type="button" data-key-input="openai-api-key" aria-label="Show OpenAI API key">Show</button> + </div> + </label> + <label> + Model + <input id="openai-model" type="text" value="gpt-5-mini" placeholder="gpt-5-mini" list="openai-model-options" spellcheck="false"> + <datalist id="openai-model-options"></datalist> + </label> + <details class="provider-advanced"> + <summary>Endpoint</summary> + <label> + Responses API endpoint + <input id="openai-api-url" type="url" value="https://api.openai.com/v1/responses" spellcheck="false"> + </label> + </details> + </section> + <section class="provider-config" data-provider-config="openrouter"> + <div class="provider-config-heading"> + <div><span class="provider-logo">OR</span><h3>OpenRouter</h3></div> + <span class="provider-active">Selected</span> + </div> + <label> + API key + <div class="secret-field"> + <input id="openrouter-api-key" type="password" placeholder="sk-or-v1-…" autocomplete="off" spellcheck="false"> + <button class="toggle-key" type="button" data-key-input="openrouter-api-key" aria-label="Show OpenRouter API key">Show</button> + </div> + </label> + <label> + Model + <input id="openrouter-model" type="text" value="openai/gpt-5-mini" placeholder="provider/model" list="openrouter-model-options" spellcheck="false"> + <datalist id="openrouter-model-options"></datalist> + <small class="field-help">OpenRouter model IDs use the provider/model format.</small> + </label> + <details class="provider-advanced"> + <summary>Endpoint</summary> + <label> + Responses API endpoint + <input id="openrouter-api-url" type="url" value="https://openrouter.ai/api/v1/responses" spellcheck="false"> + </label> + </details> + </section> + </div> + <details class="shared-settings"> + <summary>Shared instructions</summary> <label> System instructions <textarea id="system-prompt" rows="3">You are a patient technical tutor. Use clear Markdown and LaTeX where useful. Use mathematical language, assume graduate level knowledge. Build on the conversation context and explain assumptions.</textarea> </label> </details> - <div class="security-callout"><strong>Local-use note</strong> — any script running on this origin can access local storage. Use a restricted project key and never deploy this frontend publicly with a saved key.</div> + <div class="security-callout"><strong>Local-use note</strong> — any script running on this origin can access local storage. Use a restricted API key and never deploy this frontend publicly with a saved key.</div> <div class="dialog-actions"> <button id="clear-history" class="button button-ghost danger" type="button">Clear chat history</button> - <button id="forget-key" class="button button-ghost danger" type="button">Forget key</button> + <button id="forget-key" class="button button-ghost danger" type="button">Forget selected key</button> <button class="button button-dark" value="save" type="submit">Save settings</button> </div> </form>@@ -253,7 +302,7 @@ <p id="usage-note" class="usage-note"></p>
<p class="usage-disclaimer">This is not your OpenAI invoice. It excludes requests from other browsers and applications, incomplete requests without returned usage, taxes, credits, tools, special processing, and usage recorded before this tracker was added. Prices can change.</p> <div class="dialog-actions"> <button id="reset-usage" class="button button-ghost danger" type="button">Reset local history</button> - <a class="button button-ghost usage-dashboard" href="https://platform.openai.com/usage" target="_blank" rel="noopener noreferrer">Official dashboard ↗</a> + <a id="usage-dashboard" class="button button-ghost usage-dashboard" href="https://platform.openai.com/usage" target="_blank" rel="noopener noreferrer">OpenAI dashboard ↗</a> <button class="button button-dark" value="close" type="submit">Done</button> </div> </form>
@@ -133,12 +133,13 @@ .starter-icon { grid-row: 1 / span 2; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; background: #e4ebe7; color: var(--sage-dark); }
.starter strong { align-self: end; font-size: .86rem; } .starter small { color: var(--muted); line-height: 1.4; } -.conversation { width: min(850px, calc(100% - 50px)); margin: 0 auto; padding: 54px 0 220px; } -.message { content-visibility: auto; contain-intrinsic-size: auto 220px; margin-bottom: 34px; } +.conversation { width: min(850px, calc(100% - 50px)); margin: 0 auto; padding: 54px 0 max(220px, calc(100vh - 150px)); overflow-anchor: none; } +.message { margin-bottom: 34px; } .question-row { display: flex; justify-content: flex-end; } .question-bubble { max-width: min(680px, 88%); background: #e7ebe7; padding: 13px 17px; border-radius: 18px 18px 5px 18px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; } .answer { margin-top: 22px; display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 13px; scroll-margin-top: 88px; } .answer-mark { display: grid; place-items: center; align-self: start; width: 29px; height: 29px; border-radius: 9px; background: var(--ink); color: white; font-family: Georgia, serif; font-style: italic; font-size: .85rem; } +.answer-content { min-width: 0; } .answer-body { min-width: 0; line-height: 1.72; font-size: .98rem; overflow-wrap: anywhere; } .answer-body > :first-child { margin-top: 1px; } .answer-body > :last-child { margin-bottom: 0; }@@ -153,6 +154,9 @@ .answer-body pre code { background: none; padding: 0; font-size: .84rem; }
.answer-body blockquote { border-left: 3px solid var(--sage); margin-left: 0; padding-left: 15px; color: var(--muted); } .answer-body a { color: var(--sage-dark); text-underline-offset: 3px; } .answer-body .katex-display { overflow-x: auto; overflow-y: hidden; padding: 5px 0; } +.answer-attribution { display: flex; align-items: baseline; gap: 6px; min-width: 0; margin-top: 8px; color: var(--muted); line-height: 1.2; } +.answer-attribution span { overflow: hidden; font-size: .65rem; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; } +.answer-attribution small { flex: 0 0 auto; font-size: .53rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; } .answer-actions { display: flex; gap: 4px; margin: 12px 0 0 43px; opacity: .58; } .mini-action { border: 0; background: transparent; border-radius: 7px; padding: 5px 8px; cursor: pointer; color: var(--muted); font-size: .75rem; } .mini-action:hover { background: #e9e9e2; color: var(--ink); }@@ -172,6 +176,10 @@ .composer-tools { display: flex; align-items: center; gap: 10px; min-width: 0; }
.upload-pdf { flex: 0 0 auto; border: 1px solid var(--line); border-radius: 8px; background: transparent; padding: 5px 8px; color: var(--sage-dark); cursor: pointer; font-size: .68rem; font-weight: 750; } .upload-pdf:hover { background: var(--paper); } .upload-pdf:disabled { opacity: .45; cursor: default; } +.active-connection { min-width: 0; max-width: 190px; display: grid; gap: 1px; padding: 2px 5px; border: 0; border-radius: 7px; background: transparent; cursor: pointer; text-align: left; line-height: 1.1; } +.active-connection:hover { background: var(--paper); } +.active-connection span { overflow: hidden; color: var(--ink); font-size: .68rem; font-weight: 760; text-overflow: ellipsis; white-space: nowrap; } +.active-connection small { color: var(--muted); font-size: .5rem; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; } .attachments { display: flex; gap: 6px; overflow-x: auto; padding: 0 4px 9px 0; scrollbar-width: thin; } .attachments[hidden] { display: none; } .attachment-chip { flex: 0 1 auto; min-width: 0; max-width: 260px; display: inline-flex; align-items: center; gap: 6px; padding: 5px 7px; border: 1px solid var(--line); border-radius: 9px; background: var(--paper); color: var(--muted); font-size: .67rem; }@@ -238,14 +246,32 @@ .selection-menu blockquote { margin: 10px 0; padding: 7px 9px; border-left: 2px solid var(--sage); background: #f4f5f1; color: var(--muted); font-size: .74rem; line-height: 1.4; max-height: 70px; overflow: hidden; }
.selection-menu select, .selection-menu textarea { width: 100%; border: 1px solid var(--line); border-radius: 8px; padding: 8px; margin-bottom: 8px; background: white; font-size: .78rem; } .selection-menu .button { width: 100%; } -.settings-dialog { width: min(500px, calc(100vw - 28px)); border: 0; border-radius: 20px; padding: 0; color: var(--ink); box-shadow: var(--shadow); } +.settings-dialog { width: min(500px, calc(100vw - 28px)); max-height: calc(100vh - 28px); overflow: auto; border: 0; border-radius: 20px; padding: 0; color: var(--ink); box-shadow: var(--shadow); } +#settings-dialog { width: min(780px, calc(100vw - 28px)); } .settings-dialog::backdrop { background: rgba(25,31,28,.36); backdrop-filter: blur(3px); } .settings-dialog form { padding: 24px; } .dialog-heading { display: flex; justify-content: space-between; align-items: start; margin-bottom: 12px; } .settings-copy { color: var(--muted); font-size: .8rem; line-height: 1.55; } .settings-dialog label { display: block; margin: 14px 0; font-size: .73rem; font-weight: 750; } -.settings-dialog input, .settings-dialog textarea { width: 100%; margin-top: 6px; padding: 10px 11px; border: 1px solid var(--line); border-radius: 9px; outline: none; resize: vertical; font-weight: 400; } -.settings-dialog input:focus, .settings-dialog textarea:focus { border-color: var(--sage-dark); box-shadow: 0 0 0 3px rgba(79,109,96,.1); } +.settings-dialog input, .settings-dialog textarea, .settings-dialog select { width: 100%; margin-top: 6px; padding: 10px 11px; border: 1px solid var(--line); border-radius: 9px; outline: none; resize: vertical; font-weight: 400; background: white; color: var(--ink); } +.settings-dialog input:focus, .settings-dialog textarea:focus, .settings-dialog select:focus { border-color: var(--sage-dark); box-shadow: 0 0 0 3px rgba(79,109,96,.1); } +.field-help { display: block; margin-top: 5px; color: var(--muted); font-size: .65rem; font-weight: 400; line-height: 1.4; } +.active-provider-field { max-width: 280px; } +.provider-configs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 18px 0 16px; } +.provider-config { min-width: 0; padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--paper); transition: border-color .15s, box-shadow .15s; } +.provider-config.active { border-color: var(--sage-dark); box-shadow: 0 0 0 3px rgba(79,109,96,.09); } +.provider-config-heading, .provider-config-heading > div { display: flex; align-items: center; gap: 8px; } +.provider-config-heading { justify-content: space-between; margin-bottom: 4px; } +.provider-config-heading h3 { margin: 0; font-size: .84rem; } +.provider-logo { display: grid; place-items: center; width: 27px; height: 27px; border-radius: 8px; background: var(--ink); color: var(--paper); font-size: .49rem; font-weight: 800; letter-spacing: -.02em; } +.provider-active { display: none; padding: 3px 6px; border-radius: 999px; background: rgba(79,109,96,.12); color: var(--sage-dark); font-size: .52rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; } +.provider-config.active .provider-active { display: inline-block; } +.provider-config label { margin: 12px 0; } +.provider-advanced { margin-top: 12px; } +.provider-advanced label { margin-bottom: 0; } +.shared-settings { margin: 15px 0; } +.attachment-chip.inactive-provider { opacity: .58; } +.attachment-provider { color: var(--muted); font-size: .58rem; } .secret-field { display: flex; position: relative; } .secret-field input { padding-right: 58px; } .secret-field button { position: absolute; right: 6px; top: 12px; border: 0; background: transparent; color: var(--sage-dark); cursor: pointer; font-size: .72rem; }@@ -310,6 +336,7 @@ .welcome h1 { font-size: 3.2rem; }
.conversation { width: calc(100% - 26px); padding-top: 25px; } .composer-wrap { padding-left: 12px; padding-right: 12px; } .hint { display: none; } + .active-connection { max-width: 145px; } .answer { grid-template-columns: 25px minmax(0,1fr); gap: 9px; } .answer-mark { width: 25px; height: 25px; } .answer-actions { margin-left: 34px; }@@ -319,6 +346,9 @@ .usage-dialog form { padding: 19px; }
.usage-dialog .dialog-actions { display: grid; grid-template-columns: 1fr 1fr; } .usage-dialog .dialog-actions .button-dark { grid-column: 1 / -1; } .shortcut-list { grid-template-columns: 118px 1fr; gap: 9px 11px; } +} +@media (max-width: 700px) { + .provider-configs { grid-template-columns: 1fr; } } @media (max-width: 390px) { .brand > span:last-child, .usage-caption, .settings-label { display: none; }@@ -359,6 +389,9 @@ :root[data-theme="dark"] .selection-menu,
:root[data-theme="dark"] .settings-dialog, :root[data-theme="dark"] .selection-menu select, :root[data-theme="dark"] .selection-menu textarea { background: var(--card); } +:root[data-theme="dark"] .settings-dialog input, +:root[data-theme="dark"] .settings-dialog textarea, +:root[data-theme="dark"] .settings-dialog select { background: var(--card); } :root[data-theme="dark"] .selection-menu blockquote { background: #1a211e; } :root[data-theme="dark"] .security-callout { background: #30291f; color: #d1b99e; } :root[data-theme="dark"] .error-text { color: #f0aaa2; background: #3c2422; }