arnaucube
arnaucube/study-labPublic Self-hosted repository
add upload of md files into context; fix enumeration rendering, horizontal scrolling at node map
arnaucube git@arnaucube.com
Sat, 22 Aug 2026 11:23:47 +0200
commit

8061edc4384e91a00102bdb9180fc14c83554dc5

parent

4f34cebf8db9e15c4cd4d042432048706ad4c0cd

4 files changed, 260 insertions(+), 197 deletions(-)

jump to
M README.md → README.md

@@ -28,8 +28,8 @@ - persistent light/dark theme with system-theme detection

- monthly token tracking with per-model details and a local billing estimate - independent chat-history clearing and per-conversation deletion - multiple persistent maps with a browsable conversation sidebar -- per-map PDF context for books and papers, uploaded once through the Files API -- debounced search across map titles, questions, answers, and PDF names +- per-map PDF and Markdown context, uploaded once through the Files API +- debounced search across map titles, questions, answers, and attachment names - collapsible, zoomable concept trees with active-path highlighting - vim-style keyboard navigation for nodes, maps, search, and the composer - large-answer optimizations through throttled stream painting

@@ -57,16 +57,16 @@

Open the local URL printed by `serve`. 1. Choose **Settings**. -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`. +2. Paste the API key for each provider you plan to use. +3. Add model cards, choose OpenAI or OpenRouter on each card, and enter any model ID available to that account. OpenRouter model IDs use the `provider/model` form, such as `openai/gpt-5-mini`. +4. Select the radio button on the model card that should be used by default. 5. Save and ask a question. -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. +Settings stores the OpenAI and OpenRouter API keys once at the top. Model cards below can independently choose a provider, model ID, and endpoint; one card is selected as the default connection. A fresh installation starts with one OpenAI model card, and **+ Add model** creates more. -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. +To add reference material, choose **+ PDF / MD** beside the composer and select one or more PDF or Markdown (`.md`) files. 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`. +The endpoint on each model card is configurable under **Endpoint**. OpenAI defaults to `https://api.openai.com/v1/responses`; OpenRouter defaults to `https://openrouter.ai/api/v1/responses`. ## How branching works

@@ -84,7 +84,7 @@ **Settings → Clear chat history** is the explicit destructive action that removes all saved maps. It does not remove the API key, other settings, or the separate API-usage ledger.

## Search and navigation -Use the search field in the conversation sidebar—or press `/` outside an input—to search all saved map titles, questions, answers, and PDF names. Results link directly to the matching map or question node. Search is debounced and capped at 60 displayed results to keep large workspaces responsive. +Use the search field in the conversation sidebar—or press `/` outside an input—to search all saved map titles, questions, answers, and attachment names. Results link directly to the matching map or question node. Search is debounced and capped at 60 displayed results to keep large workspaces responsive. The concept-map toolbar can expand or collapse all branches and zoom the tree from 80% to 140%. Individual branches have disclosure arrows, and their collapsed state persists with the map. The current root-to-node path remains highlighted.

@@ -104,22 +104,22 @@ - `?` — open the in-app shortcut reference

![In-app reference for Vim-style keyboard shortcuts](screenshots/vim%20shortcuts.png) -## PDF context +## File context -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. +PDF and Markdown files are uploaded directly from the browser to the selected provider's Files endpoint, derived from its configured Responses API URL. Only their file IDs, types, providers, names, and sizes are stored in `localStorage`; the file contents are not copied into browser storage. PDF page images use low visual detail to reduce token use, while extracted PDF text and Markdown text remain available to the model. Use a model that supports the selected file type. -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. +File IDs belong to the provider that received the upload. If you switch providers, files uploaded to the other provider remain visible but are dimmed and are not sent. Upload the file 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. -Removing a PDF chip or clearing local chat history detaches the file locally but does **not** delete the uploaded copy from OpenAI. Delete retained files separately through your OpenAI account if needed. Custom API providers must expose a compatible `/files` endpoint next to their configured `/responses` endpoint. +Removing a file chip or clearing local chat history detaches the file locally but does **not** delete the uploaded copy from the provider. Delete retained files separately through your provider account if needed. Custom API providers must expose a compatible `/files` endpoint next to their configured `/responses` endpoint. ## Storage and security 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` — selected provider, per-provider API keys/models/endpoints, and system instructions +- `study-lab:v1` — multi-map conversation workspace, attachment references, collapsed branches, and active-map selection +- `study-lab:settings:v1` — provider API keys, model cards, the default model, 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
M app.js → app.js

