From b08495815f20aa886e7e178ac1cef7ae32946dc5 Mon Sep 17 00:00:00 2001 From: Peisong Xiao Date: Sat, 17 Jan 2026 22:38:36 +0000 Subject: [PATCH] dev-multi-env: modularized environments (#1) Reviewed-on: https://git.peisongxiao.com/peisongxiao/wwcompanion/pulls/1 --- wwcompanion-extension/background.js | 422 +++++++++-- wwcompanion-extension/manifest.json | 2 +- wwcompanion-extension/popup.css | 37 +- wwcompanion-extension/popup.html | 22 +- wwcompanion-extension/popup.js | 256 +++++-- wwcompanion-extension/settings.css | 86 ++- wwcompanion-extension/settings.html | 99 ++- wwcompanion-extension/settings.js | 1086 +++++++++++++++++++++++++-- 8 files changed, 1785 insertions(+), 225 deletions(-) diff --git a/wwcompanion-extension/background.js b/wwcompanion-extension/background.js index fb8b610..873b18c 100644 --- a/wwcompanion-extension/background.js +++ b/wwcompanion-extension/background.js @@ -15,6 +15,12 @@ const DEFAULT_TASKS = [ const DEFAULT_SETTINGS = { apiKey: "", + apiKeys: [], + activeApiKeyId: "", + apiConfigs: [], + activeApiConfigId: "", + envConfigs: [], + activeEnvConfigId: "", apiBaseUrl: "https://api.openai.com/v1", apiKeyHeader: "Authorization", apiKeyPrefix: "Bearer ", @@ -80,6 +86,186 @@ 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; + } 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; + } + } + + 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; + } + } + + 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); } @@ -175,6 +361,9 @@ async function handleAnalysisRequest(port, payload, signal) { const { apiKey, + apiMode, + apiUrl, + requestTemplate, apiBaseUrl, apiKeyHeader, apiKeyPrefix, @@ -186,19 +375,35 @@ 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; + } + if (apiKeyHeader && !apiKey) { + safePost(port, { type: "ERROR", message: "Missing API key." }); + 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) { @@ -217,20 +422,39 @@ 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, + apiKeyHeader, + apiKeyPrefix, + apiBaseUrl, + model, + 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 { @@ -255,6 +479,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, @@ -296,39 +587,54 @@ 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, + apiKeyHeader, + apiKeyPrefix, + apiBaseUrl, + model, + systemPrompt, + userMessage, + signal, + onDelta +}) { + const replacements = { + PROMPT_GOES_HERE: userMessage, + SYSTEM_PROMPT_GOES_HERE: systemPrompt, + 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, + 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/manifest.json b/wwcompanion-extension/manifest.json index c0fd612..3df4559 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.3.0", "description": "AI companion for WaterlooWorks job postings.", "permissions": ["storage", "activeTab"], "host_permissions": ["https://waterlooworks.uwaterloo.ca/*"], 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 293a044..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(); } @@ -428,30 +522,96 @@ async function handleAnalyze() { return; } - const { apiKey, apiBaseUrl, apiKeyHeader, apiKeyPrefix, model, systemPrompt, resume } = - await getStorage([ - "apiKey", - "apiBaseUrl", - "apiKeyHeader", - "apiKeyPrefix", - "model", - "systemPrompt", - "resume" - ]); + const { + apiKeys = [], + activeApiKeyId = "", + apiConfigs = [], + activeApiConfigId = "", + envConfigs = [], + apiBaseUrl, + apiKeyHeader, + apiKeyPrefix, + model, + systemPrompt, + resume + } = await getStorage([ + "apiKeys", + "activeApiKeyId", + "apiConfigs", + "activeApiConfigId", + "envConfigs", + "apiBaseUrl", + "apiKeyHeader", + "apiKeyPrefix", + "model", + "systemPrompt", + "resume" + ]); - if (!apiBaseUrl) { - setStatus("Set an API base URL in Settings."); + const resolvedConfigs = Array.isArray(apiConfigs) ? apiConfigs : []; + const resolvedEnvs = Array.isArray(envConfigs) ? envConfigs : []; + const selectedEnvId = envSelect.value; + const activeEnv = + resolvedEnvs.find((entry) => entry.id === selectedEnvId) || + resolvedEnvs[0]; + if (!activeEnv) { + setStatus("Add an environment in Settings."); return; } - - if (apiKeyHeader && !apiKey) { - setStatus("Add your API key in Settings."); + const resolvedSystemPrompt = + activeEnv.systemPrompt ?? systemPrompt ?? ""; + const resolvedApiConfigId = + activeEnv.apiConfigId || activeApiConfigId || resolvedConfigs[0]?.id || ""; + const activeConfig = + 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 = activeConfig?.apiBaseUrl || apiBaseUrl || ""; + const resolvedApiKeyHeader = activeConfig?.apiKeyHeader ?? apiKeyHeader ?? ""; + const resolvedApiKeyPrefix = activeConfig?.apiKeyPrefix ?? apiKeyPrefix ?? ""; + const resolvedModel = activeConfig?.model || model || ""; - if (!model) { - setStatus("Set a model name in Settings."); - return; + const resolvedKeys = Array.isArray(apiKeys) ? apiKeys : []; + const resolvedKeyId = + activeConfig?.apiKeyId || activeApiKeyId || resolvedKeys[0]?.id || ""; + const activeKey = resolvedKeys.find((entry) => entry.id === resolvedKeyId); + const apiKey = activeKey?.key || ""; + + if (isAdvanced) { + if (!resolvedApiUrl) { + setStatus("Set an API URL in Settings."); + return; + } + if (!resolvedTemplate) { + setStatus("Set a request template in Settings."); + return; + } + const needsKey = + Boolean(resolvedApiKeyHeader) || + resolvedTemplate.includes("API_KEY_GOES_HERE"); + if (needsKey && !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); @@ -467,11 +627,14 @@ async function handleAnalyze() { type: "START_ANALYSIS", payload: { apiKey, - apiBaseUrl, - apiKeyHeader, - apiKeyPrefix, - model, - systemPrompt: systemPrompt || "", + apiMode: isAdvanced ? "advanced" : "basic", + apiUrl: resolvedApiUrl, + requestTemplate: resolvedTemplate, + apiBaseUrl: resolvedApiBaseUrl, + apiKeyHeader: resolvedApiKeyHeader, + apiKeyPrefix: resolvedApiKeyPrefix, + model: resolvedModel, + systemPrompt: resolvedSystemPrompt, resume: resume || "", taskText: task.text || "", postingText: state.postingText, @@ -527,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() { @@ -562,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.css b/wwcompanion-extension/settings.css index 3d0c33d..21f63bf 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; @@ -229,6 +234,83 @@ 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, +.api-config-actions .delete, +.env-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; +} + +.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"] { @@ -251,7 +333,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 4a144af..b0ef5e5 100644 --- a/wwcompanion-extension/settings.html +++ b/wwcompanion-extension/settings.html @@ -16,49 +16,6 @@
-
- - -

API

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

System Prompt

+

API KEYS

- +
+
+ +
+
@@ -102,7 +59,49 @@ -

Resume

+

API

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

Environment

+ API configuration and system prompt go here +
+
+
+
+
+ +
+
+
+
+ +
+ + +
+

Resume

+ Text to your profile goes here +
diff --git a/wwcompanion-extension/settings.js b/wwcompanion-extension/settings.js index 1c1adf4..61ea3fb 100644 --- a/wwcompanion-extension/settings.js +++ b/wwcompanion-extension/settings.js @@ -1,23 +1,24 @@ -const apiKeyInput = document.getElementById("apiKey"); -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 addApiKeyBtn = document.getElementById("addApiKeyBtn"); +const apiKeysContainer = document.getElementById("apiKeys"); +const addEnvConfigBtn = document.getElementById("addEnvConfigBtn"); +const envConfigsContainer = document.getElementById("envConfigs"); const addTaskBtn = document.getElementById("addTaskBtn"); const tasksContainer = document.getElementById("tasks"); const statusEl = document.getElementById("status"); -const toggleKeyBtn = document.getElementById("toggleKey"); 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"; +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)); @@ -41,6 +42,748 @@ function newTaskId() { return `task-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; } +function newApiKeyId() { + if (crypto?.randomUUID) return crypto.randomUUID(); + 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 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)] + .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 getTopEnvId() { + return collectEnvConfigs()[0]?.id || ""; +} + +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 = false; + + const advancedBtn = card.querySelector(".advanced-toggle"); + if (advancedBtn) advancedBtn.disabled = isAdvanced; +} + +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 advanced-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 advanced-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"; + 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"; + 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"; + 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(); + updateEnvApiOptions(); + }); + actions.appendChild(duplicateBtn); + + const resetBtn = document.createElement("button"); + resetBtn.type = "button"; + resetBtn.className = "ghost reset-openai"; + resetBtn.textContent = "Reset to OpenAI"; + resetBtn.addEventListener("click", () => { + baseInput.value = OPENAI_DEFAULTS.apiBaseUrl; + headerInput.value = OPENAI_DEFAULTS.apiKeyHeader; + prefixInput.value = OPENAI_DEFAULTS.apiKeyPrefix; + modelInput.value = DEFAULT_MODEL; + urlInput.value = ""; + templateInput.value = ""; + setApiConfigAdvanced(card, false); + updateEnvApiOptions(); + }); + actions.appendChild(resetBtn); + + const deleteBtn = document.createElement("button"); + deleteBtn.type = "button"; + deleteBtn.className = "ghost delete"; + deleteBtn.textContent = "Delete"; + deleteBtn.addEventListener("click", () => { + card.remove(); + updateEnvApiOptions(); + updateApiConfigControls(); + }); + actions.appendChild(deleteBtn); + + const updateSelect = () => updateEnvApiOptions(); + 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 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"; + card.dataset.id = entry.id || newApiKeyId(); + + 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 = entry.name || ""; + nameInput.className = "api-key-name"; + nameField.appendChild(nameLabel); + nameField.appendChild(nameInput); + + const keyField = document.createElement("div"); + keyField.className = "field"; + const keyLabel = document.createElement("label"); + keyLabel.textContent = "Key"; + const keyInline = document.createElement("div"); + keyInline.className = "inline"; + const keyInput = document.createElement("input"); + keyInput.type = "password"; + keyInput.autocomplete = "off"; + keyInput.placeholder = "sk-..."; + keyInput.value = entry.key || ""; + keyInput.className = "api-key-value"; + const showBtn = document.createElement("button"); + showBtn.type = "button"; + showBtn.className = "ghost"; + showBtn.textContent = "Show"; + showBtn.addEventListener("click", () => { + const isPassword = keyInput.type === "password"; + keyInput.type = isPassword ? "text" : "password"; + showBtn.textContent = isPassword ? "Hide" : "Show"; + }); + keyInline.appendChild(keyInput); + keyInline.appendChild(showBtn); + keyField.appendChild(keyLabel); + keyField.appendChild(keyInline); + + 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"; + deleteBtn.textContent = "Delete"; + deleteBtn.addEventListener("click", () => { + card.remove(); + updateApiConfigKeyOptions(); + updateApiKeyControls(); + }); + actions.appendChild(deleteBtn); + + const updateSelect = () => updateApiConfigKeyOptions(); + nameInput.addEventListener("input", updateSelect); + keyInput.addEventListener("input", updateSelect); + + card.appendChild(nameField); + card.appendChild(keyField); + card.appendChild(actions); + + return card; +} + +function collectApiKeys() { + const cards = [...apiKeysContainer.querySelectorAll(".api-key-card")]; + return cards.map((card) => { + const nameInput = card.querySelector(".api-key-name"); + const keyInput = card.querySelector(".api-key-value"); + return { + id: card.dataset.id || newApiKeyId(), + name: (nameInput?.value || "Default").trim(), + key: (keyInput?.value || "").trim() + }; + }); +} + +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"); + 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; + } + + 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); + } + + if (preferred && keys.some((key) => key.id === preferred)) { + select.value = preferred; + } else { + select.value = keys[0].id; + } + + select.dataset.preferred = select.value; + }); +} + +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 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"; + 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(); + updateEnvControls(); + updateTaskEnvOptions(); + }); + + const deleteBtn = document.createElement("button"); + deleteBtn.type = "button"; + deleteBtn.className = "ghost delete"; + deleteBtn.textContent = "Delete"; + deleteBtn.addEventListener("click", () => { + card.remove(); + updateEnvControls(); + updateTaskEnvOptions(); + }); + + actions.appendChild(duplicateBtn); + actions.appendChild(deleteBtn); + nameInput.addEventListener("input", () => updateEnvApiOptions()); + + card.appendChild(nameField); + card.appendChild(apiField); + card.appendChild(promptField); + card.appendChild(actions); + + 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) => { + 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 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; + }); + updateTaskEnvOptions(); +} + function buildTaskCard(task) { const card = document.createElement("div"); card.className = "task-card"; @@ -57,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"); @@ -129,20 +882,34 @@ 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: "", + defaultEnvId: getTopEnvId() + }); card.insertAdjacentElement("afterend", newCard); updateTaskControls(); + updateTaskEnvOptions(); }); duplicateBtn.addEventListener("click", () => { const copy = { id: newTaskId(), - name: `${nameInput.value || "Untitled"} Copy`, - text: textArea.value + name: ensureUniqueName( + `${nameInput.value || "Untitled"} Copy`, + collectNames(tasksContainer, ".task-name") + ), + text: textArea.value, + defaultEnvId: envSelect.value || "" }; const newCard = buildTaskCard(copy); card.insertAdjacentElement("afterend", newCard); updateTaskControls(); + updateTaskEnvOptions(); }); deleteBtn.addEventListener("click", () => { @@ -158,6 +925,7 @@ function buildTaskCard(task) { actions.appendChild(deleteBtn); card.appendChild(nameField); + card.appendChild(envField); card.appendChild(textField); card.appendChild(actions); @@ -181,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 || "" }; }); } @@ -192,6 +962,12 @@ function collectTasks() { async function loadSettings() { const { apiKey = "", + apiKeys = [], + activeApiKeyId = "", + apiConfigs = [], + activeApiConfigId = "", + envConfigs = [], + activeEnvConfigId = "", apiBaseUrl = "", apiKeyHeader = "", apiKeyPrefix = "", @@ -202,6 +978,12 @@ async function loadSettings() { theme = "system" } = await getStorage([ "apiKey", + "apiKeys", + "activeApiKeyId", + "apiConfigs", + "activeApiConfigId", + "envConfigs", + "activeEnvConfigId", "apiBaseUrl", "apiKeyHeader", "apiKeyPrefix", @@ -212,40 +994,202 @@ async function loadSettings() { "theme" ]); - apiKeyInput.value = apiKey; - apiBaseUrlInput.value = apiBaseUrl; - apiKeyHeaderInput.value = apiKeyHeader; - apiKeyPrefixInput.value = apiKeyPrefix; - modelInput.value = model; - systemPromptInput.value = systemPrompt; resumeInput.value = resume; themeSelect.value = theme; applyTheme(theme); + let resolvedKeys = Array.isArray(apiKeys) ? apiKeys : []; + let resolvedActiveId = activeApiKeyId; + + if (!resolvedKeys.length && apiKey) { + const migrated = { id: newApiKeyId(), name: "Default", key: apiKey }; + resolvedKeys = [migrated]; + resolvedActiveId = migrated.id; + await chrome.storage.local.set({ + 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: "" }) + ); + } else { + for (const entry of resolvedKeys) { + apiKeysContainer.appendChild(buildApiKeyCard(entry)); + } + } + updateApiKeyControls(); + + 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(); + updateApiConfigControls(); + + let resolvedEnvConfigs = Array.isArray(envConfigs) ? envConfigs : []; + const fallbackApiConfigId = + resolvedActiveConfigId || resolvedConfigs[0]?.id || ""; + + if (!resolvedEnvConfigs.length) { + const migrated = { + id: newEnvConfigId(), + name: "Default", + apiConfigId: fallbackApiConfigId, + systemPrompt: systemPrompt || DEFAULT_SYSTEM_PROMPT + }; + resolvedEnvConfigs = [migrated]; + await chrome.storage.local.set({ + envConfigs: resolvedEnvConfigs, + activeEnvConfigId: migrated.id + }); + } 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 === activeEnvConfigId + ); + if (!hasActive && resolvedEnvConfigs.length) { + await chrome.storage.local.set({ + activeEnvConfigId: resolvedEnvConfigs[0].id + }); + } + } + + envConfigsContainer.innerHTML = ""; + for (const config of resolvedEnvConfigs) { + envConfigsContainer.appendChild(buildEnvConfigCard(config)); + } + updateEnvApiOptions(); + 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() { const tasks = collectTasks(); + const apiKeys = collectApiKeys(); + const apiConfigs = collectApiConfigs(); + const envConfigs = collectEnvConfigs(); + const activeEnvConfigId = envConfigs[0]?.id || ""; + const activeEnv = envConfigs[0]; + const activeApiConfigId = + activeEnv?.apiConfigId || apiConfigs[0]?.id || ""; + const activeConfig = apiConfigs.find((entry) => entry.id === activeApiConfigId); + const activeApiKeyId = + activeConfig?.apiKeyId || + apiKeys[0]?.id || + ""; await chrome.storage.local.set({ - apiKey: apiKeyInput.value.trim(), - apiBaseUrl: apiBaseUrlInput.value.trim(), - apiKeyHeader: apiKeyHeaderInput.value.trim(), - apiKeyPrefix: apiKeyPrefixInput.value, - model: modelInput.value.trim(), - systemPrompt: systemPromptInput.value, + apiKeys, + activeApiKeyId, + apiConfigs, + activeApiConfigId, + envConfigs, + activeEnvConfigId, + systemPrompt: activeEnv?.systemPrompt || "", resume: resumeInput.value, tasks, theme: themeSelect.value @@ -255,7 +1199,15 @@ 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: "", + defaultEnvId: getTopEnvId() + }); const first = tasksContainer.firstElementChild; if (first) { tasksContainer.insertBefore(newCard, first); @@ -263,25 +1215,73 @@ addTaskBtn.addEventListener("click", () => { tasksContainer.appendChild(newCard); } updateTaskControls(); + updateTaskEnvOptions(); }); -toggleKeyBtn.addEventListener("click", () => { - const isPassword = apiKeyInput.type === "password"; - apiKeyInput.type = isPassword ? "text" : "password"; - toggleKeyBtn.textContent = isPassword ? "Hide" : "Show"; +addApiKeyBtn.addEventListener("click", () => { + 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); + } + updateApiConfigKeyOptions(); + updateApiKeyControls(); +}); + +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(); + updateEnvApiOptions(); + updateApiConfigControls(); +}); + +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(); + updateEnvControls(); + updateTaskEnvOptions(); }); 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();