From 3bb350f3cfdd84a5fe2a1ccd504e79be6d421b5d Mon Sep 17 00:00:00 2001 From: Peisong Xiao Date: Sat, 17 Jan 2026 16:12:41 -0500 Subject: [PATCH 1/5] Added multi-key support --- wwcompanion-extension/background.js | 13 +++ wwcompanion-extension/manifest.json | 2 +- wwcompanion-extension/popup.js | 36 ++++-- wwcompanion-extension/settings.css | 24 ++++ wwcompanion-extension/settings.html | 24 +++- wwcompanion-extension/settings.js | 170 ++++++++++++++++++++++++++-- 6 files changed, 244 insertions(+), 25 deletions(-) diff --git a/wwcompanion-extension/background.js b/wwcompanion-extension/background.js index fb8b610..a76ab6e 100644 --- a/wwcompanion-extension/background.js +++ b/wwcompanion-extension/background.js @@ -15,6 +15,8 @@ const DEFAULT_TASKS = [ const DEFAULT_SETTINGS = { apiKey: "", + apiKeys: [], + activeApiKeyId: "", apiBaseUrl: "https://api.openai.com/v1", apiKeyHeader: "Authorization", apiKeyPrefix: "Bearer ", @@ -80,6 +82,17 @@ chrome.runtime.onInstalled.addListener(async () => { if (missing) updates[key] = value; } + const hasApiKeys = + Array.isArray(stored.apiKeys) && stored.apiKeys.length > 0; + + if (!hasApiKeys && stored.apiKey) { + const id = crypto?.randomUUID + ? crypto.randomUUID() + : `key-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + updates.apiKeys = [{ id, name: "Default", key: stored.apiKey }]; + updates.activeApiKeyId = id; + } + if (Object.keys(updates).length) { await chrome.storage.local.set(updates); } diff --git a/wwcompanion-extension/manifest.json b/wwcompanion-extension/manifest.json index c0fd612..ad3f641 100644 --- a/wwcompanion-extension/manifest.json +++ b/wwcompanion-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "WWCompanion", - "version": "0.2.3", + "version": "0.2.4", "description": "AI companion for WaterlooWorks job postings.", "permissions": ["storage", "activeTab"], "host_permissions": ["https://waterlooworks.uwaterloo.ca/*"], diff --git a/wwcompanion-extension/popup.js b/wwcompanion-extension/popup.js index 293a044..e251a6b 100644 --- a/wwcompanion-extension/popup.js +++ b/wwcompanion-extension/popup.js @@ -428,24 +428,38 @@ async function handleAnalyze() { return; } - const { apiKey, apiBaseUrl, apiKeyHeader, apiKeyPrefix, model, systemPrompt, resume } = - await getStorage([ - "apiKey", - "apiBaseUrl", - "apiKeyHeader", - "apiKeyPrefix", - "model", - "systemPrompt", - "resume" - ]); + const { + apiKeys = [], + activeApiKeyId = "", + apiBaseUrl, + apiKeyHeader, + apiKeyPrefix, + model, + systemPrompt, + resume + } = await getStorage([ + "apiKeys", + "activeApiKeyId", + "apiBaseUrl", + "apiKeyHeader", + "apiKeyPrefix", + "model", + "systemPrompt", + "resume" + ]); if (!apiBaseUrl) { setStatus("Set an API base URL in Settings."); return; } + const resolvedKeys = Array.isArray(apiKeys) ? apiKeys : []; + const activeKey = + resolvedKeys.find((entry) => entry.id === activeApiKeyId) || resolvedKeys[0]; + const apiKey = activeKey?.key || ""; + if (apiKeyHeader && !apiKey) { - setStatus("Add your API key in Settings."); + setStatus("Add an API key in Settings."); return; } diff --git a/wwcompanion-extension/settings.css b/wwcompanion-extension/settings.css index 3d0c33d..27bcc07 100644 --- a/wwcompanion-extension/settings.css +++ b/wwcompanion-extension/settings.css @@ -229,6 +229,30 @@ button:active { gap: 8px; } +.api-keys { + display: grid; + gap: 12px; +} + +.api-key-card { + padding: 12px; + border-radius: 12px; + border: 1px solid var(--border); + background: var(--card-bg); + display: grid; + gap: 8px; +} + +.api-key-actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.api-key-actions .delete { + color: #c0392b; +} + @media (prefers-color-scheme: dark) { :root:not([data-theme]), :root[data-theme="system"] { diff --git a/wwcompanion-extension/settings.html b/wwcompanion-extension/settings.html index 4a144af..86babd3 100644 --- a/wwcompanion-extension/settings.html +++ b/wwcompanion-extension/settings.html @@ -30,11 +30,8 @@
- -
- - -
+ +
@@ -59,6 +56,23 @@
+
+ + +

API Keys

+
+
+
+
+ +
+
+
+
+
@@ -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(); -- 2.34.1 From e3c7cbba95db1dbcbbc876ce5f6dc7a7aa324a53 Mon Sep 17 00:00:00 2001 From: Peisong Xiao Date: Sat, 17 Jan 2026 17:09:47 -0500 Subject: [PATCH 3/5] Added multi-env support --- wwcompanion-extension/background.js | 82 ++++++- wwcompanion-extension/popup.js | 39 ++- wwcompanion-extension/settings.css | 21 ++ wwcompanion-extension/settings.html | 92 +++---- wwcompanion-extension/settings.js | 367 +++++++++++++++++++++++----- 5 files changed, 475 insertions(+), 126 deletions(-) diff --git a/wwcompanion-extension/background.js b/wwcompanion-extension/background.js index 6b92c07..82a9250 100644 --- a/wwcompanion-extension/background.js +++ b/wwcompanion-extension/background.js @@ -19,6 +19,8 @@ const DEFAULT_SETTINGS = { activeApiKeyId: "", apiConfigs: [], activeApiConfigId: "", + envConfigs: [], + activeEnvConfigId: "", apiBaseUrl: "https://api.openai.com/v1", apiKeyHeader: "Authorization", apiKeyPrefix: "Bearer ", @@ -188,6 +190,58 @@ chrome.runtime.onInstalled.addListener(async () => { } } + const resolvedApiConfigs = updates.apiConfigs || stored.apiConfigs || []; + const resolvedActiveApiConfigId = + updates.activeApiConfigId || + stored.activeApiConfigId || + resolvedApiConfigs[0]?.id || + ""; + const hasEnvConfigs = + Array.isArray(stored.envConfigs) && stored.envConfigs.length > 0; + + if (!hasEnvConfigs) { + const id = crypto?.randomUUID + ? crypto.randomUUID() + : `env-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + updates.envConfigs = [ + { + id, + name: "Default", + apiConfigId: resolvedActiveApiConfigId, + systemPrompt: stored.systemPrompt || DEFAULT_SETTINGS.systemPrompt + } + ]; + updates.activeEnvConfigId = id; + } else { + const normalizedEnvs = stored.envConfigs.map((config) => ({ + ...config, + apiConfigId: config.apiConfigId || resolvedActiveApiConfigId, + systemPrompt: config.systemPrompt ?? "" + })); + const envNeedsUpdate = normalizedEnvs.some((config, index) => { + const original = stored.envConfigs[index]; + return ( + config.apiConfigId !== original.apiConfigId || + (config.systemPrompt || "") !== (original.systemPrompt || "") + ); + }); + if (envNeedsUpdate) { + updates.envConfigs = normalizedEnvs; + } + + const envActiveId = updates.activeEnvConfigId || stored.activeEnvConfigId; + if (envActiveId) { + const exists = stored.envConfigs.some( + (config) => config.id === envActiveId + ); + if (!exists) { + updates.activeEnvConfigId = stored.envConfigs[0].id; + } + } else { + updates.activeEnvConfigId = stored.envConfigs[0].id; + } + } + if (Object.keys(updates).length) { await chrome.storage.local.set(updates); } @@ -307,6 +361,10 @@ async function handleAnalysisRequest(port, payload, signal) { safePost(port, { type: "ERROR", message: "Missing request template." }); return; } + if (apiKeyHeader && !apiKey) { + safePost(port, { type: "ERROR", message: "Missing API key." }); + return; + } } else { if (!apiBaseUrl) { safePost(port, { type: "ERROR", message: "Missing API base URL." }); @@ -345,6 +403,10 @@ async function handleAnalysisRequest(port, payload, signal) { apiKey, apiUrl, requestTemplate, + apiKeyHeader, + apiKeyPrefix, + apiBaseUrl, + model, systemPrompt: systemPrompt || "", userMessage, signal, @@ -511,6 +573,10 @@ async function streamCustomCompletion({ apiKey, apiUrl, requestTemplate, + apiKeyHeader, + apiKeyPrefix, + apiBaseUrl, + model, systemPrompt, userMessage, signal, @@ -519,16 +585,24 @@ async function streamCustomCompletion({ const replacements = { PROMPT_GOES_HERE: userMessage, SYSTEM_PROMPT_GOES_HERE: systemPrompt, - API_KEY_GOES_HERE: apiKey + API_KEY_GOES_HERE: apiKey, + MODEL_GOES_HERE: model || "", + API_BASE_URL_GOES_HERE: apiBaseUrl || "" }; const resolvedUrl = replaceUrlTokens(apiUrl, replacements); const body = buildTemplateBody(requestTemplate, replacements); + const headers = { + "Content-Type": "application/json" + }; + const authHeader = buildAuthHeader(apiKeyHeader, apiKeyPrefix, apiKey); + if (authHeader) { + headers[authHeader.name] = authHeader.value; + } + const response = await fetch(resolvedUrl, { method: "POST", - headers: { - "Content-Type": "application/json" - }, + headers, body: JSON.stringify(body), signal }); diff --git a/wwcompanion-extension/popup.js b/wwcompanion-extension/popup.js index 3be90d0..d9af489 100644 --- a/wwcompanion-extension/popup.js +++ b/wwcompanion-extension/popup.js @@ -433,6 +433,8 @@ async function handleAnalyze() { activeApiKeyId = "", apiConfigs = [], activeApiConfigId = "", + envConfigs = [], + activeEnvConfigId = "", apiBaseUrl, apiKeyHeader, apiKeyPrefix, @@ -444,6 +446,8 @@ async function handleAnalyze() { "activeApiKeyId", "apiConfigs", "activeApiConfigId", + "envConfigs", + "activeEnvConfigId", "apiBaseUrl", "apiKeyHeader", "apiKeyPrefix", @@ -453,22 +457,28 @@ async function handleAnalyze() { ]); const resolvedConfigs = Array.isArray(apiConfigs) ? apiConfigs : []; + const resolvedEnvs = Array.isArray(envConfigs) ? envConfigs : []; + const activeEnv = + resolvedEnvs.find((entry) => entry.id === activeEnvConfigId) || + resolvedEnvs[0]; + const resolvedSystemPrompt = + activeEnv?.systemPrompt ?? systemPrompt ?? ""; + const resolvedApiConfigId = + activeEnv?.apiConfigId || activeApiConfigId || resolvedConfigs[0]?.id || ""; const activeConfig = - resolvedConfigs.find((entry) => entry.id === activeApiConfigId) || + resolvedConfigs.find((entry) => entry.id === resolvedApiConfigId) || resolvedConfigs[0]; + if (!activeConfig) { + setStatus("Add an API configuration in Settings."); + return; + } 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 resolvedApiBaseUrl = activeConfig?.apiBaseUrl || apiBaseUrl || ""; + const resolvedApiKeyHeader = activeConfig?.apiKeyHeader ?? apiKeyHeader ?? ""; + const resolvedApiKeyPrefix = activeConfig?.apiKeyPrefix ?? apiKeyPrefix ?? ""; + const resolvedModel = activeConfig?.model || model || ""; const resolvedKeys = Array.isArray(apiKeys) ? apiKeys : []; const resolvedKeyId = @@ -485,7 +495,10 @@ async function handleAnalyze() { setStatus("Set a request template in Settings."); return; } - if (resolvedTemplate.includes("API_KEY_GOES_HERE") && !apiKey) { + const needsKey = + Boolean(resolvedApiKeyHeader) || + resolvedTemplate.includes("API_KEY_GOES_HERE"); + if (needsKey && !apiKey) { setStatus("Add an API key in Settings."); return; } @@ -524,7 +537,7 @@ async function handleAnalyze() { apiKeyHeader: resolvedApiKeyHeader, apiKeyPrefix: resolvedApiKeyPrefix, model: resolvedModel, - systemPrompt: systemPrompt || "", + systemPrompt: resolvedSystemPrompt, resume: resume || "", taskText: task.text || "", postingText: state.postingText, diff --git a/wwcompanion-extension/settings.css b/wwcompanion-extension/settings.css index 2326294..21f63bf 100644 --- a/wwcompanion-extension/settings.css +++ b/wwcompanion-extension/settings.css @@ -256,6 +256,7 @@ button:active { .api-key-actions .delete, .api-config-actions .delete, +.env-config-actions .delete, .task-actions .delete { background: #c0392b; border-color: #c0392b; @@ -290,6 +291,26 @@ button:active { justify-content: flex-end; } +.env-configs { + display: grid; + gap: 12px; +} + +.env-config-card { + padding: 12px; + border-radius: 12px; + border: 1px solid var(--border); + background: var(--card-bg); + display: grid; + gap: 8px; +} + +.env-config-actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} + @media (prefers-color-scheme: dark) { :root:not([data-theme]), :root[data-theme="system"] { diff --git a/wwcompanion-extension/settings.html b/wwcompanion-extension/settings.html index 9c3d605..3ae9916 100644 --- a/wwcompanion-extension/settings.html +++ b/wwcompanion-extension/settings.html @@ -16,46 +16,6 @@
-
- - -

API

-
-
-
-
-
- -
-
-
- - -
-
-
-
- -
- - -

API KEYS

-
-
-
-
- -
-
-
-
-
-

System Prompt

+

API KEYS

- +
+
+ +
+
+
+
+ +
+ + +

API

+
+
+
+
+
+ +
+
+
+
+
+ +
+ + +

Environment

+
+
+
+
+ +
+
+ + +
+
diff --git a/wwcompanion-extension/settings.js b/wwcompanion-extension/settings.js index 0983954..510720e 100644 --- a/wwcompanion-extension/settings.js +++ b/wwcompanion-extension/settings.js @@ -1,11 +1,12 @@ -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 addEnvConfigBtn = document.getElementById("addEnvConfigBtn"); +const envConfigsContainer = document.getElementById("envConfigs"); +const activeEnvConfigSelect = document.getElementById("activeEnvConfigSelect"); const addTaskBtn = document.getElementById("addTaskBtn"); const tasksContainer = document.getElementById("tasks"); const statusEl = document.getElementById("status"); @@ -17,6 +18,8 @@ const OPENAI_DEFAULTS = { apiKeyPrefix: "Bearer " }; const DEFAULT_MODEL = "gpt-4o-mini"; +const DEFAULT_SYSTEM_PROMPT = + "You are a precise, honest assistant. Be concise and avoid inventing details, be critical about evaluations. You should put in a small summary of all the sections at the end. You should answer in no longer than 3 sections including the summary. And remember to bold or italicize key points."; function getStorage(keys) { return new Promise((resolve) => chrome.storage.local.get(keys, resolve)); @@ -50,6 +53,18 @@ function newApiConfigId() { return `config-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; } +function newEnvConfigId() { + if (crypto?.randomUUID) return crypto.randomUUID(); + return `env-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; +} + +function buildChatUrlFromBase(baseUrl) { + const trimmed = (baseUrl || "").trim().replace(/\/+$/, ""); + if (!trimmed) return ""; + if (trimmed.endsWith("/chat/completions")) return trimmed; + return `${trimmed}/chat/completions`; +} + function collectNames(container, selector) { if (!container) return []; return [...container.querySelectorAll(selector)] @@ -94,10 +109,10 @@ function setApiConfigAdvanced(card, isAdvanced) { }); const resetBtn = card.querySelector(".reset-openai"); - if (resetBtn) resetBtn.disabled = isAdvanced; + if (resetBtn) resetBtn.disabled = false; const advancedBtn = card.querySelector(".advanced-toggle"); - if (advancedBtn && isAdvanced) advancedBtn.remove(); + if (advancedBtn) advancedBtn.disabled = isAdvanced; } function readApiConfigFromCard(card) { @@ -165,7 +180,7 @@ function buildApiConfigCard(config) { baseField.appendChild(baseInput); const headerField = document.createElement("div"); - headerField.className = "field basic-only"; + headerField.className = "field advanced-only"; const headerLabel = document.createElement("label"); headerLabel.textContent = "API Key Header"; const headerInput = document.createElement("input"); @@ -177,7 +192,7 @@ function buildApiConfigCard(config) { headerField.appendChild(headerInput); const prefixField = document.createElement("div"); - prefixField.className = "field basic-only"; + prefixField.className = "field advanced-only"; const prefixLabel = document.createElement("label"); prefixLabel.textContent = "API Key Prefix"; const prefixInput = document.createElement("input"); @@ -235,17 +250,28 @@ function buildApiConfigCard(config) { 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 advancedBtn = document.createElement("button"); + advancedBtn.type = "button"; + advancedBtn.className = "ghost advanced-toggle"; + advancedBtn.textContent = "Advanced Mode"; + advancedBtn.addEventListener("click", () => { + if (card.classList.contains("is-advanced")) return; + urlInput.value = buildChatUrlFromBase(baseInput.value); + templateInput.value = [ + "{", + ` \"model\": \"${modelInput.value || DEFAULT_MODEL}\",`, + " \"stream\": true,", + " \"messages\": [", + " { \"role\": \"system\", \"content\": \"SYSTEM_PROMPT_GOES_HERE\" },", + " { \"role\": \"user\", \"content\": \"PROMPT_GOES_HERE\" }", + " ],", + " \"api_key\": \"API_KEY_GOES_HERE\"", + "}" + ].join("\n"); + setApiConfigAdvanced(card, true); + updateEnvApiOptions(); + }); + actions.appendChild(advancedBtn); const duplicateBtn = document.createElement("button"); duplicateBtn.type = "button"; @@ -259,7 +285,7 @@ function buildApiConfigCard(config) { const newCard = buildApiConfigCard(copy); card.insertAdjacentElement("afterend", newCard); updateApiConfigKeyOptions(); - updateApiConfigSelect(newCard.dataset.id); + updateEnvApiOptions(); }); actions.appendChild(duplicateBtn); @@ -268,14 +294,14 @@ function buildApiConfigCard(config) { 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); + modelInput.value = DEFAULT_MODEL; + urlInput.value = ""; + templateInput.value = ""; + setApiConfigAdvanced(card, false); + updateEnvApiOptions(); }); actions.appendChild(resetBtn); @@ -285,11 +311,11 @@ function buildApiConfigCard(config) { deleteBtn.textContent = "Delete"; deleteBtn.addEventListener("click", () => { card.remove(); - updateApiConfigSelect(activeApiConfigSelect.value); + updateEnvApiOptions(); }); actions.appendChild(deleteBtn); - const updateSelect = () => updateApiConfigSelect(activeApiConfigSelect.value); + const updateSelect = () => updateEnvApiOptions(); nameInput.addEventListener("input", updateSelect); baseInput.addEventListener("input", updateSelect); headerInput.addEventListener("input", updateSelect); @@ -318,35 +344,6 @@ function collectApiConfigs() { 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"; @@ -458,6 +455,170 @@ function updateApiConfigKeyOptions() { }); } +function buildEnvConfigCard(config) { + const card = document.createElement("div"); + card.className = "env-config-card"; + card.dataset.id = config.id || newEnvConfigId(); + + 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 = "env-config-name"; + nameField.appendChild(nameLabel); + nameField.appendChild(nameInput); + + const apiField = document.createElement("div"); + apiField.className = "field"; + const apiLabel = document.createElement("label"); + apiLabel.textContent = "API config"; + const apiSelect = document.createElement("select"); + apiSelect.className = "env-config-api-select"; + apiSelect.dataset.preferred = config.apiConfigId || ""; + apiField.appendChild(apiLabel); + apiField.appendChild(apiSelect); + + const promptField = document.createElement("div"); + promptField.className = "field"; + const promptLabel = document.createElement("label"); + promptLabel.textContent = "System prompt"; + const promptInput = document.createElement("textarea"); + promptInput.rows = 8; + promptInput.value = config.systemPrompt || ""; + promptInput.className = "env-config-prompt"; + promptField.appendChild(promptLabel); + promptField.appendChild(promptInput); + + const actions = document.createElement("div"); + actions.className = "env-config-actions"; + + const duplicateBtn = document.createElement("button"); + duplicateBtn.type = "button"; + duplicateBtn.className = "ghost duplicate"; + duplicateBtn.textContent = "Duplicate"; + duplicateBtn.addEventListener("click", () => { + const names = collectNames(envConfigsContainer, ".env-config-name"); + const copy = collectEnvConfigs().find((entry) => entry.id === card.dataset.id) || { + id: card.dataset.id, + name: nameInput.value || "Default", + apiConfigId: apiSelect.value || "", + systemPrompt: promptInput.value || "" + }; + const newCard = buildEnvConfigCard({ + id: newEnvConfigId(), + name: ensureUniqueName(`${copy.name || "Default"} Copy`, names), + apiConfigId: copy.apiConfigId, + systemPrompt: copy.systemPrompt + }); + card.insertAdjacentElement("afterend", newCard); + updateEnvApiOptions(); + updateEnvConfigSelect(newCard.dataset.id); + }); + + const deleteBtn = document.createElement("button"); + deleteBtn.type = "button"; + deleteBtn.className = "ghost delete"; + deleteBtn.textContent = "Delete"; + deleteBtn.addEventListener("click", () => { + card.remove(); + updateEnvConfigSelect(activeEnvConfigSelect.value); + }); + + actions.appendChild(duplicateBtn); + actions.appendChild(deleteBtn); + + nameInput.addEventListener("input", () => + updateEnvConfigSelect(activeEnvConfigSelect.value) + ); + + card.appendChild(nameField); + card.appendChild(apiField); + card.appendChild(promptField); + card.appendChild(actions); + + return card; +} + +function collectEnvConfigs() { + const cards = [...envConfigsContainer.querySelectorAll(".env-config-card")]; + return cards.map((card) => { + const nameInput = card.querySelector(".env-config-name"); + const apiSelect = card.querySelector(".env-config-api-select"); + const promptInput = card.querySelector(".env-config-prompt"); + return { + id: card.dataset.id || newEnvConfigId(), + name: (nameInput?.value || "Default").trim(), + apiConfigId: apiSelect?.value || "", + systemPrompt: (promptInput?.value || "").trim() + }; + }); +} + +function updateEnvConfigSelect(preferredId) { + const configs = collectEnvConfigs(); + activeEnvConfigSelect.innerHTML = ""; + + if (!configs.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "No environments configured"; + activeEnvConfigSelect.appendChild(option); + activeEnvConfigSelect.disabled = true; + return; + } + + activeEnvConfigSelect.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"; + activeEnvConfigSelect.appendChild(option); + } + + activeEnvConfigSelect.value = selectedId; +} + +function updateEnvApiOptions() { + const apiConfigs = collectApiConfigs(); + const selects = envConfigsContainer.querySelectorAll(".env-config-api-select"); + selects.forEach((select) => { + const preferred = select.dataset.preferred || select.value; + select.innerHTML = ""; + if (!apiConfigs.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "No API configs configured"; + select.appendChild(option); + select.disabled = true; + return; + } + + select.disabled = false; + for (const config of apiConfigs) { + const option = document.createElement("option"); + option.value = config.id; + option.textContent = config.name || "Default"; + select.appendChild(option); + } + + if (preferred && apiConfigs.some((config) => config.id === preferred)) { + select.value = preferred; + } else { + select.value = apiConfigs[0].id; + } + + select.dataset.preferred = select.value; + }); +} + function buildTaskCard(task) { const card = document.createElement("div"); card.className = "task-card"; @@ -619,6 +780,8 @@ async function loadSettings() { activeApiKeyId = "", apiConfigs = [], activeApiConfigId = "", + envConfigs = [], + activeEnvConfigId = "", apiBaseUrl = "", apiKeyHeader = "", apiKeyPrefix = "", @@ -633,6 +796,8 @@ async function loadSettings() { "activeApiKeyId", "apiConfigs", "activeApiConfigId", + "envConfigs", + "activeEnvConfigId", "apiBaseUrl", "apiKeyHeader", "apiKeyPrefix", @@ -643,7 +808,6 @@ async function loadSettings() { "theme" ]); - systemPromptInput.value = systemPrompt; resumeInput.value = resume; themeSelect.value = theme; applyTheme(theme); @@ -727,7 +891,57 @@ async function loadSettings() { apiConfigsContainer.appendChild(buildApiConfigCard(config)); } updateApiConfigKeyOptions(); - updateApiConfigSelect(resolvedActiveConfigId); + + let resolvedEnvConfigs = Array.isArray(envConfigs) ? envConfigs : []; + let resolvedActiveEnvId = activeEnvConfigId; + const fallbackApiConfigId = + resolvedActiveConfigId || resolvedConfigs[0]?.id || ""; + + if (!resolvedEnvConfigs.length) { + const migrated = { + id: newEnvConfigId(), + name: "Default", + apiConfigId: fallbackApiConfigId, + systemPrompt: systemPrompt || DEFAULT_SYSTEM_PROMPT + }; + resolvedEnvConfigs = [migrated]; + resolvedActiveEnvId = migrated.id; + await chrome.storage.local.set({ + envConfigs: resolvedEnvConfigs, + activeEnvConfigId: resolvedActiveEnvId + }); + } else { + const withDefaults = resolvedEnvConfigs.map((config) => ({ + ...config, + apiConfigId: config.apiConfigId || fallbackApiConfigId, + systemPrompt: config.systemPrompt ?? "" + })); + const needsUpdate = withDefaults.some((config, index) => { + const original = resolvedEnvConfigs[index]; + return ( + config.apiConfigId !== original.apiConfigId || + (config.systemPrompt || "") !== (original.systemPrompt || "") + ); + }); + if (needsUpdate) { + resolvedEnvConfigs = withDefaults; + await chrome.storage.local.set({ envConfigs: resolvedEnvConfigs }); + } + const hasActive = resolvedEnvConfigs.some( + (config) => config.id === resolvedActiveEnvId + ); + if (!hasActive) { + resolvedActiveEnvId = resolvedEnvConfigs[0].id; + await chrome.storage.local.set({ activeEnvConfigId: resolvedActiveEnvId }); + } + } + + envConfigsContainer.innerHTML = ""; + for (const config of resolvedEnvConfigs) { + envConfigsContainer.appendChild(buildEnvConfigCard(config)); + } + updateEnvApiOptions(); + updateEnvConfigSelect(resolvedActiveEnvId); tasksContainer.innerHTML = ""; if (!tasks.length) { @@ -748,10 +962,14 @@ 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 envConfigs = collectEnvConfigs(); + const activeEnvConfigId = + envConfigs.find((entry) => entry.id === activeEnvConfigSelect.value)?.id || + envConfigs[0]?.id || ""; + const activeEnv = envConfigs.find((entry) => entry.id === activeEnvConfigId); + const activeApiConfigId = + activeEnv?.apiConfigId || apiConfigs[0]?.id || ""; const activeConfig = apiConfigs.find((entry) => entry.id === activeApiConfigId); const activeApiKeyId = activeConfig?.apiKeyId || @@ -762,7 +980,9 @@ async function saveSettings() { activeApiKeyId, apiConfigs, activeApiConfigId, - systemPrompt: systemPromptInput.value, + envConfigs, + activeEnvConfigId, + systemPrompt: activeEnv?.systemPrompt || "", resume: resumeInput.value, tasks, theme: themeSelect.value @@ -821,11 +1041,32 @@ addApiConfigBtn.addEventListener("click", () => { apiConfigsContainer.appendChild(newCard); } updateApiConfigKeyOptions(); - updateApiConfigSelect(activeApiConfigSelect.value); + updateEnvApiOptions(); }); -activeApiConfigSelect.addEventListener("change", () => { - updateApiConfigSelect(activeApiConfigSelect.value); +addEnvConfigBtn.addEventListener("click", () => { + const name = buildUniqueDefaultName( + collectNames(envConfigsContainer, ".env-config-name") + ); + const fallbackApiConfigId = collectApiConfigs()[0]?.id || ""; + const newCard = buildEnvConfigCard({ + id: newEnvConfigId(), + name, + apiConfigId: fallbackApiConfigId, + systemPrompt: DEFAULT_SYSTEM_PROMPT + }); + const first = envConfigsContainer.firstElementChild; + if (first) { + envConfigsContainer.insertBefore(newCard, first); + } else { + envConfigsContainer.appendChild(newCard); + } + updateEnvApiOptions(); + updateEnvConfigSelect(newCard.dataset.id); +}); + +activeEnvConfigSelect.addEventListener("change", () => { + updateEnvConfigSelect(activeEnvConfigSelect.value); }); themeSelect.addEventListener("change", () => applyTheme(themeSelect.value)); -- 2.34.1 From df80aee8eb3c4e0dea6847ff922e3577f4a3f7a1 Mon Sep 17 00:00:00 2001 From: Peisong Xiao Date: Sat, 17 Jan 2026 17:36:44 -0500 Subject: [PATCH 4/5] reworked UI, moved environment selection to tasks --- wwcompanion-extension/background.js | 24 ++ wwcompanion-extension/popup.css | 37 +-- wwcompanion-extension/popup.html | 22 +- wwcompanion-extension/popup.js | 154 ++++++++++--- wwcompanion-extension/settings.html | 14 +- wwcompanion-extension/settings.js | 334 +++++++++++++++++++++++----- 6 files changed, 467 insertions(+), 118 deletions(-) diff --git a/wwcompanion-extension/background.js b/wwcompanion-extension/background.js index 82a9250..873b18c 100644 --- a/wwcompanion-extension/background.js +++ b/wwcompanion-extension/background.js @@ -242,6 +242,30 @@ chrome.runtime.onInstalled.addListener(async () => { } } + const resolvedEnvConfigs = updates.envConfigs || stored.envConfigs || []; + const defaultEnvId = + resolvedEnvConfigs[0]?.id || + updates.activeEnvConfigId || + stored.activeEnvConfigId || + ""; + const taskSource = Array.isArray(updates.tasks) + ? updates.tasks + : Array.isArray(stored.tasks) + ? stored.tasks + : []; + if (taskSource.length) { + const normalizedTasks = taskSource.map((task) => ({ + ...task, + defaultEnvId: task.defaultEnvId || defaultEnvId + })); + const needsTaskUpdate = normalizedTasks.some( + (task, index) => task.defaultEnvId !== taskSource[index]?.defaultEnvId + ); + if (needsTaskUpdate) { + updates.tasks = normalizedTasks; + } + } + if (Object.keys(updates).length) { await chrome.storage.local.set(updates); } diff --git a/wwcompanion-extension/popup.css b/wwcompanion-extension/popup.css index 20f3a18..20bfa5b 100644 --- a/wwcompanion-extension/popup.css +++ b/wwcompanion-extension/popup.css @@ -127,28 +127,33 @@ select { font-size: 12px; } -.button-row { - display: grid; - grid-template-columns: minmax(64px, 0.8fr) minmax(0, 1.4fr) minmax(64px, 0.8fr); +.env-row { + display: flex; + align-items: flex-end; +} + +.env-row .env-field { + flex: 1; + margin: 0; +} + +.task-row { + display: flex; + align-items: flex-end; gap: 8px; } -.button-row button { - white-space: nowrap; +.task-row button { + padding: 6px 15px; } -.button-row .primary { - padding: 6px 8px; +.task-row .task-field { + flex: 1; + margin: 0; } -.stop-row { - display: flex; - justify-content: stretch; - margin-top: 4px; -} - -.stop-row button { - width: 100%; +.task-row select { + min-width: 0; } .hidden { @@ -192,7 +197,7 @@ button:active { border: 1px solid var(--border); } -.stop-row .ghost { +.stop-btn { background: #c0392b; border-color: #c0392b; color: #fff6f2; diff --git a/wwcompanion-extension/popup.html b/wwcompanion-extension/popup.html index 8b807c6..67d3ca9 100644 --- a/wwcompanion-extension/popup.html +++ b/wwcompanion-extension/popup.html @@ -17,17 +17,19 @@
-
- - +
+
+ + +
-
- - - -
-
diff --git a/wwcompanion-extension/popup.js b/wwcompanion-extension/popup.js index d9af489..b5086c9 100644 --- a/wwcompanion-extension/popup.js +++ b/wwcompanion-extension/popup.js @@ -1,10 +1,7 @@ -const extractBtn = document.getElementById("extractBtn"); -const analyzeBtn = document.getElementById("analyzeBtn"); +const runBtn = document.getElementById("runBtn"); const abortBtn = document.getElementById("abortBtn"); -const extractRunBtn = document.getElementById("extractRunBtn"); -const stopRow = document.getElementById("stopRow"); -const buttonRow = document.querySelector(".button-row"); const taskSelect = document.getElementById("taskSelect"); +const envSelect = document.getElementById("envSelect"); const outputEl = document.getElementById("output"); const statusEl = document.getElementById("status"); const postingCountEl = document.getElementById("postingCount"); @@ -16,14 +13,19 @@ const clearOutputBtn = document.getElementById("clearOutputBtn"); const OUTPUT_STORAGE_KEY = "lastOutput"; const AUTO_RUN_KEY = "autoRunDefaultTask"; +const LAST_TASK_KEY = "lastSelectedTaskId"; +const LAST_ENV_KEY = "lastSelectedEnvId"; const state = { postingText: "", tasks: [], + envs: [], port: null, isAnalyzing: false, outputRaw: "", - autoRunPending: false + autoRunPending: false, + selectedTaskId: "", + selectedEnvId: "" }; function getStorage(keys) { @@ -245,15 +247,12 @@ function applyTheme(theme) { function setAnalyzing(isAnalyzing) { state.isAnalyzing = isAnalyzing; - analyzeBtn.disabled = isAnalyzing; + runBtn.disabled = isAnalyzing; abortBtn.disabled = !isAnalyzing; - extractBtn.disabled = isAnalyzing; - extractRunBtn.disabled = isAnalyzing; - if (buttonRow && stopRow) { - buttonRow.classList.toggle("hidden", isAnalyzing); - stopRow.classList.toggle("hidden", !isAnalyzing); - } + runBtn.classList.toggle("hidden", isAnalyzing); + abortBtn.classList.toggle("hidden", !isAnalyzing); updateTaskSelectState(); + updateEnvSelectState(); } function updatePostingCount() { @@ -286,11 +285,70 @@ function renderTasks(tasks) { updateTaskSelectState(); } +function renderEnvironments(envs) { + state.envs = envs; + envSelect.innerHTML = ""; + + if (!envs.length) { + const option = document.createElement("option"); + option.textContent = "No environments configured"; + option.value = ""; + envSelect.appendChild(option); + updateEnvSelectState(); + return; + } + + for (const env of envs) { + const option = document.createElement("option"); + option.value = env.id; + option.textContent = env.name || "Default"; + envSelect.appendChild(option); + } + updateEnvSelectState(); +} + function updateTaskSelectState() { const hasTasks = state.tasks.length > 0; taskSelect.disabled = state.isAnalyzing || !hasTasks; } +function updateEnvSelectState() { + const hasEnvs = state.envs.length > 0; + envSelect.disabled = state.isAnalyzing || !hasEnvs; +} + +function getTaskDefaultEnvId(task) { + return task?.defaultEnvId || state.envs[0]?.id || ""; +} + +function setEnvironmentSelection(envId) { + const target = + envId && state.envs.some((env) => env.id === envId) + ? envId + : state.envs[0]?.id || ""; + if (target) { + envSelect.value = target; + } + state.selectedEnvId = target; +} + +function selectTask(taskId, { resetEnv } = { resetEnv: false }) { + if (!taskId) return; + taskSelect.value = taskId; + state.selectedTaskId = taskId; + const task = state.tasks.find((item) => item.id === taskId); + if (resetEnv) { + setEnvironmentSelection(getTaskDefaultEnvId(task)); + } +} + +async function persistSelections() { + await chrome.storage.local.set({ + [LAST_TASK_KEY]: state.selectedTaskId, + [LAST_ENV_KEY]: state.selectedEnvId + }); +} + function isWaterlooWorksUrl(url) { try { return new URL(url).hostname === "waterlooworks.uwaterloo.ca"; @@ -377,9 +435,45 @@ function ensurePort() { return port; } -async function loadTasks() { - const { tasks = [] } = await getStorage(["tasks"]); +async function loadConfig() { + const stored = await getStorage([ + "tasks", + "envConfigs", + LAST_TASK_KEY, + LAST_ENV_KEY + ]); + const tasks = Array.isArray(stored.tasks) ? stored.tasks : []; + const envs = Array.isArray(stored.envConfigs) ? stored.envConfigs : []; renderTasks(tasks); + renderEnvironments(envs); + + if (!tasks.length) { + state.selectedTaskId = ""; + state.selectedEnvId = envs[0]?.id || ""; + return; + } + + const storedTaskId = stored[LAST_TASK_KEY]; + const storedEnvId = stored[LAST_ENV_KEY]; + const initialTaskId = tasks.some((task) => task.id === storedTaskId) + ? storedTaskId + : tasks[0].id; + selectTask(initialTaskId, { resetEnv: false }); + + const task = tasks.find((item) => item.id === initialTaskId); + if (storedEnvId && envs.some((env) => env.id === storedEnvId)) { + setEnvironmentSelection(storedEnvId); + } else { + setEnvironmentSelection(getTaskDefaultEnvId(task)); + } + + if ( + storedTaskId !== state.selectedTaskId || + storedEnvId !== state.selectedEnvId + ) { + await persistSelections(); + } + maybeRunDefaultTask(); } @@ -434,7 +528,6 @@ async function handleAnalyze() { apiConfigs = [], activeApiConfigId = "", envConfigs = [], - activeEnvConfigId = "", apiBaseUrl, apiKeyHeader, apiKeyPrefix, @@ -447,7 +540,6 @@ async function handleAnalyze() { "apiConfigs", "activeApiConfigId", "envConfigs", - "activeEnvConfigId", "apiBaseUrl", "apiKeyHeader", "apiKeyPrefix", @@ -458,13 +550,18 @@ async function handleAnalyze() { const resolvedConfigs = Array.isArray(apiConfigs) ? apiConfigs : []; const resolvedEnvs = Array.isArray(envConfigs) ? envConfigs : []; + const selectedEnvId = envSelect.value; const activeEnv = - resolvedEnvs.find((entry) => entry.id === activeEnvConfigId) || + resolvedEnvs.find((entry) => entry.id === selectedEnvId) || resolvedEnvs[0]; + if (!activeEnv) { + setStatus("Add an environment in Settings."); + return; + } const resolvedSystemPrompt = - activeEnv?.systemPrompt ?? systemPrompt ?? ""; + activeEnv.systemPrompt ?? systemPrompt ?? ""; const resolvedApiConfigId = - activeEnv?.apiConfigId || activeApiConfigId || resolvedConfigs[0]?.id || ""; + activeEnv.apiConfigId || activeApiConfigId || resolvedConfigs[0]?.id || ""; const activeConfig = resolvedConfigs.find((entry) => entry.id === resolvedApiConfigId) || resolvedConfigs[0]; @@ -593,20 +690,26 @@ function handleCopyRaw() { void copyTextToClipboard(text, "Markdown"); } -extractBtn.addEventListener("click", handleExtract); -analyzeBtn.addEventListener("click", handleAnalyze); -extractRunBtn.addEventListener("click", handleExtractAndAnalyze); +runBtn.addEventListener("click", handleExtractAndAnalyze); abortBtn.addEventListener("click", handleAbort); settingsBtn.addEventListener("click", () => chrome.runtime.openOptionsPage()); copyRenderedBtn.addEventListener("click", handleCopyRendered); copyRawBtn.addEventListener("click", handleCopyRaw); clearOutputBtn.addEventListener("click", () => void handleClearOutput()); +taskSelect.addEventListener("change", () => { + selectTask(taskSelect.value, { resetEnv: true }); + void persistSelections(); +}); +envSelect.addEventListener("change", () => { + setEnvironmentSelection(envSelect.value); + void persistSelections(); +}); updatePostingCount(); updatePromptCount(0); renderOutput(); setAnalyzing(false); -loadTasks(); +loadConfig(); loadTheme(); async function loadSavedOutput() { @@ -628,7 +731,8 @@ function maybeRunDefaultTask() { if (!state.autoRunPending) return; if (state.isAnalyzing) return; if (!state.tasks.length) return; - taskSelect.value = state.tasks[0].id; + selectTask(state.tasks[0].id, { resetEnv: true }); + void persistSelections(); state.autoRunPending = false; void handleExtractAndAnalyze(); } diff --git a/wwcompanion-extension/settings.html b/wwcompanion-extension/settings.html index 3ae9916..b0ef5e5 100644 --- a/wwcompanion-extension/settings.html +++ b/wwcompanion-extension/settings.html @@ -78,17 +78,16 @@ -

Environment

+
+

Environment

+ API configuration and system prompt go here +
-
- - -
@@ -99,7 +98,10 @@ -

Resume

+
+

Resume

+ Text to your profile goes here +
diff --git a/wwcompanion-extension/settings.js b/wwcompanion-extension/settings.js index 510720e..61ea3fb 100644 --- a/wwcompanion-extension/settings.js +++ b/wwcompanion-extension/settings.js @@ -6,7 +6,6 @@ const addApiKeyBtn = document.getElementById("addApiKeyBtn"); const apiKeysContainer = document.getElementById("apiKeys"); const addEnvConfigBtn = document.getElementById("addEnvConfigBtn"); const envConfigsContainer = document.getElementById("envConfigs"); -const activeEnvConfigSelect = document.getElementById("activeEnvConfigSelect"); const addTaskBtn = document.getElementById("addTaskBtn"); const tasksContainer = document.getElementById("tasks"); const statusEl = document.getElementById("status"); @@ -91,6 +90,10 @@ function ensureUniqueName(desired, existingNames) { return buildUniqueDefaultName(existingNames); } +function getTopEnvId() { + return collectEnvConfigs()[0]?.id || ""; +} + function setApiConfigAdvanced(card, isAdvanced) { card.classList.toggle("is-advanced", isAdvanced); card.dataset.mode = isAdvanced ? "advanced" : "basic"; @@ -250,6 +253,46 @@ function buildApiConfigCard(config) { const actions = document.createElement("div"); actions.className = "api-config-actions"; + const moveTopBtn = document.createElement("button"); + moveTopBtn.type = "button"; + moveTopBtn.className = "ghost move-top"; + moveTopBtn.textContent = "Top"; + const moveUpBtn = document.createElement("button"); + moveUpBtn.type = "button"; + moveUpBtn.className = "ghost move-up"; + moveUpBtn.textContent = "Up"; + const moveDownBtn = document.createElement("button"); + moveDownBtn.type = "button"; + moveDownBtn.className = "ghost move-down"; + moveDownBtn.textContent = "Down"; + + moveTopBtn.addEventListener("click", () => { + const first = apiConfigsContainer.firstElementChild; + if (!first || first === card) return; + apiConfigsContainer.insertBefore(card, first); + updateApiConfigControls(); + updateEnvApiOptions(); + }); + + moveUpBtn.addEventListener("click", () => { + const previous = card.previousElementSibling; + if (!previous) return; + apiConfigsContainer.insertBefore(card, previous); + updateApiConfigControls(); + updateEnvApiOptions(); + }); + + moveDownBtn.addEventListener("click", () => { + const next = card.nextElementSibling; + if (!next) return; + apiConfigsContainer.insertBefore(card, next.nextElementSibling); + updateApiConfigControls(); + updateEnvApiOptions(); + }); + + actions.appendChild(moveTopBtn); + actions.appendChild(moveUpBtn); + actions.appendChild(moveDownBtn); const advancedBtn = document.createElement("button"); advancedBtn.type = "button"; advancedBtn.className = "ghost advanced-toggle"; @@ -312,6 +355,7 @@ function buildApiConfigCard(config) { deleteBtn.addEventListener("click", () => { card.remove(); updateEnvApiOptions(); + updateApiConfigControls(); }); actions.appendChild(deleteBtn); @@ -344,6 +388,18 @@ function collectApiConfigs() { return cards.map((card) => readApiConfigFromCard(card)); } +function updateApiConfigControls() { + const cards = [...apiConfigsContainer.querySelectorAll(".api-config-card")]; + cards.forEach((card, index) => { + const moveTopBtn = card.querySelector(".move-top"); + const moveUpBtn = card.querySelector(".move-up"); + const moveDownBtn = card.querySelector(".move-down"); + if (moveTopBtn) moveTopBtn.disabled = index === 0; + if (moveUpBtn) moveUpBtn.disabled = index === 0; + if (moveDownBtn) moveDownBtn.disabled = index === cards.length - 1; + }); +} + function buildApiKeyCard(entry) { const card = document.createElement("div"); card.className = "api-key-card"; @@ -388,6 +444,46 @@ function buildApiKeyCard(entry) { const actions = document.createElement("div"); actions.className = "api-key-actions"; + const moveTopBtn = document.createElement("button"); + moveTopBtn.type = "button"; + moveTopBtn.className = "ghost move-top"; + moveTopBtn.textContent = "Top"; + const moveUpBtn = document.createElement("button"); + moveUpBtn.type = "button"; + moveUpBtn.className = "ghost move-up"; + moveUpBtn.textContent = "Up"; + const moveDownBtn = document.createElement("button"); + moveDownBtn.type = "button"; + moveDownBtn.className = "ghost move-down"; + moveDownBtn.textContent = "Down"; + + moveTopBtn.addEventListener("click", () => { + const first = apiKeysContainer.firstElementChild; + if (!first || first === card) return; + apiKeysContainer.insertBefore(card, first); + updateApiKeyControls(); + updateApiConfigKeyOptions(); + }); + + moveUpBtn.addEventListener("click", () => { + const previous = card.previousElementSibling; + if (!previous) return; + apiKeysContainer.insertBefore(card, previous); + updateApiKeyControls(); + updateApiConfigKeyOptions(); + }); + + moveDownBtn.addEventListener("click", () => { + const next = card.nextElementSibling; + if (!next) return; + apiKeysContainer.insertBefore(card, next.nextElementSibling); + updateApiKeyControls(); + updateApiConfigKeyOptions(); + }); + + actions.appendChild(moveTopBtn); + actions.appendChild(moveUpBtn); + actions.appendChild(moveDownBtn); const deleteBtn = document.createElement("button"); deleteBtn.type = "button"; deleteBtn.className = "ghost delete"; @@ -395,6 +491,7 @@ function buildApiKeyCard(entry) { deleteBtn.addEventListener("click", () => { card.remove(); updateApiConfigKeyOptions(); + updateApiKeyControls(); }); actions.appendChild(deleteBtn); @@ -422,6 +519,18 @@ function collectApiKeys() { }); } +function updateApiKeyControls() { + const cards = [...apiKeysContainer.querySelectorAll(".api-key-card")]; + cards.forEach((card, index) => { + const moveTopBtn = card.querySelector(".move-top"); + const moveUpBtn = card.querySelector(".move-up"); + const moveDownBtn = card.querySelector(".move-down"); + if (moveTopBtn) moveTopBtn.disabled = index === 0; + if (moveUpBtn) moveUpBtn.disabled = index === 0; + if (moveDownBtn) moveDownBtn.disabled = index === cards.length - 1; + }); +} + function updateApiConfigKeyOptions() { const keys = collectApiKeys(); const selects = apiConfigsContainer.querySelectorAll(".api-config-key-select"); @@ -494,6 +603,46 @@ function buildEnvConfigCard(config) { const actions = document.createElement("div"); actions.className = "env-config-actions"; + const moveTopBtn = document.createElement("button"); + moveTopBtn.type = "button"; + moveTopBtn.className = "ghost move-top"; + moveTopBtn.textContent = "Top"; + const moveUpBtn = document.createElement("button"); + moveUpBtn.type = "button"; + moveUpBtn.className = "ghost move-up"; + moveUpBtn.textContent = "Up"; + const moveDownBtn = document.createElement("button"); + moveDownBtn.type = "button"; + moveDownBtn.className = "ghost move-down"; + moveDownBtn.textContent = "Down"; + + moveTopBtn.addEventListener("click", () => { + const first = envConfigsContainer.firstElementChild; + if (!first || first === card) return; + envConfigsContainer.insertBefore(card, first); + updateEnvControls(); + updateTaskEnvOptions(); + }); + + moveUpBtn.addEventListener("click", () => { + const previous = card.previousElementSibling; + if (!previous) return; + envConfigsContainer.insertBefore(card, previous); + updateEnvControls(); + updateTaskEnvOptions(); + }); + + moveDownBtn.addEventListener("click", () => { + const next = card.nextElementSibling; + if (!next) return; + envConfigsContainer.insertBefore(card, next.nextElementSibling); + updateEnvControls(); + updateTaskEnvOptions(); + }); + + actions.appendChild(moveTopBtn); + actions.appendChild(moveUpBtn); + actions.appendChild(moveDownBtn); const duplicateBtn = document.createElement("button"); duplicateBtn.type = "button"; @@ -515,7 +664,8 @@ function buildEnvConfigCard(config) { }); card.insertAdjacentElement("afterend", newCard); updateEnvApiOptions(); - updateEnvConfigSelect(newCard.dataset.id); + updateEnvControls(); + updateTaskEnvOptions(); }); const deleteBtn = document.createElement("button"); @@ -524,15 +674,13 @@ function buildEnvConfigCard(config) { deleteBtn.textContent = "Delete"; deleteBtn.addEventListener("click", () => { card.remove(); - updateEnvConfigSelect(activeEnvConfigSelect.value); + updateEnvControls(); + updateTaskEnvOptions(); }); actions.appendChild(duplicateBtn); actions.appendChild(deleteBtn); - - nameInput.addEventListener("input", () => - updateEnvConfigSelect(activeEnvConfigSelect.value) - ); + nameInput.addEventListener("input", () => updateEnvApiOptions()); card.appendChild(nameField); card.appendChild(apiField); @@ -542,6 +690,51 @@ function buildEnvConfigCard(config) { return card; } +function updateEnvControls() { + const cards = [...envConfigsContainer.querySelectorAll(".env-config-card")]; + cards.forEach((card, index) => { + const moveTopBtn = card.querySelector(".move-top"); + const moveUpBtn = card.querySelector(".move-up"); + const moveDownBtn = card.querySelector(".move-down"); + if (moveTopBtn) moveTopBtn.disabled = index === 0; + if (moveUpBtn) moveUpBtn.disabled = index === 0; + if (moveDownBtn) moveDownBtn.disabled = index === cards.length - 1; + }); +} + +function updateTaskEnvOptions() { + const envs = collectEnvConfigs(); + const selects = tasksContainer.querySelectorAll(".task-env-select"); + selects.forEach((select) => { + const preferred = select.dataset.preferred || select.value; + select.innerHTML = ""; + if (!envs.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "No environments configured"; + select.appendChild(option); + select.disabled = true; + return; + } + + select.disabled = false; + for (const env of envs) { + const option = document.createElement("option"); + option.value = env.id; + option.textContent = env.name || "Default"; + select.appendChild(option); + } + + if (preferred && envs.some((env) => env.id === preferred)) { + select.value = preferred; + } else { + select.value = envs[0].id; + } + + select.dataset.preferred = select.value; + }); +} + function collectEnvConfigs() { const cards = [...envConfigsContainer.querySelectorAll(".env-config-card")]; return cards.map((card) => { @@ -557,35 +750,6 @@ function collectEnvConfigs() { }); } -function updateEnvConfigSelect(preferredId) { - const configs = collectEnvConfigs(); - activeEnvConfigSelect.innerHTML = ""; - - if (!configs.length) { - const option = document.createElement("option"); - option.value = ""; - option.textContent = "No environments configured"; - activeEnvConfigSelect.appendChild(option); - activeEnvConfigSelect.disabled = true; - return; - } - - activeEnvConfigSelect.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"; - activeEnvConfigSelect.appendChild(option); - } - - activeEnvConfigSelect.value = selectedId; -} - function updateEnvApiOptions() { const apiConfigs = collectApiConfigs(); const selects = envConfigsContainer.querySelectorAll(".env-config-api-select"); @@ -617,6 +781,7 @@ function updateEnvApiOptions() { select.dataset.preferred = select.value; }); + updateTaskEnvOptions(); } function buildTaskCard(task) { @@ -635,6 +800,16 @@ function buildTaskCard(task) { nameField.appendChild(nameLabel); nameField.appendChild(nameInput); + const envField = document.createElement("div"); + envField.className = "field"; + const envLabel = document.createElement("label"); + envLabel.textContent = "Default environment"; + const envSelect = document.createElement("select"); + envSelect.className = "task-env-select"; + envSelect.dataset.preferred = task.defaultEnvId || ""; + envField.appendChild(envLabel); + envField.appendChild(envSelect); + const textField = document.createElement("div"); textField.className = "field"; const textLabel = document.createElement("label"); @@ -710,9 +885,15 @@ function buildTaskCard(task) { const name = buildUniqueDefaultName( collectNames(tasksContainer, ".task-name") ); - const newCard = buildTaskCard({ id: newTaskId(), name, text: "" }); + const newCard = buildTaskCard({ + id: newTaskId(), + name, + text: "", + defaultEnvId: getTopEnvId() + }); card.insertAdjacentElement("afterend", newCard); updateTaskControls(); + updateTaskEnvOptions(); }); duplicateBtn.addEventListener("click", () => { @@ -722,11 +903,13 @@ function buildTaskCard(task) { `${nameInput.value || "Untitled"} Copy`, collectNames(tasksContainer, ".task-name") ), - text: textArea.value + text: textArea.value, + defaultEnvId: envSelect.value || "" }; const newCard = buildTaskCard(copy); card.insertAdjacentElement("afterend", newCard); updateTaskControls(); + updateTaskEnvOptions(); }); deleteBtn.addEventListener("click", () => { @@ -742,6 +925,7 @@ function buildTaskCard(task) { actions.appendChild(deleteBtn); card.appendChild(nameField); + card.appendChild(envField); card.appendChild(textField); card.appendChild(actions); @@ -765,10 +949,12 @@ function collectTasks() { return cards.map((card) => { const nameInput = card.querySelector(".task-name"); const textArea = card.querySelector(".task-text"); + const envSelect = card.querySelector(".task-env-select"); return { id: card.dataset.id || newTaskId(), name: (nameInput?.value || "Untitled Task").trim(), - text: (textArea?.value || "").trim() + text: (textArea?.value || "").trim(), + defaultEnvId: envSelect?.value || "" }; }); } @@ -841,6 +1027,7 @@ async function loadSettings() { apiKeysContainer.appendChild(buildApiKeyCard(entry)); } } + updateApiKeyControls(); let resolvedConfigs = Array.isArray(apiConfigs) ? apiConfigs : []; let resolvedActiveConfigId = activeApiConfigId; @@ -891,9 +1078,9 @@ async function loadSettings() { apiConfigsContainer.appendChild(buildApiConfigCard(config)); } updateApiConfigKeyOptions(); + updateApiConfigControls(); let resolvedEnvConfigs = Array.isArray(envConfigs) ? envConfigs : []; - let resolvedActiveEnvId = activeEnvConfigId; const fallbackApiConfigId = resolvedActiveConfigId || resolvedConfigs[0]?.id || ""; @@ -905,10 +1092,9 @@ async function loadSettings() { systemPrompt: systemPrompt || DEFAULT_SYSTEM_PROMPT }; resolvedEnvConfigs = [migrated]; - resolvedActiveEnvId = migrated.id; await chrome.storage.local.set({ envConfigs: resolvedEnvConfigs, - activeEnvConfigId: resolvedActiveEnvId + activeEnvConfigId: migrated.id }); } else { const withDefaults = resolvedEnvConfigs.map((config) => ({ @@ -928,11 +1114,12 @@ async function loadSettings() { await chrome.storage.local.set({ envConfigs: resolvedEnvConfigs }); } const hasActive = resolvedEnvConfigs.some( - (config) => config.id === resolvedActiveEnvId + (config) => config.id === activeEnvConfigId ); - if (!hasActive) { - resolvedActiveEnvId = resolvedEnvConfigs[0].id; - await chrome.storage.local.set({ activeEnvConfigId: resolvedActiveEnvId }); + if (!hasActive && resolvedEnvConfigs.length) { + await chrome.storage.local.set({ + activeEnvConfigId: resolvedEnvConfigs[0].id + }); } } @@ -941,21 +1128,44 @@ async function loadSettings() { envConfigsContainer.appendChild(buildEnvConfigCard(config)); } updateEnvApiOptions(); - updateEnvConfigSelect(resolvedActiveEnvId); + updateEnvControls(); tasksContainer.innerHTML = ""; - if (!tasks.length) { + const defaultEnvId = resolvedEnvConfigs[0]?.id || ""; + const normalizedTasks = Array.isArray(tasks) + ? tasks.map((task) => ({ + ...task, + defaultEnvId: task.defaultEnvId || defaultEnvId + })) + : []; + if ( + normalizedTasks.length && + normalizedTasks.some( + (task, index) => task.defaultEnvId !== tasks[index]?.defaultEnvId + ) + ) { + await chrome.storage.local.set({ tasks: normalizedTasks }); + } + + if (!normalizedTasks.length) { tasksContainer.appendChild( - buildTaskCard({ id: newTaskId(), name: "", text: "" }) + buildTaskCard({ + id: newTaskId(), + name: "", + text: "", + defaultEnvId + }) ); updateTaskControls(); + updateTaskEnvOptions(); return; } - for (const task of tasks) { + for (const task of normalizedTasks) { tasksContainer.appendChild(buildTaskCard(task)); } updateTaskControls(); + updateTaskEnvOptions(); } async function saveSettings() { @@ -963,11 +1173,8 @@ async function saveSettings() { const apiKeys = collectApiKeys(); const apiConfigs = collectApiConfigs(); const envConfigs = collectEnvConfigs(); - const activeEnvConfigId = - envConfigs.find((entry) => entry.id === activeEnvConfigSelect.value)?.id || - envConfigs[0]?.id || - ""; - const activeEnv = envConfigs.find((entry) => entry.id === activeEnvConfigId); + const activeEnvConfigId = envConfigs[0]?.id || ""; + const activeEnv = envConfigs[0]; const activeApiConfigId = activeEnv?.apiConfigId || apiConfigs[0]?.id || ""; const activeConfig = apiConfigs.find((entry) => entry.id === activeApiConfigId); @@ -995,7 +1202,12 @@ addTaskBtn.addEventListener("click", () => { const name = buildUniqueDefaultName( collectNames(tasksContainer, ".task-name") ); - const newCard = buildTaskCard({ id: newTaskId(), name, text: "" }); + const newCard = buildTaskCard({ + id: newTaskId(), + name, + text: "", + defaultEnvId: getTopEnvId() + }); const first = tasksContainer.firstElementChild; if (first) { tasksContainer.insertBefore(newCard, first); @@ -1003,6 +1215,7 @@ addTaskBtn.addEventListener("click", () => { tasksContainer.appendChild(newCard); } updateTaskControls(); + updateTaskEnvOptions(); }); addApiKeyBtn.addEventListener("click", () => { @@ -1017,6 +1230,7 @@ addApiKeyBtn.addEventListener("click", () => { apiKeysContainer.appendChild(newCard); } updateApiConfigKeyOptions(); + updateApiKeyControls(); }); addApiConfigBtn.addEventListener("click", () => { @@ -1042,6 +1256,7 @@ addApiConfigBtn.addEventListener("click", () => { } updateApiConfigKeyOptions(); updateEnvApiOptions(); + updateApiConfigControls(); }); addEnvConfigBtn.addEventListener("click", () => { @@ -1062,11 +1277,8 @@ addEnvConfigBtn.addEventListener("click", () => { envConfigsContainer.appendChild(newCard); } updateEnvApiOptions(); - updateEnvConfigSelect(newCard.dataset.id); -}); - -activeEnvConfigSelect.addEventListener("change", () => { - updateEnvConfigSelect(activeEnvConfigSelect.value); + updateEnvControls(); + updateTaskEnvOptions(); }); themeSelect.addEventListener("change", () => applyTheme(themeSelect.value)); -- 2.34.1 From 08f7ee8055840fb031b6ca900f8c9a4b3a3b5fe7 Mon Sep 17 00:00:00 2001 From: Peisong Xiao Date: Sat, 17 Jan 2026 17:37:52 -0500 Subject: [PATCH 5/5] updated version number --- wwcompanion-extension/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wwcompanion-extension/manifest.json b/wwcompanion-extension/manifest.json index ad3f641..3df4559 100644 --- a/wwcompanion-extension/manifest.json +++ b/wwcompanion-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "WWCompanion", - "version": "0.2.4", + "version": "0.3.0", "description": "AI companion for WaterlooWorks job postings.", "permissions": ["storage", "activeTab"], "host_permissions": ["https://waterlooworks.uwaterloo.ca/*"], -- 2.34.1