@@ -8,7 +8,7 @@ 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 FILE_REQUEST_LIMIT = 50 * 1024 * 1024; const PRICE_CACHE_TTL = 24 * 60 * 60 * 1000; const PROVIDERS = { openai: {

@@ -36,14 +36,9 @@ { 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' } - }, + activeModelId: 'model-openai', + apiKeys: { openai: '', openrouter: '' }, + models: [{ id: 'model-openai', provider: 'openai', model: 'gpt-5-mini', apiUrl: 'https://api.openai.com/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.' };

@@ -54,17 +49,16 @@ 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'), - provider: $('#provider'), openaiApiKey: $('#openai-api-key'), openrouterApiKey: $('#openrouter-api-key'), - openaiModel: $('#openai-model'), openrouterModel: $('#openrouter-model'), + openaiApiKey: $('#openai-api-key'), openrouterApiKey: $('#openrouter-api-key'), modelConfigs: $('#model-configs'), openaiModelOptions: $('#openai-model-options'), openrouterModelOptions: $('#openrouter-model-options'), - openaiApiUrl: $('#openai-api-url'), openrouterApiUrl: $('#openrouter-api-url'), systemPrompt: $('#system-prompt'), + 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'), usageDashboard: $('#usage-dashboard'), historyList: $('#history-list'), historyEmpty: $('#history-empty'), historyToggle: $('#toggle-history'), - attachments: $('#attachments'), uploadPDF: $('#upload-pdf'), pdfInput: $('#pdf-input'), + attachments: $('#attachments'), uploadFile: $('#upload-file'), fileInput: $('#file-input'), activeConnection: $('#active-connection'), activeModel: $('#active-model'), activeProvider: $('#active-provider'), conversationSearch: $('#conversation-search'), searchResults: $('#search-results'), treeZoom: $('#tree-zoom'), shortcutsDialog: $('#shortcuts-dialog')

@@ -72,13 +66,13 @@ };

migrateLegacyStorage(); let settings = normalizeSettings(loadJSON(SETTINGS_KEY, DEFAULT_SETTINGS)); - let settingsDraftProfiles = null; + let settingsDraft = null; let workspace = loadWorkspace(); let state = currentMap(); let usageRecords = loadUsage(); let openRouterPrices = loadOpenRouterPrices(); let activeRequest = null; - let uploadingPDFs = false; + let uploadingFiles = false; let uploadTargetMapId = null; let selectedText = null; let saveTimer = 0;

@@ -95,27 +89,42 @@ 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) - }; + const apiKeys = { + openai: String(value.apiKeys?.openai ?? value.profiles?.openai?.apiKey ?? value.apiKey ?? ''), + openrouter: String(value.apiKeys?.openrouter ?? value.profiles?.openrouter?.apiKey ?? '') + }; + let sourceModels = Array.isArray(value.models) ? value.models : null; + if (!sourceModels?.length && value.profiles) { + sourceModels = Object.entries(PROVIDERS).map(([provider, defaults]) => ({ + id: `model-${provider}`, provider, model: value.profiles[provider]?.model || defaults.model, + apiUrl: value.profiles[provider]?.apiUrl || defaults.apiUrl + })); + } + if (!sourceModels?.length) { + const provider = PROVIDERS[value.provider] ? value.provider : 'openai'; + sourceModels = [{ + id: 'model-openai', provider, model: value.model || PROVIDERS[provider].model, + apiUrl: value.apiUrl || PROVIDERS[provider].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 usedIds = new Set(); + const models = sourceModels.map((saved, index) => { + const provider = PROVIDERS[saved?.provider] ? saved.provider : 'openai'; + let id = String(saved?.id || `model-${index + 1}`); + while (usedIds.has(id)) id = `${id}-${index + 1}`; + usedIds.add(id); + return { + id, provider, model: String(saved?.model || PROVIDERS[provider].model), + apiUrl: String(saved?.apiUrl || PROVIDERS[provider].apiUrl) }; - } - const active = profiles[provider]; + }); + const legacyActive = models.find(model => model.provider === value.provider)?.id; + const activeModelId = models.some(model => model.id === value.activeModelId) + ? value.activeModelId : (legacyActive || models[0].id); + const active = models.find(model => model.id === activeModelId) || models[0]; return { - provider, profiles, apiKey: active.apiKey, model: active.model, apiUrl: active.apiUrl, + activeModelId, apiKeys, models, provider: active.provider, apiKey: apiKeys[active.provider], + model: active.model, apiUrl: active.apiUrl, systemPrompt: String(value.systemPrompt ?? DEFAULT_SETTINGS.systemPrompt) }; }

@@ -191,6 +200,7 @@ .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', + kind: file.kind === 'markdown' || String(file.name).toLowerCase().endsWith('.md') ? 'markdown' : 'pdf', provider: PROVIDERS[file.provider] ? file.provider : 'openai' })) : []; const createdAt = Number(value.createdAt) || Number(normalized.nodes[0]?.createdAt) || Date.now();

@@ -352,11 +362,21 @@ const lines = text.split('\n');

const html = []; let paragraph = []; let list = null; + let listItemOpen = false; const flushParagraph = () => { if (paragraph.length) html.push(`<p>${inlineMarkdown(paragraph.join('\n')).replace(/\n/g, '<br>')}</p>`); paragraph = []; }; - const closeList = () => { if (list) html.push(`</${list}>`); list = null; }; + const closeListItem = () => { + if (listItemOpen) html.push('</li>'); + listItemOpen = false; + }; + const closeList = () => { + if (!list) return; + closeListItem(); + html.push(`</${list}>`); + list = null; + }; for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { const line = lines[lineIndex];

@@ -378,7 +398,7 @@ }

} const heading = line.match(/^(#{1,3})\s+(.+)/); const bullet = line.match(/^\s*[-*+]\s+(.+)/); - const numbered = line.match(/^\s*\d+[.)]\s+(.+)/); + const numbered = line.match(/^\s*(\d+)[.)]\s+(.+)/); const quote = line.match(/^>\s?(.*)/); const blockToken = line.match(/^\u0000(\d+)\u0000$/); if (blockToken) { flushParagraph(); closeList(); html.push(stash[Number(blockToken[1])]); }

@@ -386,11 +406,27 @@ else if (heading) { flushParagraph(); closeList(); const level = heading[1].length; html.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`); }

else if (bullet || numbered) { flushParagraph(); const wanted = bullet ? 'ul' : 'ol'; - if (list !== wanted) { closeList(); html.push(`<${wanted}>`); list = wanted; } - html.push(`<li>${inlineMarkdown((bullet || numbered)[1])}</li>`); + if (list !== wanted) { + closeList(); + const start = numbered && numbered[1] !== '1' ? ` start="${Number(numbered[1])}"` : ''; + html.push(`<${wanted}${start}>`); + list = wanted; + } else closeListItem(); + html.push(`<li>${inlineMarkdown(bullet ? bullet[1] : numbered[2])}`); + listItemOpen = true; } else if (quote) { flushParagraph(); closeList(); html.push(`<blockquote>${inlineMarkdown(quote[1])}</blockquote>`); } - else if (!line.trim()) { flushParagraph(); closeList(); } - else paragraph.push(line); + else if (!line.trim()) { + flushParagraph(); + let nextIndex = lineIndex + 1; + while (nextIndex < lines.length && !lines[nextIndex].trim()) nextIndex += 1; + const nextLine = lines[nextIndex] || ''; + const continuesList = Boolean(list) && (/^\s+\S/.test(nextLine) || (list === 'ul' + ? /^\s*[-*+]\s+/.test(nextLine) + : /^\s*\d+[.)]\s+/.test(nextLine))); + if (!continuesList) closeList(); + } + else if (list && /^\s+\S/.test(line)) html.push(`<p>${inlineMarkdown(line.trim())}</p>`); + else { closeList(); paragraph.push(line); } } flushParagraph(); closeList(); return html.join('').replace(/\u0000(\d+)\u0000/g, (_, index) => stash[Number(index)]);

@@ -560,8 +596,8 @@ button.className = `history-item${map.id === workspace.activeMapId ? ' active' : ''}`;

button.dataset.mapId = map.id; button.setAttribute('aria-current', map.id === workspace.activeMapId ? 'page' : 'false'); const date = new Date(map.updatedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); - const pdfMeta = map.attachments.length ? ` · ${map.attachments.length} PDF${map.attachments.length === 1 ? '' : 's'}` : ''; - button.innerHTML = `<span class="history-glyph" aria-hidden="true">${map.nodes.length || map.attachments.length ? '◇' : '+'}</span><span class="history-copy"><span class="history-title">${escapeHTML(map.title)}</span><span class="history-meta">${map.nodes.length} ${map.nodes.length === 1 ? 'node' : 'nodes'}${pdfMeta} · ${date}</span></span>`; + const fileMeta = map.attachments.length ? ` · ${map.attachments.length} file${map.attachments.length === 1 ? '' : 's'}` : ''; + button.innerHTML = `<span class="history-glyph" aria-hidden="true">${map.nodes.length || map.attachments.length ? '◇' : '+'}</span><span class="history-copy"><span class="history-title">${escapeHTML(map.title)}</span><span class="history-meta">${map.nodes.length} ${map.nodes.length === 1 ? 'node' : 'nodes'}${fileMeta} · ${date}</span></span>`; const rename = document.createElement('button'); rename.type = 'button'; rename.className = 'rename-map';

@@ -598,7 +634,7 @@ fields: [node.question, node.answer || ''], mapTitle: map.title

}); } for (const file of map.attachments) { - entries.push({ mapId: map.id, nodeId: null, kind: 'PDF', label: file.name, fields: [file.name], mapTitle: map.title }); + entries.push({ mapId: map.id, nodeId: null, kind: file.kind === 'markdown' ? 'Markdown' : 'PDF', label: file.name, fields: [file.name], mapTitle: map.title }); } } searchIndex = entries;

@@ -664,18 +700,19 @@ chip.className = 'attachment-chip';

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>`; + const badge = file.kind === 'markdown' ? 'MD' : 'PDF'; + chip.innerHTML = `<span class="attachment-badge${file.kind === 'markdown' ? ' md' : ''}" aria-hidden="true">${badge}</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) { + if (uploadingFiles && state.id === uploadTargetMapId) { const status = document.createElement('span'); status.className = 'attachment-chip upload-status'; status.innerHTML = '<span class="upload-spinner" aria-hidden="true"></span> Uploading…'; fragment.append(status); } els.attachments.replaceChildren(fragment); - els.attachments.hidden = !state.attachments.length && !(uploadingPDFs && state.id === uploadTargetMapId); - els.uploadPDF.disabled = uploadingPDFs || Boolean(activeRequest); + els.attachments.hidden = !state.attachments.length && !(uploadingFiles && state.id === uploadTargetMapId); + els.uploadFile.disabled = uploadingFiles || Boolean(activeRequest); } function renderAll(options) { renderConversation(options); renderTree(); renderHistory(); renderAttachments(); }

@@ -708,7 +745,9 @@ }

} const content = map.attachments .filter(file => file.provider === provider) - .map(file => ({ type: 'input_file', file_id: file.id, detail: file.detail || 'low' })); + .map(file => file.kind === 'markdown' + ? { type: 'input_file', file_id: file.id } + : { type: 'input_file', file_id: file.id, detail: file.detail || 'low' }); content.push({ type: 'input_text', text: question }); const structured = provider === 'openrouter' || content.length > 1; messages.push(structured ? { type: 'message', role: 'user', content } : { role: 'user', content: question });

@@ -724,22 +763,22 @@ url.hash = '';

return url.toString(); } - async function uploadPDFs(fileList) { + async function uploadFiles(fileList) { const files = [...fileList]; if (!files.length) return; - if (activeRequest || uploadingPDFs) { showToast('Finish the current operation first'); return; } + if (activeRequest || uploadingFiles) { showToast('Finish the current operation first'); 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 valid = files.filter(file => /\.(pdf|md)$/i.test(file.name)); + if (valid.length !== files.length) { showToast('Only PDF and Markdown (.md) files can be added'); return; } + if (valid.some(file => file.size >= FILE_REQUEST_LIMIT)) { showToast('Each file must be smaller than 50 MB'); return; } 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; } + if (existingBytes + addedBytes >= FILE_REQUEST_LIMIT) { showToast('Files in one map must total less than 50 MB'); return; } const targetMap = state; const connection = { ...settings }; - uploadingPDFs = true; + uploadingFiles = true; uploadTargetMapId = targetMap.id; renderAttachments(); let uploaded = 0;

@@ -753,7 +792,7 @@ const response = await fetch(endpoint, {

method: 'POST', headers: requestHeaders(false, connection), body: form }); if (!response.ok) { - let message = `PDF upload failed (${response.status})`; + let message = `File upload failed (${response.status})`; try { const data = await response.json(); message = data.error?.message || message; } catch {} throw new Error(message); }

@@ -762,6 +801,7 @@ 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 ?? data.size_bytes) || file.size, createdAt: Date.now(), detail: 'low', + kind: file.name.toLowerCase().endsWith('.md') ? 'markdown' : 'pdf', provider: connection.provider }); uploaded += 1;

@@ -769,13 +809,13 @@ touchMap(targetMap);

persistNow(); if (state === targetMap) renderAll(); } - showToast(`${uploaded} PDF${uploaded === 1 ? '' : 's'} added to this map`); + showToast(`${uploaded} file${uploaded === 1 ? '' : 's'} added to this map`); } catch (error) { - showToast(error.message || 'PDF upload failed'); + showToast(error.message || 'File upload failed'); } finally { - uploadingPDFs = false; + uploadingFiles = false; uploadTargetMapId = null; - els.pdfInput.value = ''; + els.fileInput.value = ''; renderAll(); } }

@@ -788,14 +828,14 @@ state.attachments = state.attachments.filter(candidate => candidate.id !== fileId);

touchMap(); persistNow(); renderAll(); - showToast('PDF removed from this map'); + showToast('File removed from this map'); } async function ask(question, parentId = state.activeId) { 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 (uploadingFiles) { showToast('Wait for the file upload to finish'); return; } if (!settings.apiKey) { openSettings(); showToast(`Add a ${providerLabel()} API key to begin`); return; } const requestMap = state;

@@ -886,21 +926,18 @@ function setBusy(busy) {

els.prompt.disabled = busy; els.send.textContent = busy ? '■' : '↑'; els.send.setAttribute('aria-label', busy ? 'Stop response' : 'Send message'); - els.uploadPDF.disabled = busy || uploadingPDFs; + els.uploadFile.disabled = busy || uploadingFiles; } function openSettings() { - 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; + settingsDraft = structuredClone({ + activeModelId: settings.activeModelId, apiKeys: settings.apiKeys, models: settings.models + }); + els.openaiApiKey.value = settingsDraft.apiKeys.openai || ''; + els.openrouterApiKey.value = settingsDraft.apiKeys.openrouter || ''; fillModelOptions(els.openaiModelOptions, PROVIDERS.openai.models); fillModelOptions(els.openrouterModelOptions, PROVIDERS.openrouter.models); - updateProviderCards(); + renderModelCards(); els.systemPrompt.value = settings.systemPrompt; els.settings.showModal(); setTimeout(() => (settings.provider === 'openrouter' ? els.openrouterApiKey : els.openaiApiKey).focus(), 50);

@@ -910,25 +947,59 @@ 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 renderModelCards() { + if (!settingsDraft) return; + els.modelConfigs.innerHTML = settingsDraft.models.map((connection, index) => { + const defaults = PROVIDERS[connection.provider]; + const active = connection.id === settingsDraft.activeModelId; + return `<section class="model-config${active ? ' active' : ''}" data-model-card data-model-id="${escapeHTML(connection.id)}" data-provider="${connection.provider}"> + <div class="model-config-heading"> + <div><span class="provider-logo">${connection.provider === 'openrouter' ? 'OR' : 'OAI'}</span><h4>Model ${index + 1}</h4></div> + <label class="default-model"><input class="default-model-radio" type="radio" name="default-model" value="${escapeHTML(connection.id)}"${active ? ' checked' : ''}> Use by default</label> + </div> + <div class="model-fields"> + <label>Provider + <select class="model-provider"> + <option value="openai"${connection.provider === 'openai' ? ' selected' : ''}>OpenAI</option> + <option value="openrouter"${connection.provider === 'openrouter' ? ' selected' : ''}>OpenRouter</option> + </select> + </label> + <label>Model + <input class="model-name-input" type="text" value="${escapeHTML(connection.model)}" placeholder="${escapeHTML(defaults.model)}" list="${connection.provider}-model-options" spellcheck="false" required> + </label> + </div> + <details class="provider-advanced"> + <summary>Endpoint</summary> + <label>Responses API endpoint + <input class="model-api-url" type="url" value="${escapeHTML(connection.apiUrl)}" spellcheck="false" required> + </label> + </details> + <button class="remove-model" type="button"${settingsDraft.models.length === 1 ? ' disabled' : ''} aria-label="Remove model ${index + 1}">Remove model</button> + </section>`; + }).join(''); + } + + function captureSettingsForm() { + if (!settingsDraft) return; + settingsDraft.apiKeys = { + openai: els.openaiApiKey.value.trim(), openrouter: els.openrouterApiKey.value.trim() }; + settingsDraft.models = [...els.modelConfigs.querySelectorAll('[data-model-card]')].map(card => { + const provider = $('.model-provider', card).value; + return { + id: card.dataset.modelId, provider, + model: $('.model-name-input', card).value.trim() || PROVIDERS[provider].model, + apiUrl: $('.model-api-url', card).value.trim() || PROVIDERS[provider].apiUrl + }; + }); + settingsDraft.activeModelId = $('input[name="default-model"]:checked', els.modelConfigs)?.value + || settingsDraft.models[0]?.id; } - function updateProviderCards() { - document.querySelectorAll('[data-provider-config]').forEach(card => { - card.classList.toggle('active', card.dataset.providerConfig === els.provider.value); + function updateModelCardSelection() { + const selected = $('input[name="default-model"]:checked', els.modelConfigs)?.value; + els.modelConfigs.querySelectorAll('[data-model-card]').forEach(card => { + card.classList.toggle('active', card.dataset.modelId === selected); }); }

@@ -1280,7 +1351,7 @@

function deleteMap(mapId) { const map = workspace.maps.find(candidate => candidate.id === mapId); if (!map) return; - if (uploadingPDFs && uploadTargetMapId === map.id) { showToast('Wait for this map’s PDF upload to finish'); return; } + if (uploadingFiles && uploadTargetMapId === map.id) { showToast('Wait for this map’s file upload to finish'); return; } const remoteNote = map.attachments.length ? ' Uploaded API files will not be deleted.' : ''; if (!confirm(`Delete “${map.title}” and its entire conversation? This cannot be undone.${remoteNote}`)) return; if (activeRequest && map.nodes.some(node => node.status === 'loading')) activeRequest.abort();

@@ -1423,9 +1494,10 @@ if (first) { event.preventDefault(); first.click(); }

}); $('#study-lab-home').addEventListener('click', event => { event.preventDefault(); createNewMap(); }); $('#new-map-sidebar').addEventListener('click', createNewMap); - els.uploadPDF.addEventListener('click', () => els.pdfInput.click()); - els.pdfInput.addEventListener('change', () => uploadPDFs(els.pdfInput.files)); + els.uploadFile.addEventListener('click', () => els.fileInput.click()); + els.fileInput.addEventListener('change', () => uploadFiles(els.fileInput.files)); $('#close-settings').addEventListener('click', () => els.settings.close()); + els.settings.addEventListener('close', () => { settingsDraft = null; }); $('#close-usage').addEventListener('click', () => els.usageDialog.close()); $('#open-shortcuts').addEventListener('click', () => els.shortcutsDialog.showModal()); $('#close-shortcuts').addEventListener('click', () => els.shortcutsDialog.close());

@@ -1449,26 +1521,42 @@ 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); + $('#add-model').addEventListener('click', () => { + captureSettingsForm(); + const id = uid(); + settingsDraft.models.push({ id, provider: 'openai', model: PROVIDERS.openai.model, apiUrl: PROVIDERS.openai.apiUrl }); + renderModelCards(); + $('.model-name-input', els.modelConfigs.lastElementChild)?.focus(); + }); + els.modelConfigs.addEventListener('change', event => { + if (event.target.matches('.default-model-radio')) { updateModelCardSelection(); return; } + if (!event.target.matches('.model-provider')) return; + const card = event.target.closest('[data-model-card]'); + const previousProvider = card.dataset.provider; + const provider = event.target.value; + captureSettingsForm(); + const connection = settingsDraft.models.find(model => model.id === card.dataset.modelId); + if (connection.model === PROVIDERS[previousProvider].model) connection.model = PROVIDERS[provider].model; + if (connection.apiUrl === PROVIDERS[previousProvider].apiUrl) connection.apiUrl = PROVIDERS[provider].apiUrl; + connection.provider = provider; + renderModelCards(); + }); + els.modelConfigs.addEventListener('click', event => { + const button = event.target.closest('.remove-model'); + if (!button || button.disabled) return; + const id = button.closest('[data-model-card]').dataset.modelId; + captureSettingsForm(); + settingsDraft.models = settingsDraft.models.filter(model => model.id !== id); + if (settingsDraft.activeModelId === id) settingsDraft.activeModelId = settingsDraft.models[0].id; + renderModelCards(); + }); $('#settings-form').addEventListener('submit', event => { event.preventDefault(); - captureProviderForms(); - settings = normalizeSettings({ provider: els.provider.value, profiles: settingsDraftProfiles, systemPrompt: els.systemPrompt.value.trim() }); + captureSettingsForm(); + settings = normalizeSettings({ ...settingsDraft, systemPrompt: els.systemPrompt.value.trim() }); localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); - settingsDraftProfiles = null; + settingsDraft = 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(); - showToast(`${providerLabel(provider)} 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 }); });
M index.html → index.html

