From 2638e08453ad08784df15f0d25c664d0a48c791e Mon Sep 17 00:00:00 2001 From: Peisong Xiao Date: Sat, 17 Jan 2026 16:46:01 -0500 Subject: [PATCH] Added multi-api support and advanced mode --- wwcompanion-extension/background.js | 311 +++++++++++++---- wwcompanion-extension/popup.js | 75 +++- wwcompanion-extension/settings.css | 45 ++- wwcompanion-extension/settings.html | 31 +- wwcompanion-extension/settings.js | 513 ++++++++++++++++++++++++---- 5 files changed, 809 insertions(+), 166 deletions(-) diff --git a/wwcompanion-extension/background.js b/wwcompanion-extension/background.js index a76ab6e..6b92c07 100644 --- a/wwcompanion-extension/background.js +++ b/wwcompanion-extension/background.js @@ -17,6 +17,8 @@ const DEFAULT_SETTINGS = { apiKey: "", apiKeys: [], activeApiKeyId: "", + apiConfigs: [], + activeApiConfigId: "", apiBaseUrl: "https://api.openai.com/v1", apiKeyHeader: "Authorization", apiKeyPrefix: "Bearer ", @@ -91,6 +93,99 @@ chrome.runtime.onInstalled.addListener(async () => { : `key-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; updates.apiKeys = [{ id, name: "Default", key: stored.apiKey }]; updates.activeApiKeyId = id; + } else if (hasApiKeys && stored.activeApiKeyId) { + const exists = stored.apiKeys.some((key) => key.id === stored.activeApiKeyId); + if (!exists) { + updates.activeApiKeyId = stored.apiKeys[0].id; + } + } else if (hasApiKeys && !stored.activeApiKeyId) { + updates.activeApiKeyId = stored.apiKeys[0].id; + } + + const hasApiConfigs = + Array.isArray(stored.apiConfigs) && stored.apiConfigs.length > 0; + + if (!hasApiConfigs) { + const fallbackKeyId = + updates.activeApiKeyId || + stored.activeApiKeyId || + stored.apiKeys?.[0]?.id || + ""; + const id = crypto?.randomUUID + ? crypto.randomUUID() + : `config-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + updates.apiConfigs = [ + { + id, + name: "Default", + apiBaseUrl: stored.apiBaseUrl || DEFAULT_SETTINGS.apiBaseUrl, + apiKeyHeader: stored.apiKeyHeader || DEFAULT_SETTINGS.apiKeyHeader, + apiKeyPrefix: stored.apiKeyPrefix || DEFAULT_SETTINGS.apiKeyPrefix, + model: stored.model || DEFAULT_SETTINGS.model, + apiKeyId: fallbackKeyId, + apiUrl: "", + requestTemplate: "", + advanced: false + } + ]; + updates.activeApiConfigId = id; + } else if (stored.activeApiConfigId) { + const exists = stored.apiConfigs.some( + (config) => config.id === stored.activeApiConfigId + ); + if (!exists) { + updates.activeApiConfigId = stored.apiConfigs[0].id; + } + const fallbackKeyId = + updates.activeApiKeyId || + stored.activeApiKeyId || + stored.apiKeys?.[0]?.id || + ""; + const normalizedConfigs = stored.apiConfigs.map((config) => ({ + ...config, + apiKeyId: config.apiKeyId || fallbackKeyId, + apiUrl: config.apiUrl || "", + requestTemplate: config.requestTemplate || "", + advanced: Boolean(config.advanced) + })); + const needsUpdate = normalizedConfigs.some((config, index) => { + const original = stored.apiConfigs[index]; + return ( + config.apiKeyId !== original.apiKeyId || + (config.apiUrl || "") !== (original.apiUrl || "") || + (config.requestTemplate || "") !== (original.requestTemplate || "") || + Boolean(config.advanced) !== Boolean(original.advanced) + ); + }); + if (needsUpdate) { + updates.apiConfigs = normalizedConfigs; + } + } else { + updates.activeApiConfigId = stored.apiConfigs[0].id; + const fallbackKeyId = + updates.activeApiKeyId || + stored.activeApiKeyId || + stored.apiKeys?.[0]?.id || + ""; + const normalizedConfigs = stored.apiConfigs.map((config) => ({ + ...config, + apiKeyId: config.apiKeyId || fallbackKeyId, + apiUrl: config.apiUrl || "", + requestTemplate: config.requestTemplate || "", + advanced: Boolean(config.advanced) + })); + const needsUpdate = normalizedConfigs.some((config, index) => { + const original = stored.apiConfigs[index]; + return ( + config.apiKeyId !== original.apiKeyId || + (config.apiUrl || "") !== (original.apiUrl || "") || + (config.requestTemplate || "") !== (original.requestTemplate || "") || + Boolean(config.advanced) !== Boolean(original.advanced) + ); + }); + if (needsUpdate) { + updates.apiConfigs = normalizedConfigs; + } } if (Object.keys(updates).length) { @@ -188,6 +283,9 @@ async function handleAnalysisRequest(port, payload, signal) { const { apiKey, + apiMode, + apiUrl, + requestTemplate, apiBaseUrl, apiKeyHeader, apiKeyPrefix, @@ -199,19 +297,31 @@ async function handleAnalysisRequest(port, payload, signal) { tabId } = payload || {}; - if (!apiBaseUrl) { - safePost(port, { type: "ERROR", message: "Missing API base URL." }); - return; - } + const isAdvanced = apiMode === "advanced"; + if (isAdvanced) { + if (!apiUrl) { + safePost(port, { type: "ERROR", message: "Missing API URL." }); + return; + } + if (!requestTemplate) { + safePost(port, { type: "ERROR", message: "Missing request template." }); + return; + } + } else { + if (!apiBaseUrl) { + safePost(port, { type: "ERROR", message: "Missing API base URL." }); + return; + } - if (apiKeyHeader && !apiKey) { - safePost(port, { type: "ERROR", message: "Missing API key." }); - return; - } + if (apiKeyHeader && !apiKey) { + safePost(port, { type: "ERROR", message: "Missing API key." }); + return; + } - if (!model) { - safePost(port, { type: "ERROR", message: "Missing model name." }); - return; + if (!model) { + safePost(port, { type: "ERROR", message: "Missing model name." }); + return; + } } if (!postingText) { @@ -230,20 +340,35 @@ async function handleAnalysisRequest(port, payload, signal) { openKeepalive(tabId); try { - await streamChatCompletion({ - apiKey, - apiBaseUrl, - apiKeyHeader, - apiKeyPrefix, - model, - systemPrompt: systemPrompt || "", - userMessage, - signal, - onDelta: (text) => { - streamState.outputText += text; - broadcast({ type: "DELTA", text }); - } - }); + if (isAdvanced) { + await streamCustomCompletion({ + apiKey, + apiUrl, + requestTemplate, + systemPrompt: systemPrompt || "", + userMessage, + signal, + onDelta: (text) => { + streamState.outputText += text; + broadcast({ type: "DELTA", text }); + } + }); + } else { + await streamChatCompletion({ + apiKey, + apiBaseUrl, + apiKeyHeader, + apiKeyPrefix, + model, + systemPrompt: systemPrompt || "", + userMessage, + signal, + onDelta: (text) => { + streamState.outputText += text; + broadcast({ type: "DELTA", text }); + } + }); + } broadcast({ type: "DONE" }); } finally { @@ -268,6 +393,73 @@ function buildAuthHeader(apiKeyHeader, apiKeyPrefix, apiKey) { }; } +function replaceQuotedToken(template, token, value) { + const quoted = `"${token}"`; + const jsonValue = JSON.stringify(value ?? ""); + return template.split(quoted).join(jsonValue); +} + +function replaceTemplateTokens(template, replacements) { + let output = template || ""; + for (const [token, value] of Object.entries(replacements)) { + output = replaceQuotedToken(output, token, value ?? ""); + output = output.split(token).join(value ?? ""); + } + return output; +} + +function replaceUrlTokens(url, replacements) { + let output = url || ""; + for (const [token, value] of Object.entries(replacements)) { + output = output.split(token).join(encodeURIComponent(value ?? "")); + } + return output; +} + +function buildTemplateBody(template, replacements) { + const filled = replaceTemplateTokens(template, replacements); + try { + return JSON.parse(filled); + } catch { + throw new Error("Invalid request template JSON."); + } +} + +async function readSseStream(response, onDelta) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + // OpenAI-compatible SSE stream; parse incremental deltas from data lines. + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith("data:")) continue; + + const data = trimmed.slice(5).trim(); + if (!data) continue; + if (data === "[DONE]") return; + + let parsed; + try { + parsed = JSON.parse(data); + } catch { + continue; + } + + const delta = parsed?.choices?.[0]?.delta?.content; + if (delta) onDelta(delta); + } + } +} + async function streamChatCompletion({ apiKey, apiBaseUrl, @@ -309,39 +501,42 @@ async function streamChatCompletion({ if (!response.ok) { const errorText = await response.text(); - throw new Error(`OpenAI API error ${response.status}: ${errorText}`); + throw new Error(`API error ${response.status}: ${errorText}`); } - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - // OpenAI streams Server-Sent Events; parse incremental deltas from data lines. - while (true) { - const { value, done } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed.startsWith("data:")) continue; - - const data = trimmed.slice(5).trim(); - if (!data) continue; - if (data === "[DONE]") return; - - let parsed; - try { - parsed = JSON.parse(data); - } catch { - continue; - } - - const delta = parsed?.choices?.[0]?.delta?.content; - if (delta) onDelta(delta); - } - } + await readSseStream(response, onDelta); +} + +async function streamCustomCompletion({ + apiKey, + apiUrl, + requestTemplate, + systemPrompt, + userMessage, + signal, + onDelta +}) { + const replacements = { + PROMPT_GOES_HERE: userMessage, + SYSTEM_PROMPT_GOES_HERE: systemPrompt, + API_KEY_GOES_HERE: apiKey + }; + const resolvedUrl = replaceUrlTokens(apiUrl, replacements); + const body = buildTemplateBody(requestTemplate, replacements); + + const response = await fetch(resolvedUrl, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(body), + signal + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`API error ${response.status}: ${errorText}`); + } + + await readSseStream(response, onDelta); } diff --git a/wwcompanion-extension/popup.js b/wwcompanion-extension/popup.js index e251a6b..3be90d0 100644 --- a/wwcompanion-extension/popup.js +++ b/wwcompanion-extension/popup.js @@ -431,6 +431,8 @@ async function handleAnalyze() { const { apiKeys = [], activeApiKeyId = "", + apiConfigs = [], + activeApiConfigId = "", apiBaseUrl, apiKeyHeader, apiKeyPrefix, @@ -440,6 +442,8 @@ async function handleAnalyze() { } = await getStorage([ "apiKeys", "activeApiKeyId", + "apiConfigs", + "activeApiConfigId", "apiBaseUrl", "apiKeyHeader", "apiKeyPrefix", @@ -448,24 +452,56 @@ async function handleAnalyze() { "resume" ]); - if (!apiBaseUrl) { - setStatus("Set an API base URL in Settings."); - return; - } + const resolvedConfigs = Array.isArray(apiConfigs) ? apiConfigs : []; + const activeConfig = + resolvedConfigs.find((entry) => entry.id === activeApiConfigId) || + resolvedConfigs[0]; + const isAdvanced = Boolean(activeConfig?.advanced); + const resolvedApiUrl = activeConfig?.apiUrl || ""; + const resolvedTemplate = activeConfig?.requestTemplate || ""; + const resolvedApiBaseUrl = isAdvanced + ? "" + : activeConfig?.apiBaseUrl || apiBaseUrl || ""; + const resolvedApiKeyHeader = isAdvanced + ? "" + : activeConfig?.apiKeyHeader ?? apiKeyHeader ?? ""; + const resolvedApiKeyPrefix = isAdvanced + ? "" + : activeConfig?.apiKeyPrefix ?? apiKeyPrefix ?? ""; + const resolvedModel = isAdvanced ? "" : activeConfig?.model || model || ""; const resolvedKeys = Array.isArray(apiKeys) ? apiKeys : []; - const activeKey = - resolvedKeys.find((entry) => entry.id === activeApiKeyId) || resolvedKeys[0]; + const resolvedKeyId = + activeConfig?.apiKeyId || activeApiKeyId || resolvedKeys[0]?.id || ""; + const activeKey = resolvedKeys.find((entry) => entry.id === resolvedKeyId); const apiKey = activeKey?.key || ""; - if (apiKeyHeader && !apiKey) { - setStatus("Add an API key in Settings."); - return; - } - - if (!model) { - setStatus("Set a model name in Settings."); - return; + if (isAdvanced) { + if (!resolvedApiUrl) { + setStatus("Set an API URL in Settings."); + return; + } + if (!resolvedTemplate) { + setStatus("Set a request template in Settings."); + return; + } + if (resolvedTemplate.includes("API_KEY_GOES_HERE") && !apiKey) { + setStatus("Add an API key in Settings."); + return; + } + } else { + if (!resolvedApiBaseUrl) { + setStatus("Set an API base URL in Settings."); + return; + } + if (resolvedApiKeyHeader && !apiKey) { + setStatus("Add an API key in Settings."); + return; + } + if (!resolvedModel) { + setStatus("Set a model name in Settings."); + return; + } } const promptText = buildUserMessage(resume || "", task.text || "", state.postingText); @@ -481,10 +517,13 @@ async function handleAnalyze() { type: "START_ANALYSIS", payload: { apiKey, - apiBaseUrl, - apiKeyHeader, - apiKeyPrefix, - model, + apiMode: isAdvanced ? "advanced" : "basic", + apiUrl: resolvedApiUrl, + requestTemplate: resolvedTemplate, + apiBaseUrl: resolvedApiBaseUrl, + apiKeyHeader: resolvedApiKeyHeader, + apiKeyPrefix: resolvedApiKeyPrefix, + model: resolvedModel, systemPrompt: systemPrompt || "", resume: resume || "", taskText: task.text || "", diff --git a/wwcompanion-extension/settings.css b/wwcompanion-extension/settings.css index 27bcc07..2326294 100644 --- a/wwcompanion-extension/settings.css +++ b/wwcompanion-extension/settings.css @@ -124,6 +124,11 @@ body { margin-bottom: 12px; } +.row-actions { + display: flex; + gap: 8px; +} + .row-title { display: flex; align-items: baseline; @@ -249,8 +254,40 @@ button:active { justify-content: flex-end; } -.api-key-actions .delete { - color: #c0392b; +.api-key-actions .delete, +.api-config-actions .delete, +.task-actions .delete { + background: #c0392b; + border-color: #c0392b; + color: #fff6f2; +} + +.api-configs { + display: grid; + gap: 12px; +} + +.api-config-card { + padding: 12px; + border-radius: 12px; + border: 1px solid var(--border); + background: var(--card-bg); + display: grid; + gap: 8px; +} + +.api-config-card.is-advanced .basic-only { + display: none; +} + +.api-config-card:not(.is-advanced) .advanced-only { + display: none; +} + +.api-config-actions { + display: flex; + gap: 8px; + justify-content: flex-end; } @media (prefers-color-scheme: dark) { @@ -275,7 +312,3 @@ button:active { gap: 6px; justify-content: flex-end; } - -.task-actions .delete { - color: #c0392b; -} diff --git a/wwcompanion-extension/settings.html b/wwcompanion-extension/settings.html index 86babd3..9c3d605 100644 --- a/wwcompanion-extension/settings.html +++ b/wwcompanion-extension/settings.html @@ -27,32 +27,15 @@
- +
+ +
- - -
-
- - -
-
- - -
-
- - -
-
- - + +
+
@@ -62,7 +45,7 @@ -

API Keys

+

API KEYS

diff --git a/wwcompanion-extension/settings.js b/wwcompanion-extension/settings.js index 16d5da2..0983954 100644 --- a/wwcompanion-extension/settings.js +++ b/wwcompanion-extension/settings.js @@ -1,24 +1,22 @@ -const apiBaseUrlInput = document.getElementById("apiBaseUrl"); -const apiKeyHeaderInput = document.getElementById("apiKeyHeader"); -const apiKeyPrefixInput = document.getElementById("apiKeyPrefix"); -const modelInput = document.getElementById("model"); const systemPromptInput = document.getElementById("systemPrompt"); const resumeInput = document.getElementById("resume"); const saveBtn = document.getElementById("saveBtn"); +const addApiConfigBtn = document.getElementById("addApiConfigBtn"); +const apiConfigsContainer = document.getElementById("apiConfigs"); +const activeApiConfigSelect = document.getElementById("activeApiConfigSelect"); const addApiKeyBtn = document.getElementById("addApiKeyBtn"); const apiKeysContainer = document.getElementById("apiKeys"); -const activeApiKeySelect = document.getElementById("activeApiKeySelect"); const addTaskBtn = document.getElementById("addTaskBtn"); const tasksContainer = document.getElementById("tasks"); const statusEl = document.getElementById("status"); const themeSelect = document.getElementById("themeSelect"); -const resetApiBtn = document.getElementById("resetApiBtn"); const OPENAI_DEFAULTS = { apiBaseUrl: "https://api.openai.com/v1", apiKeyHeader: "Authorization", apiKeyPrefix: "Bearer " }; +const DEFAULT_MODEL = "gpt-4o-mini"; function getStorage(keys) { return new Promise((resolve) => chrome.storage.local.get(keys, resolve)); @@ -47,6 +45,308 @@ function newApiKeyId() { return `key-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; } +function newApiConfigId() { + if (crypto?.randomUUID) return crypto.randomUUID(); + return `config-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; +} + +function collectNames(container, selector) { + if (!container) return []; + return [...container.querySelectorAll(selector)] + .map((input) => (input.value || "").trim()) + .filter(Boolean); +} + +function buildUniqueDefaultName(names) { + const lower = new Set(names.map((name) => name.toLowerCase())); + if (!lower.has("default")) return "Default"; + let index = 2; + while (lower.has(`default-${index}`)) { + index += 1; + } + return `Default-${index}`; +} + +function ensureUniqueName(desired, existingNames) { + const trimmed = (desired || "").trim(); + const lowerNames = existingNames.map((name) => name.toLowerCase()); + if (trimmed && !lowerNames.includes(trimmed.toLowerCase())) { + return trimmed; + } + return buildUniqueDefaultName(existingNames); +} + +function setApiConfigAdvanced(card, isAdvanced) { + card.classList.toggle("is-advanced", isAdvanced); + card.dataset.mode = isAdvanced ? "advanced" : "basic"; + + const basicFields = card.querySelectorAll( + ".basic-only input, .basic-only textarea" + ); + const advancedFields = card.querySelectorAll( + ".advanced-only input, .advanced-only textarea" + ); + basicFields.forEach((field) => { + field.disabled = isAdvanced; + }); + advancedFields.forEach((field) => { + field.disabled = !isAdvanced; + }); + + const resetBtn = card.querySelector(".reset-openai"); + if (resetBtn) resetBtn.disabled = isAdvanced; + + const advancedBtn = card.querySelector(".advanced-toggle"); + if (advancedBtn && isAdvanced) advancedBtn.remove(); +} + +function readApiConfigFromCard(card) { + const nameInput = card.querySelector(".api-config-name"); + const keySelect = card.querySelector(".api-config-key-select"); + const baseInput = card.querySelector(".api-config-base"); + const headerInput = card.querySelector(".api-config-header"); + const prefixInput = card.querySelector(".api-config-prefix"); + const modelInput = card.querySelector(".api-config-model"); + const urlInput = card.querySelector(".api-config-url"); + const templateInput = card.querySelector(".api-config-template"); + const isAdvanced = card.classList.contains("is-advanced"); + + return { + id: card.dataset.id || newApiConfigId(), + name: (nameInput?.value || "Default").trim(), + apiKeyId: keySelect?.value || "", + apiBaseUrl: (baseInput?.value || "").trim(), + apiKeyHeader: (headerInput?.value || "").trim(), + apiKeyPrefix: prefixInput?.value || "", + model: (modelInput?.value || "").trim(), + apiUrl: (urlInput?.value || "").trim(), + requestTemplate: (templateInput?.value || "").trim(), + advanced: isAdvanced + }; +} + +function buildApiConfigCard(config) { + const card = document.createElement("div"); + card.className = "api-config-card"; + card.dataset.id = config.id || newApiConfigId(); + const isAdvanced = Boolean(config.advanced); + + const nameField = document.createElement("div"); + nameField.className = "field"; + const nameLabel = document.createElement("label"); + nameLabel.textContent = "Name"; + const nameInput = document.createElement("input"); + nameInput.type = "text"; + nameInput.value = config.name || ""; + nameInput.className = "api-config-name"; + nameField.appendChild(nameLabel); + nameField.appendChild(nameInput); + + const keyField = document.createElement("div"); + keyField.className = "field"; + const keyLabel = document.createElement("label"); + keyLabel.textContent = "API Key"; + const keySelect = document.createElement("select"); + keySelect.className = "api-config-key-select"; + keySelect.dataset.preferred = config.apiKeyId || ""; + keyField.appendChild(keyLabel); + keyField.appendChild(keySelect); + + const baseField = document.createElement("div"); + baseField.className = "field basic-only"; + const baseLabel = document.createElement("label"); + baseLabel.textContent = "API Base URL"; + const baseInput = document.createElement("input"); + baseInput.type = "text"; + baseInput.placeholder = OPENAI_DEFAULTS.apiBaseUrl; + baseInput.value = config.apiBaseUrl || ""; + baseInput.className = "api-config-base"; + baseField.appendChild(baseLabel); + baseField.appendChild(baseInput); + + const headerField = document.createElement("div"); + headerField.className = "field basic-only"; + const headerLabel = document.createElement("label"); + headerLabel.textContent = "API Key Header"; + const headerInput = document.createElement("input"); + headerInput.type = "text"; + headerInput.placeholder = OPENAI_DEFAULTS.apiKeyHeader; + headerInput.value = config.apiKeyHeader || ""; + headerInput.className = "api-config-header"; + headerField.appendChild(headerLabel); + headerField.appendChild(headerInput); + + const prefixField = document.createElement("div"); + prefixField.className = "field basic-only"; + const prefixLabel = document.createElement("label"); + prefixLabel.textContent = "API Key Prefix"; + const prefixInput = document.createElement("input"); + prefixInput.type = "text"; + prefixInput.placeholder = OPENAI_DEFAULTS.apiKeyPrefix; + prefixInput.value = config.apiKeyPrefix || ""; + prefixInput.className = "api-config-prefix"; + prefixField.appendChild(prefixLabel); + prefixField.appendChild(prefixInput); + + const modelField = document.createElement("div"); + modelField.className = "field basic-only"; + const modelLabel = document.createElement("label"); + modelLabel.textContent = "Model name"; + const modelInput = document.createElement("input"); + modelInput.type = "text"; + modelInput.placeholder = DEFAULT_MODEL; + modelInput.value = config.model || ""; + modelInput.className = "api-config-model"; + modelField.appendChild(modelLabel); + modelField.appendChild(modelInput); + + const urlField = document.createElement("div"); + urlField.className = "field advanced-only"; + const urlLabel = document.createElement("label"); + urlLabel.textContent = "API URL"; + const urlInput = document.createElement("input"); + urlInput.type = "text"; + urlInput.placeholder = "https://api.example.com/v1/chat/completions"; + urlInput.value = config.apiUrl || ""; + urlInput.className = "api-config-url"; + urlField.appendChild(urlLabel); + urlField.appendChild(urlInput); + + const templateField = document.createElement("div"); + templateField.className = "field advanced-only"; + const templateLabel = document.createElement("label"); + templateLabel.textContent = "Request JSON template"; + const templateInput = document.createElement("textarea"); + templateInput.rows = 8; + templateInput.placeholder = [ + "{", + " \"stream\": true,", + " \"messages\": [", + " { \"role\": \"system\", \"content\": \"SYSTEM_PROMPT_GOES_HERE\" },", + " { \"role\": \"user\", \"content\": \"PROMPT_GOES_HERE\" }", + " ],", + " \"api_key\": \"API_KEY_GOES_HERE\"", + "}" + ].join("\n"); + templateInput.value = config.requestTemplate || ""; + templateInput.className = "api-config-template"; + templateField.appendChild(templateLabel); + templateField.appendChild(templateInput); + + const actions = document.createElement("div"); + actions.className = "api-config-actions"; + if (!isAdvanced) { + const advancedBtn = document.createElement("button"); + advancedBtn.type = "button"; + advancedBtn.className = "ghost advanced-toggle"; + advancedBtn.textContent = "Advanced Mode"; + advancedBtn.addEventListener("click", () => { + setApiConfigAdvanced(card, true); + updateApiConfigSelect(activeApiConfigSelect.value); + }); + actions.appendChild(advancedBtn); + } + + const duplicateBtn = document.createElement("button"); + duplicateBtn.type = "button"; + duplicateBtn.className = "ghost duplicate"; + duplicateBtn.textContent = "Duplicate"; + duplicateBtn.addEventListener("click", () => { + const names = collectNames(apiConfigsContainer, ".api-config-name"); + const copy = readApiConfigFromCard(card); + copy.id = newApiConfigId(); + copy.name = ensureUniqueName(`${copy.name || "Default"} Copy`, names); + const newCard = buildApiConfigCard(copy); + card.insertAdjacentElement("afterend", newCard); + updateApiConfigKeyOptions(); + updateApiConfigSelect(newCard.dataset.id); + }); + actions.appendChild(duplicateBtn); + + const resetBtn = document.createElement("button"); + resetBtn.type = "button"; + resetBtn.className = "ghost reset-openai"; + resetBtn.textContent = "Reset to OpenAI"; + resetBtn.addEventListener("click", () => { + if (card.classList.contains("is-advanced")) { + setStatus("Advanced mode cannot be reset to OpenAI."); + return; + } + baseInput.value = OPENAI_DEFAULTS.apiBaseUrl; + headerInput.value = OPENAI_DEFAULTS.apiKeyHeader; + prefixInput.value = OPENAI_DEFAULTS.apiKeyPrefix; + updateApiConfigSelect(activeApiConfigSelect.value); + }); + actions.appendChild(resetBtn); + + const deleteBtn = document.createElement("button"); + deleteBtn.type = "button"; + deleteBtn.className = "ghost delete"; + deleteBtn.textContent = "Delete"; + deleteBtn.addEventListener("click", () => { + card.remove(); + updateApiConfigSelect(activeApiConfigSelect.value); + }); + actions.appendChild(deleteBtn); + + const updateSelect = () => updateApiConfigSelect(activeApiConfigSelect.value); + nameInput.addEventListener("input", updateSelect); + baseInput.addEventListener("input", updateSelect); + headerInput.addEventListener("input", updateSelect); + prefixInput.addEventListener("input", updateSelect); + modelInput.addEventListener("input", updateSelect); + urlInput.addEventListener("input", updateSelect); + templateInput.addEventListener("input", updateSelect); + + card.appendChild(nameField); + card.appendChild(keyField); + card.appendChild(baseField); + card.appendChild(headerField); + card.appendChild(prefixField); + card.appendChild(modelField); + card.appendChild(urlField); + card.appendChild(templateField); + card.appendChild(actions); + + setApiConfigAdvanced(card, isAdvanced); + + return card; +} + +function collectApiConfigs() { + const cards = [...apiConfigsContainer.querySelectorAll(".api-config-card")]; + return cards.map((card) => readApiConfigFromCard(card)); +} + +function updateApiConfigSelect(preferredId) { + const configs = collectApiConfigs(); + activeApiConfigSelect.innerHTML = ""; + + if (!configs.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "No configs configured"; + activeApiConfigSelect.appendChild(option); + activeApiConfigSelect.disabled = true; + return; + } + + activeApiConfigSelect.disabled = false; + const selectedId = + preferredId && configs.some((config) => config.id === preferredId) + ? preferredId + : configs[0].id; + + for (const config of configs) { + const option = document.createElement("option"); + option.value = config.id; + option.textContent = config.name || "Default"; + activeApiConfigSelect.appendChild(option); + } + + activeApiConfigSelect.value = selectedId; +} + function buildApiKeyCard(entry) { const card = document.createElement("div"); card.className = "api-key-card"; @@ -97,11 +397,11 @@ function buildApiKeyCard(entry) { deleteBtn.textContent = "Delete"; deleteBtn.addEventListener("click", () => { card.remove(); - updateApiKeySelect(); + updateApiConfigKeyOptions(); }); actions.appendChild(deleteBtn); - const updateSelect = () => updateApiKeySelect(activeApiKeySelect.value); + const updateSelect = () => updateApiConfigKeyOptions(); nameInput.addEventListener("input", updateSelect); keyInput.addEventListener("input", updateSelect); @@ -125,33 +425,37 @@ function collectApiKeys() { }); } -function updateApiKeySelect(preferredId) { +function updateApiConfigKeyOptions() { const keys = collectApiKeys(); - activeApiKeySelect.innerHTML = ""; + const selects = apiConfigsContainer.querySelectorAll(".api-config-key-select"); + selects.forEach((select) => { + const preferred = select.dataset.preferred || select.value; + select.innerHTML = ""; + if (!keys.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "No keys configured"; + select.appendChild(option); + select.disabled = true; + return; + } - if (!keys.length) { - const option = document.createElement("option"); - option.value = ""; - option.textContent = "No keys configured"; - activeApiKeySelect.appendChild(option); - activeApiKeySelect.disabled = true; - return; - } + select.disabled = false; + for (const key of keys) { + const option = document.createElement("option"); + option.value = key.id; + option.textContent = key.name || "Default"; + select.appendChild(option); + } - activeApiKeySelect.disabled = false; - const selectedId = - preferredId && keys.some((key) => key.id === preferredId) - ? preferredId - : keys[0].id; + if (preferred && keys.some((key) => key.id === preferred)) { + select.value = preferred; + } else { + select.value = keys[0].id; + } - for (const key of keys) { - const option = document.createElement("option"); - option.value = key.id; - option.textContent = key.name || "Default"; - activeApiKeySelect.appendChild(option); - } - - activeApiKeySelect.value = selectedId; + select.dataset.preferred = select.value; + }); } function buildTaskCard(task) { @@ -242,7 +546,10 @@ function buildTaskCard(task) { }); addBelowBtn.addEventListener("click", () => { - const newCard = buildTaskCard({ id: newTaskId(), name: "", text: "" }); + const name = buildUniqueDefaultName( + collectNames(tasksContainer, ".task-name") + ); + const newCard = buildTaskCard({ id: newTaskId(), name, text: "" }); card.insertAdjacentElement("afterend", newCard); updateTaskControls(); }); @@ -250,7 +557,10 @@ function buildTaskCard(task) { duplicateBtn.addEventListener("click", () => { const copy = { id: newTaskId(), - name: `${nameInput.value || "Untitled"} Copy`, + name: ensureUniqueName( + `${nameInput.value || "Untitled"} Copy`, + collectNames(tasksContainer, ".task-name") + ), text: textArea.value }; const newCard = buildTaskCard(copy); @@ -307,6 +617,8 @@ async function loadSettings() { apiKey = "", apiKeys = [], activeApiKeyId = "", + apiConfigs = [], + activeApiConfigId = "", apiBaseUrl = "", apiKeyHeader = "", apiKeyPrefix = "", @@ -319,6 +631,8 @@ async function loadSettings() { "apiKey", "apiKeys", "activeApiKeyId", + "apiConfigs", + "activeApiConfigId", "apiBaseUrl", "apiKeyHeader", "apiKeyPrefix", @@ -329,10 +643,6 @@ async function loadSettings() { "theme" ]); - apiBaseUrlInput.value = apiBaseUrl; - apiKeyHeaderInput.value = apiKeyHeader; - apiKeyPrefixInput.value = apiKeyPrefix; - modelInput.value = model; systemPromptInput.value = systemPrompt; resumeInput.value = resume; themeSelect.value = theme; @@ -349,17 +659,75 @@ async function loadSettings() { apiKeys: resolvedKeys, activeApiKeyId: resolvedActiveId }); + } else if (resolvedKeys.length) { + const hasActive = resolvedKeys.some((entry) => entry.id === resolvedActiveId); + if (!hasActive) { + resolvedActiveId = resolvedKeys[0].id; + await chrome.storage.local.set({ activeApiKeyId: resolvedActiveId }); + } } apiKeysContainer.innerHTML = ""; if (!resolvedKeys.length) { - apiKeysContainer.appendChild(buildApiKeyCard({ id: newApiKeyId(), name: "", key: "" })); + apiKeysContainer.appendChild( + buildApiKeyCard({ id: newApiKeyId(), name: "", key: "" }) + ); } else { for (const entry of resolvedKeys) { apiKeysContainer.appendChild(buildApiKeyCard(entry)); } } - updateApiKeySelect(resolvedActiveId); + + let resolvedConfigs = Array.isArray(apiConfigs) ? apiConfigs : []; + let resolvedActiveConfigId = activeApiConfigId; + + if (!resolvedConfigs.length) { + const migrated = { + id: newApiConfigId(), + name: "Default", + apiBaseUrl: apiBaseUrl || OPENAI_DEFAULTS.apiBaseUrl, + apiKeyHeader: apiKeyHeader || OPENAI_DEFAULTS.apiKeyHeader, + apiKeyPrefix: apiKeyPrefix || OPENAI_DEFAULTS.apiKeyPrefix, + model: model || DEFAULT_MODEL, + apiKeyId: resolvedActiveId || resolvedKeys[0]?.id || "", + apiUrl: "", + requestTemplate: "", + advanced: false + }; + resolvedConfigs = [migrated]; + resolvedActiveConfigId = migrated.id; + await chrome.storage.local.set({ + apiConfigs: resolvedConfigs, + activeApiConfigId: resolvedActiveConfigId + }); + } else { + const fallbackKeyId = resolvedActiveId || resolvedKeys[0]?.id || ""; + const withKeys = resolvedConfigs.map((config) => ({ + ...config, + apiKeyId: config.apiKeyId || fallbackKeyId, + apiUrl: config.apiUrl || "", + requestTemplate: config.requestTemplate || "", + advanced: Boolean(config.advanced) + })); + if (withKeys.some((config, index) => config.apiKeyId !== resolvedConfigs[index].apiKeyId)) { + resolvedConfigs = withKeys; + await chrome.storage.local.set({ apiConfigs: resolvedConfigs }); + } + const hasActive = resolvedConfigs.some( + (config) => config.id === resolvedActiveConfigId + ); + if (!hasActive) { + resolvedActiveConfigId = resolvedConfigs[0].id; + await chrome.storage.local.set({ activeApiConfigId: resolvedActiveConfigId }); + } + } + + apiConfigsContainer.innerHTML = ""; + for (const config of resolvedConfigs) { + apiConfigsContainer.appendChild(buildApiConfigCard(config)); + } + updateApiConfigKeyOptions(); + updateApiConfigSelect(resolvedActiveConfigId); tasksContainer.innerHTML = ""; if (!tasks.length) { @@ -379,17 +747,21 @@ async function loadSettings() { async function saveSettings() { const tasks = collectTasks(); const apiKeys = collectApiKeys(); + const apiConfigs = collectApiConfigs(); + const activeApiConfigId = + apiConfigs.find((entry) => entry.id === activeApiConfigSelect.value)?.id || + apiConfigs[0]?.id || + ""; + const activeConfig = apiConfigs.find((entry) => entry.id === activeApiConfigId); const activeApiKeyId = - apiKeys.find((entry) => entry.id === activeApiKeySelect.value)?.id || + activeConfig?.apiKeyId || apiKeys[0]?.id || ""; await chrome.storage.local.set({ - apiBaseUrl: apiBaseUrlInput.value.trim(), - apiKeyHeader: apiKeyHeaderInput.value.trim(), - apiKeyPrefix: apiKeyPrefixInput.value, apiKeys, activeApiKeyId, - model: modelInput.value.trim(), + apiConfigs, + activeApiConfigId, systemPrompt: systemPromptInput.value, resume: resumeInput.value, tasks, @@ -400,7 +772,10 @@ async function saveSettings() { saveBtn.addEventListener("click", () => void saveSettings()); addTaskBtn.addEventListener("click", () => { - const newCard = buildTaskCard({ id: newTaskId(), name: "", text: "" }); + const name = buildUniqueDefaultName( + collectNames(tasksContainer, ".task-name") + ); + const newCard = buildTaskCard({ id: newTaskId(), name, text: "" }); const first = tasksContainer.firstElementChild; if (first) { tasksContainer.insertBefore(newCard, first); @@ -411,31 +786,49 @@ addTaskBtn.addEventListener("click", () => { }); addApiKeyBtn.addEventListener("click", () => { - const newCard = buildApiKeyCard({ id: newApiKeyId(), name: "", key: "" }); + const name = buildUniqueDefaultName( + collectNames(apiKeysContainer, ".api-key-name") + ); + const newCard = buildApiKeyCard({ id: newApiKeyId(), name, key: "" }); const first = apiKeysContainer.firstElementChild; if (first) { apiKeysContainer.insertBefore(newCard, first); } else { apiKeysContainer.appendChild(newCard); } - updateApiKeySelect(activeApiKeySelect.value); + updateApiConfigKeyOptions(); }); -activeApiKeySelect.addEventListener("change", () => { - updateApiKeySelect(activeApiKeySelect.value); +addApiConfigBtn.addEventListener("click", () => { + const name = buildUniqueDefaultName( + collectNames(apiConfigsContainer, ".api-config-name") + ); + const newCard = buildApiConfigCard({ + id: newApiConfigId(), + name, + apiBaseUrl: OPENAI_DEFAULTS.apiBaseUrl, + apiKeyHeader: OPENAI_DEFAULTS.apiKeyHeader, + apiKeyPrefix: OPENAI_DEFAULTS.apiKeyPrefix, + model: DEFAULT_MODEL, + apiUrl: "", + requestTemplate: "", + advanced: false + }); + const first = apiConfigsContainer.firstElementChild; + if (first) { + apiConfigsContainer.insertBefore(newCard, first); + } else { + apiConfigsContainer.appendChild(newCard); + } + updateApiConfigKeyOptions(); + updateApiConfigSelect(activeApiConfigSelect.value); +}); + +activeApiConfigSelect.addEventListener("change", () => { + updateApiConfigSelect(activeApiConfigSelect.value); }); themeSelect.addEventListener("change", () => applyTheme(themeSelect.value)); -resetApiBtn.addEventListener("click", async () => { - apiBaseUrlInput.value = OPENAI_DEFAULTS.apiBaseUrl; - apiKeyHeaderInput.value = OPENAI_DEFAULTS.apiKeyHeader; - apiKeyPrefixInput.value = OPENAI_DEFAULTS.apiKeyPrefix; - await chrome.storage.local.set({ - apiBaseUrl: OPENAI_DEFAULTS.apiBaseUrl, - apiKeyHeader: OPENAI_DEFAULTS.apiKeyHeader, - apiKeyPrefix: OPENAI_DEFAULTS.apiKeyPrefix - }); - setStatus("OpenAI defaults restored."); -}); +themeSelect.addEventListener("change", () => applyTheme(themeSelect.value)); loadSettings();