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 @@