@@ -98,12 +98,12 @@ <span>Branching from <strong id="branch-label"></strong></span>

<button id="clear-branch" type="button" aria-label="Return to latest node">×</button> </div> <form id="composer" class="composer"> - <div id="attachments" class="attachments" aria-label="PDFs in this map" hidden></div> + <div id="attachments" class="attachments" aria-label="Files in this map" hidden></div> <textarea id="prompt" rows="1" placeholder="Ask anything…" aria-label="Message" maxlength="30000"></textarea> <div class="composer-footer"> <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 id="upload-file" class="upload-file" type="button" title="Add PDF or Markdown files to this map" aria-label="Add PDF or Markdown files to this map"> + <span aria-hidden="true">+</span> PDF / MD </button> <button id="active-connection" class="active-connection" type="button" aria-label="Open API settings"> <span id="active-model">gpt-5-mini</span>

@@ -113,9 +113,9 @@ <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> </div> - <input id="pdf-input" type="file" accept="application/pdf,.pdf" multiple hidden> + <input id="file-input" type="file" accept="application/pdf,text/markdown,.pdf,.md" multiple hidden> </form> - <p class="privacy-note">Your map stays in this browser. Added PDFs are uploaded to the configured API.</p> + <p class="privacy-note">Your map stays in this browser. Added files are uploaded to the configured API.</p> </div> </section>

