diff --git a/background.js b/background.js index 3af8b82..6720f3e 100644 --- a/background.js +++ b/background.js @@ -9,6 +9,9 @@ const DEFAULT_TASKS = [ const DEFAULT_SETTINGS = { apiKey: "", + apiBaseUrl: "https://api.openai.com/v1", + apiKeyHeader: "Authorization", + apiKeyPrefix: "Bearer ", model: "gpt-4o-mini", systemPrompt: "You are a precise, honest assistant. Be concise, highlight uncertainties, and avoid inventing details.", @@ -89,10 +92,25 @@ function buildUserMessage(resume, task, posting) { } async function handleAnalysisRequest(port, payload, signal) { - const { apiKey, model, systemPrompt, resume, taskText, postingText } = payload || {}; + const { + apiKey, + apiBaseUrl, + apiKeyHeader, + apiKeyPrefix, + model, + systemPrompt, + resume, + taskText, + postingText + } = payload || {}; - if (!apiKey) { - port.postMessage({ type: "ERROR", message: "Missing OpenAI API key." }); + if (!apiBaseUrl) { + port.postMessage({ type: "ERROR", message: "Missing API base URL." }); + return; + } + + if (apiKeyHeader && !apiKey) { + port.postMessage({ type: "ERROR", message: "Missing API key." }); return; } @@ -115,6 +133,9 @@ async function handleAnalysisRequest(port, payload, signal) { await streamChatCompletion({ apiKey, + apiBaseUrl, + apiKeyHeader, + apiKeyPrefix, model, systemPrompt: systemPrompt || "", userMessage, @@ -125,20 +146,49 @@ async function handleAnalysisRequest(port, payload, signal) { port.postMessage({ type: "DONE" }); } +function buildChatUrl(apiBaseUrl) { + const trimmed = (apiBaseUrl || "").trim().replace(/\/+$/, ""); + if (!trimmed) return ""; + if (trimmed.endsWith("/chat/completions")) return trimmed; + return `${trimmed}/chat/completions`; +} + +function buildAuthHeader(apiKeyHeader, apiKeyPrefix, apiKey) { + if (!apiKeyHeader) return null; + return { + name: apiKeyHeader, + value: `${apiKeyPrefix || ""}${apiKey || ""}` + }; +} + async function streamChatCompletion({ apiKey, + apiBaseUrl, + apiKeyHeader, + apiKeyPrefix, model, systemPrompt, userMessage, signal, onDelta }) { - const response = await fetch("https://api.openai.com/v1/chat/completions", { + const chatUrl = buildChatUrl(apiBaseUrl); + if (!chatUrl) { + throw new Error("Invalid API base URL."); + } + + const headers = { + "Content-Type": "application/json" + }; + + const authHeader = buildAuthHeader(apiKeyHeader, apiKeyPrefix, apiKey); + if (authHeader) { + headers[authHeader.name] = authHeader.value; + } + + const response = await fetch(chatUrl, { method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}` - }, + headers, body: JSON.stringify({ model, stream: true, diff --git a/popup.css b/popup.css index da830bc..7d8b850 100644 --- a/popup.css +++ b/popup.css @@ -128,16 +128,31 @@ select { } .button-row { - display: flex; + display: grid; + grid-template-columns: minmax(64px, 0.8fr) minmax(0, 1.4fr) minmax(64px, 0.8fr); gap: 8px; } .button-row button { - flex: 1; + white-space: nowrap; } -.button-row .ghost { - flex: 0 0 64px; +.button-row .primary { + padding: 6px 8px; +} + +.stop-row { + display: flex; + justify-content: stretch; + margin-top: 4px; +} + +.stop-row button { + width: 100%; +} + +.hidden { + display: none; } button { @@ -177,6 +192,12 @@ button:active { border: 1px solid var(--border); } +.stop-row .ghost { + background: #c0392b; + border-color: #c0392b; + color: #fff6f2; +} + .meta { display: flex; justify-content: space-between; diff --git a/popup.html b/popup.html index 5b507ce..4b4e84a 100644 --- a/popup.html +++ b/popup.html @@ -22,8 +22,11 @@
- - + + + +
+ diff --git a/popup.js b/popup.js index 2d6694f..867a91c 100644 --- a/popup.js +++ b/popup.js @@ -1,6 +1,9 @@ const extractBtn = document.getElementById("extractBtn"); const analyzeBtn = document.getElementById("analyzeBtn"); 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 outputEl = document.getElementById("output"); const statusEl = document.getElementById("status"); @@ -226,6 +229,11 @@ function setAnalyzing(isAnalyzing) { analyzeBtn.disabled = isAnalyzing; abortBtn.disabled = !isAnalyzing; extractBtn.disabled = isAnalyzing; + extractRunBtn.disabled = isAnalyzing; + if (buttonRow && stopRow) { + buttonRow.classList.toggle("hidden", isAnalyzing); + stopRow.classList.toggle("hidden", !isAnalyzing); + } } function updatePostingCount() { @@ -333,15 +341,17 @@ async function handleExtract() { const response = await sendToActiveTab({ type: "EXTRACT_POSTING" }); if (!response?.ok) { setStatus(response?.error || "No posting detected."); - return; + return false; } state.postingText = response.sanitized || ""; updatePostingCount(); updatePromptCount(0); setStatus("Posting extracted."); + return true; } catch (error) { setStatus(error.message || "Unable to extract posting."); + return false; } } @@ -358,15 +368,24 @@ async function handleAnalyze() { return; } - const { apiKey, model, systemPrompt, resume } = await getStorage([ - "apiKey", - "model", - "systemPrompt", - "resume" - ]); + const { apiKey, apiBaseUrl, apiKeyHeader, apiKeyPrefix, model, systemPrompt, resume } = + await getStorage([ + "apiKey", + "apiBaseUrl", + "apiKeyHeader", + "apiKeyPrefix", + "model", + "systemPrompt", + "resume" + ]); - if (!apiKey) { - setStatus("Add your OpenAI API key in Settings."); + if (!apiBaseUrl) { + setStatus("Set an API base URL in Settings."); + return; + } + + if (apiKeyHeader && !apiKey) { + setStatus("Add your API key in Settings."); return; } @@ -388,6 +407,9 @@ async function handleAnalyze() { type: "START_ANALYSIS", payload: { apiKey, + apiBaseUrl, + apiKeyHeader, + apiKeyPrefix, model, systemPrompt: systemPrompt || "", resume: resume || "", @@ -397,6 +419,12 @@ async function handleAnalyze() { }); } +async function handleExtractAndAnalyze() { + const extracted = await handleExtract(); + if (!extracted) return; + await handleAnalyze(); +} + function handleAbort() { if (!state.port) return; state.port.postMessage({ type: "ABORT_ANALYSIS" }); @@ -406,12 +434,14 @@ function handleAbort() { extractBtn.addEventListener("click", handleExtract); analyzeBtn.addEventListener("click", handleAnalyze); +extractRunBtn.addEventListener("click", handleExtractAndAnalyze); abortBtn.addEventListener("click", handleAbort); settingsBtn.addEventListener("click", () => chrome.runtime.openOptionsPage()); updatePostingCount(); updatePromptCount(0); renderOutput(); +setAnalyzing(false); loadTasks(); loadTheme(); diff --git a/settings.css b/settings.css index 31acd74..9dd8b1d 100644 --- a/settings.css +++ b/settings.css @@ -46,6 +46,14 @@ body { margin-bottom: 16px; } +.page-bar { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + .title { font-size: 26px; font-weight: 700; @@ -73,6 +81,13 @@ body { margin-bottom: 12px; } +.row-title { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; +} + h2 { margin: 0; font-size: 16px; @@ -81,6 +96,17 @@ h2 { color: var(--muted); } +.hint { + font-size: 12px; + text-transform: none; + letter-spacing: 0; + color: var(--muted); +} + +.hint-accent { + color: var(--accent); +} + .field { display: grid; gap: 6px; @@ -179,6 +205,17 @@ button:active { .task-actions { display: flex; - gap: 8px; + gap: 6px; justify-content: flex-end; } + +.icon-btn { + width: 34px; + padding: 6px 0; + font-weight: 700; + line-height: 1; +} + +.icon-btn.delete { + color: #c0392b; +} diff --git a/settings.html b/settings.html index 59507bc..5b7c1ec 100644 --- a/settings.html +++ b/settings.html @@ -11,11 +11,15 @@
WWCompanion Settings
Configure prompts, resume, and API access
+
+
+ +
-

OpenAI

- +

API

+
@@ -24,11 +28,26 @@
+
+ + +
+
+ + +
+
+ + +
-
@@ -55,7 +74,10 @@
-

Task Presets

+
+

Task Presets

+ Top task is the default +
diff --git a/settings.js b/settings.js index c6dc9cb..39f0de1 100644 --- a/settings.js +++ b/settings.js @@ -1,4 +1,7 @@ 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"); @@ -8,6 +11,13 @@ 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 " +}; function getStorage(keys) { return new Promise((resolve) => chrome.storage.local.get(keys, resolve)); @@ -60,14 +70,56 @@ function buildTaskCard(task) { const actions = document.createElement("div"); actions.className = "task-actions"; + const moveUpBtn = document.createElement("button"); + moveUpBtn.type = "button"; + moveUpBtn.className = "ghost icon-btn move-up"; + moveUpBtn.textContent = "↑"; + moveUpBtn.setAttribute("aria-label", "Move task up"); + moveUpBtn.setAttribute("title", "Move up"); + const moveDownBtn = document.createElement("button"); + moveDownBtn.type = "button"; + moveDownBtn.className = "ghost icon-btn move-down"; + moveDownBtn.textContent = "↓"; + moveDownBtn.setAttribute("aria-label", "Move task down"); + moveDownBtn.setAttribute("title", "Move down"); + const addBelowBtn = document.createElement("button"); + addBelowBtn.type = "button"; + addBelowBtn.className = "ghost icon-btn add-below"; + addBelowBtn.textContent = "+"; + addBelowBtn.setAttribute("aria-label", "Add task below"); + addBelowBtn.setAttribute("title", "Add below"); const duplicateBtn = document.createElement("button"); duplicateBtn.type = "button"; - duplicateBtn.className = "ghost"; + duplicateBtn.className = "ghost duplicate"; duplicateBtn.textContent = "Duplicate"; + duplicateBtn.setAttribute("aria-label", "Duplicate task"); + duplicateBtn.setAttribute("title", "Duplicate"); const deleteBtn = document.createElement("button"); deleteBtn.type = "button"; - deleteBtn.className = "ghost"; - deleteBtn.textContent = "Delete"; + deleteBtn.className = "ghost icon-btn delete"; + deleteBtn.textContent = "🗑"; + deleteBtn.setAttribute("aria-label", "Delete task"); + deleteBtn.setAttribute("title", "Delete"); + + moveUpBtn.addEventListener("click", () => { + const previous = card.previousElementSibling; + if (!previous) return; + tasksContainer.insertBefore(card, previous); + updateTaskControls(); + }); + + moveDownBtn.addEventListener("click", () => { + const next = card.nextElementSibling; + if (!next) return; + tasksContainer.insertBefore(card, next.nextElementSibling); + updateTaskControls(); + }); + + addBelowBtn.addEventListener("click", () => { + const newCard = buildTaskCard({ id: newTaskId(), name: "", text: "" }); + card.insertAdjacentElement("afterend", newCard); + updateTaskControls(); + }); duplicateBtn.addEventListener("click", () => { const copy = { @@ -77,12 +129,17 @@ function buildTaskCard(task) { }; const newCard = buildTaskCard(copy); card.insertAdjacentElement("afterend", newCard); + updateTaskControls(); }); deleteBtn.addEventListener("click", () => { card.remove(); + updateTaskControls(); }); + actions.appendChild(moveUpBtn); + actions.appendChild(moveDownBtn); + actions.appendChild(addBelowBtn); actions.appendChild(duplicateBtn); actions.appendChild(deleteBtn); @@ -93,6 +150,16 @@ function buildTaskCard(task) { return card; } +function updateTaskControls() { + const cards = [...tasksContainer.querySelectorAll(".task-card")]; + cards.forEach((card, index) => { + const moveUpBtn = card.querySelector(".move-up"); + const moveDownBtn = card.querySelector(".move-down"); + if (moveUpBtn) moveUpBtn.disabled = index === 0; + if (moveDownBtn) moveDownBtn.disabled = index === cards.length - 1; + }); +} + function collectTasks() { const cards = [...tasksContainer.querySelectorAll(".task-card")]; return cards.map((card) => { @@ -109,14 +176,30 @@ function collectTasks() { async function loadSettings() { const { apiKey = "", + apiBaseUrl = "", + apiKeyHeader = "", + apiKeyPrefix = "", model = "", systemPrompt = "", resume = "", tasks = [], theme = "system" - } = await getStorage(["apiKey", "model", "systemPrompt", "resume", "tasks", "theme"]); + } = await getStorage([ + "apiKey", + "apiBaseUrl", + "apiKeyHeader", + "apiKeyPrefix", + "model", + "systemPrompt", + "resume", + "tasks", + "theme" + ]); apiKeyInput.value = apiKey; + apiBaseUrlInput.value = apiBaseUrl; + apiKeyHeaderInput.value = apiKeyHeader; + apiKeyPrefixInput.value = apiKeyPrefix; modelInput.value = model; systemPromptInput.value = systemPrompt; resumeInput.value = resume; @@ -128,18 +211,23 @@ async function loadSettings() { tasksContainer.appendChild( buildTaskCard({ id: newTaskId(), name: "", text: "" }) ); + updateTaskControls(); return; } for (const task of tasks) { tasksContainer.appendChild(buildTaskCard(task)); } + updateTaskControls(); } async function saveSettings() { const tasks = collectTasks(); 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, resume: resumeInput.value, @@ -152,6 +240,7 @@ async function saveSettings() { saveBtn.addEventListener("click", () => void saveSettings()); addTaskBtn.addEventListener("click", () => { tasksContainer.appendChild(buildTaskCard({ id: newTaskId(), name: "", text: "" })); + updateTaskControls(); }); toggleKeyBtn.addEventListener("click", () => { @@ -161,5 +250,16 @@ toggleKeyBtn.addEventListener("click", () => { }); 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."); +}); loadSettings();