@@ -176,67 +176,33 @@ <h2>API settings</h2>

</div> <button id="close-settings" class="icon-button" type="button" aria-label="Close settings">×</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> - <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> + <p class="settings-copy">Add your provider keys once, then create model cards and choose which model Study Lab uses by default.</p> + <section class="api-key-settings" aria-labelledby="api-keys-title"> + <h3 id="api-keys-title">API keys</h3> + <div class="api-key-grid"> <label> - API key + OpenAI 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 + OpenRouter 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> + </section> + <div class="model-settings-heading"> + <div><h3>Models</h3><p>Choose one model as the default connection.</p></div> + <button id="add-model" class="button button-ghost add-model" type="button" aria-label="Add model">+ Add model</button> </div> + <div id="model-configs" class="model-configs"></div> + <datalist id="openai-model-options"></datalist> + <datalist id="openrouter-model-options"></datalist> <details class="shared-settings"> <summary>Shared instructions</summary> <label>

@@ -247,7 +213,6 @@ </details>

<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 selected key</button> <button class="button button-dark" value="save" type="submit">Save settings</button> </div> </form>
M styles.css → styles.css

@@ -180,9 +180,9 @@ .composer:focus-within { border-color: var(--sage-dark); box-shadow: 0 0 0 3px rgba(79,109,96,.1), 0 9px 32px rgba(34,43,39,.09); }

.composer textarea { display: block; width: 100%; max-height: 180px; resize: none; border: 0; outline: 0; background: transparent; line-height: 1.5; padding: 2px 5px 8px 0; } .composer-footer { display: flex; justify-content: space-between; align-items: center; } .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; } +.upload-file { 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-file:hover { background: var(--paper); } +.upload-file: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; }

@@ -190,7 +190,8 @@ .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; } -.pdf-badge { flex: 0 0 auto; padding: 2px 4px; border-radius: 4px; background: #a84b42; color: #fff; font-size: .52rem; font-weight: 800; letter-spacing: .04em; } +.attachment-badge { flex: 0 0 auto; padding: 2px 4px; border-radius: 4px; background: #a84b42; color: #fff; font-size: .52rem; font-weight: 800; letter-spacing: .04em; } +.attachment-badge.md { background: var(--sage-dark); } .attachment-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .remove-attachment { flex: 0 0 auto; width: 18px; height: 18px; padding: 0; border: 0; border-radius: 5px; background: transparent; color: var(--muted); cursor: pointer; line-height: 1; } .remove-attachment:hover { background: var(--line); color: var(--ink); }

@@ -217,24 +218,24 @@ .map-toolbar button { min-width: 25px; height: 26px; padding: 0 7px; border: 1px solid var(--line); border-radius: 7px; background: transparent; color: var(--muted); cursor: pointer; font-size: .62rem; }

.map-toolbar button:hover { background: var(--card); color: var(--ink); } .map-toolbar-spacer { flex: 1; } .map-toolbar output { min-width: 34px; color: var(--muted); text-align: center; font-size: .58rem; font-variant-numeric: tabular-nums; } -.tree { --tree-indent: 20px; --tree-dot-size: 17px; --tree-node-pad-y: 8px; flex: 1; min-height: 0; overflow: auto; padding: 5px 4px 30px; font-size: .76rem; scrollbar-width: thin; } -.tree ul { list-style: none; margin: 0; padding: 0 0 0 var(--tree-indent); position: relative; } -.tree > ul { padding-left: 0; } +.tree { --tree-indent: 20px; --tree-dot-size: 17px; --tree-node-pad-y: 8px; flex: 1; min-height: 0; overflow-x: auto; overflow-y: auto; padding: 5px 4px 30px; font-size: .76rem; scrollbar-width: thin; } +.tree ul { width: max-content; list-style: none; margin: 0; padding: 0 0 0 var(--tree-indent); position: relative; } +.tree > ul { min-width: 100%; padding-left: 0; } .tree ul ul::before { content: ""; position: absolute; top: -6px; bottom: 13px; left: 7px; border-left: 1px solid #c2c8c2; } .tree li { position: relative; padding: 4px 0; } .tree ul ul > li::before { content: ""; position: absolute; width: 12px; left: -13px; top: 20px; border-top: 1px solid #c2c8c2; } -.tree-node-wrap { display: flex; align-items: center; min-width: 0; } +.tree-node-wrap { display: flex; align-items: center; width: max-content; min-width: 100%; } .toggle-subtree, .toggle-placeholder { flex: 0 0 18px; width: 18px; height: 25px; display: grid; place-items: center; } .toggle-subtree { padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--muted); cursor: pointer; font-size: .9em; } .toggle-subtree:hover { background: var(--card); color: var(--ink); } -.tree-node { width: 100%; min-width: 0; flex: 1; border: 1px solid transparent; border-radius: 11px; background: transparent; padding: var(--tree-node-pad-y) 9px; text-align: left; cursor: pointer; display: grid; grid-template-columns: calc(var(--tree-dot-size) + 2px) minmax(0,1fr); gap: 7px; align-items: start; font-size: inherit; } +.tree-node { width: auto; min-width: 0; flex: 1 0 auto; border: 1px solid transparent; border-radius: 11px; background: transparent; padding: var(--tree-node-pad-y) 9px; text-align: left; cursor: pointer; display: grid; grid-template-columns: calc(var(--tree-dot-size) + 2px) auto; gap: 7px; align-items: start; font-size: inherit; } .tree-node:hover { background: rgba(255,255,255,.55); } .tree-node.active { background: white; border-color: #cbd4ce; box-shadow: 0 3px 12px rgba(40,48,44,.06); } .tree-node.on-path:not(.active) { background: rgba(139,163,151,.09); } .node-dot { width: var(--tree-dot-size); height: var(--tree-dot-size); border-radius: 50%; display: grid; place-items: center; margin-top: 1px; background: #cad6cf; color: var(--sage-dark); font-size: .7em; font-weight: 800; } .tree-node.active .node-dot { background: var(--sage-dark); color: white; } -.node-copy { min-width: 0; } -.node-title { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 1em; font-weight: 680; } +.node-copy { min-width: max-content; } +.node-title { display: block; white-space: nowrap; font-size: 1em; font-weight: 680; } .node-meta { color: #858c88; font-size: .82em; } .map-empty { margin: auto; width: 170px; text-align: center; color: var(--muted); font: 1rem/1.5 Georgia, serif; } .empty-orbit { display: grid; place-items: center; width: 54px; height: 54px; margin: auto; border: 1px dashed var(--sage); border-radius: 50%; color: var(--sage-dark); }

@@ -263,19 +264,28 @@ .settings-dialog label { display: block; margin: 14px 0; font-size: .73rem; font-weight: 750; }

.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; } +.api-key-settings { margin: 18px 0 22px; padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--paper); } +.api-key-settings h3, .model-settings-heading h3 { margin: 0; font-size: .88rem; } +.api-key-grid, .model-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +.api-key-grid label { margin: 12px 0 0; } +.model-settings-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0 0 10px; } +.model-settings-heading p { margin: 3px 0 0; color: var(--muted); font-size: .7rem; } +.add-model { flex: 0 0 auto; color: var(--sage-dark); } +.model-configs { display: grid; gap: 10px; margin-bottom: 16px; } +.model-config { min-width: 0; padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--paper); transition: border-color .15s, box-shadow .15s; } +.model-config.active { border-color: var(--sage-dark); box-shadow: 0 0 0 3px rgba(79,109,96,.09); } +.model-config-heading, .model-config-heading > div { display: flex; align-items: center; gap: 8px; } +.model-config-heading { justify-content: space-between; margin-bottom: 2px; } +.model-config-heading h4 { 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; } +.settings-dialog .default-model { display: flex; align-items: center; gap: 7px; margin: 0; color: var(--muted); cursor: pointer; font-size: .68rem; } +.settings-dialog .default-model input { width: auto; margin: 0; padding: 0; accent-color: var(--sage-dark); box-shadow: none; } +.model-config.active .default-model { color: var(--sage-dark); } +.model-config label { margin: 12px 0; } .provider-advanced { margin-top: 12px; } .provider-advanced label { margin-bottom: 0; } +.remove-model { display: block; margin: 11px 0 0 auto; padding: 0; border: 0; background: transparent; color: #9d443e; cursor: pointer; font-size: .66rem; } +.remove-model:disabled { visibility: hidden; } .shared-settings { margin: 15px 0; } .attachment-chip.inactive-provider { opacity: .58; } .attachment-provider { color: var(--muted); font-size: .58rem; }

@@ -355,7 +365,7 @@ .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; } + .api-key-grid, .model-fields { grid-template-columns: 1fr; gap: 0; } } @media (max-width: 390px) { .brand > span:last-child, .usage-caption, .settings-label { display: none; }