diff --git a/background.js b/background.js index 6720f3e..960635e 100644 --- a/background.js +++ b/background.js @@ -20,6 +20,46 @@ const DEFAULT_SETTINGS = { theme: "system" }; +const OUTPUT_STORAGE_KEY = "lastOutput"; +const AUTO_RUN_KEY = "autoRunDefaultTask"; +let activeAbortController = null; +let keepalivePort = null; +const streamState = { + active: false, + outputText: "", + subscribers: new Set() +}; + +function resetAbort() { + if (activeAbortController) { + activeAbortController.abort(); + activeAbortController = null; + } + closeKeepalive(); +} + +function openKeepalive(tabId) { + if (!tabId || keepalivePort) return; + try { + keepalivePort = chrome.tabs.connect(tabId, { name: "wwcompanion-keepalive" }); + keepalivePort.onDisconnect.addListener(() => { + keepalivePort = null; + }); + } catch { + keepalivePort = null; + } +} + +function closeKeepalive() { + if (!keepalivePort) return; + try { + keepalivePort.disconnect(); + } catch { + // Ignore disconnect failures. + } + keepalivePort = null; +} + chrome.runtime.onInstalled.addListener(async () => { const stored = await chrome.storage.local.get(Object.keys(DEFAULT_SETTINGS)); const updates = {}; @@ -42,29 +82,42 @@ chrome.runtime.onInstalled.addListener(async () => { chrome.runtime.onConnect.addListener((port) => { if (port.name !== "analysis") return; - let abortController = null; + streamState.subscribers.add(port); + port.onDisconnect.addListener(() => { + streamState.subscribers.delete(port); + }); - const resetAbort = () => { - if (abortController) abortController.abort(); - abortController = null; - }; + if (streamState.active) { + safePost(port, { + type: "SYNC", + text: streamState.outputText, + streaming: true + }); + } port.onMessage.addListener((message) => { if (message?.type === "START_ANALYSIS") { + streamState.outputText = ""; resetAbort(); - abortController = new AbortController(); - void handleAnalysisRequest(port, message.payload, abortController.signal).catch( - (error) => { + const controller = new AbortController(); + activeAbortController = controller; + const request = handleAnalysisRequest(port, message.payload, controller.signal); + void request + .catch((error) => { if (error?.name === "AbortError") { - port.postMessage({ type: "ABORTED" }); + safePost(port, { type: "ABORTED" }); return; } - port.postMessage({ + safePost(port, { type: "ERROR", message: error?.message || "Unknown error during analysis." }); - } - ); + }) + .finally(() => { + if (activeAbortController === controller) { + activeAbortController = null; + } + }); return; } @@ -73,9 +126,14 @@ chrome.runtime.onConnect.addListener((port) => { } }); - port.onDisconnect.addListener(() => { - resetAbort(); - }); +}); + +chrome.runtime.onMessage.addListener((message) => { + if (message?.type !== "RUN_DEFAULT_TASK") return; + void chrome.storage.local.set({ [AUTO_RUN_KEY]: Date.now() }); + if (chrome.action?.openPopup) { + void chrome.action.openPopup().catch(() => {}); + } }); function buildUserMessage(resume, task, posting) { @@ -91,7 +149,24 @@ function buildUserMessage(resume, task, posting) { ].join("\n"); } +function safePost(port, message) { + try { + port.postMessage(message); + } catch { + // Port can disconnect when the popup closes; ignore post failures. + } +} + +function broadcast(message) { + for (const port of streamState.subscribers) { + safePost(port, message); + } +} + async function handleAnalysisRequest(port, payload, signal) { + streamState.outputText = ""; + streamState.active = true; + const { apiKey, apiBaseUrl, @@ -101,49 +176,62 @@ async function handleAnalysisRequest(port, payload, signal) { systemPrompt, resume, taskText, - postingText + postingText, + tabId } = payload || {}; if (!apiBaseUrl) { - port.postMessage({ type: "ERROR", message: "Missing API base URL." }); + safePost(port, { type: "ERROR", message: "Missing API base URL." }); return; } if (apiKeyHeader && !apiKey) { - port.postMessage({ type: "ERROR", message: "Missing API key." }); + safePost(port, { type: "ERROR", message: "Missing API key." }); return; } if (!model) { - port.postMessage({ type: "ERROR", message: "Missing model name." }); + safePost(port, { type: "ERROR", message: "Missing model name." }); return; } if (!postingText) { - port.postMessage({ type: "ERROR", message: "No job posting text provided." }); + safePost(port, { type: "ERROR", message: "No job posting text provided." }); return; } if (!taskText) { - port.postMessage({ type: "ERROR", message: "No task prompt selected." }); + safePost(port, { type: "ERROR", message: "No task prompt selected." }); return; } const userMessage = buildUserMessage(resume, taskText, postingText); - await streamChatCompletion({ - apiKey, - apiBaseUrl, - apiKeyHeader, - apiKeyPrefix, - model, - systemPrompt: systemPrompt || "", - userMessage, - signal, - onDelta: (text) => port.postMessage({ type: "DELTA", text }) - }); + await chrome.storage.local.set({ [OUTPUT_STORAGE_KEY]: "" }); + openKeepalive(tabId); - port.postMessage({ type: "DONE" }); + try { + await streamChatCompletion({ + apiKey, + apiBaseUrl, + apiKeyHeader, + apiKeyPrefix, + model, + systemPrompt: systemPrompt || "", + userMessage, + signal, + onDelta: (text) => { + streamState.outputText += text; + broadcast({ type: "DELTA", text }); + } + }); + + broadcast({ type: "DONE" }); + } finally { + streamState.active = false; + await chrome.storage.local.set({ [OUTPUT_STORAGE_KEY]: streamState.outputText }); + closeKeepalive(); + } } function buildChatUrl(apiBaseUrl) { diff --git a/content.js b/content.js index e3dcbdc..0a4d308 100644 --- a/content.js +++ b/content.js @@ -8,6 +8,14 @@ const HEADER_LINES = new Set([ "SERVICE TEAM" ]); +const ACTION_BAR_SELECTOR = "nav.floating--action-bar"; +const INJECTED_ATTR = "data-wwcompanion-default-task"; +const DEFAULT_TASK_LABEL = "Default WWCompanion Task"; + +function isJobPostingOpen() { + return document.getElementsByClassName("modal__content").length > 0; +} + function sanitizePostingText(text) { let cleaned = text.replaceAll("fiber_manual_record", ""); const lines = cleaned.split(/\r?\n/); @@ -23,6 +31,78 @@ function sanitizePostingText(text) { return cleaned.trim(); } +function buildDefaultTaskButton(templateButton) { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = DEFAULT_TASK_LABEL; + button.className = templateButton.className; + button.setAttribute(INJECTED_ATTR, "true"); + button.setAttribute("aria-label", DEFAULT_TASK_LABEL); + button.addEventListener("click", () => { + document.dispatchEvent(new CustomEvent("WWCOMPANION_RUN_DEFAULT_TASK")); + chrome.runtime.sendMessage({ type: "RUN_DEFAULT_TASK" }); + }); + return button; +} + +function getActionBars() { + return [...document.querySelectorAll(ACTION_BAR_SELECTOR)]; +} + +function getActionBarButtonCount(bar) { + return bar.querySelectorAll(`button:not([${INJECTED_ATTR}])`).length; +} + +function selectTargetActionBar(bars) { + if (!bars.length) return null; + let best = bars[0]; + let bestCount = getActionBarButtonCount(best); + for (const bar of bars.slice(1)) { + const count = getActionBarButtonCount(bar); + if (count > bestCount) { + best = bar; + bestCount = count; + } + } + return best; +} + +function ensureDefaultTaskButton() { + const bars = getActionBars(); + if (!bars.length) return; + + if (!isJobPostingOpen()) { + for (const bar of bars) { + const injected = bar.querySelector(`[${INJECTED_ATTR}]`); + if (injected) injected.remove(); + } + return; + } + + const toolbar = selectTargetActionBar(bars); + if (!toolbar) return; + + for (const bar of bars) { + if (bar === toolbar) continue; + const injected = bar.querySelector(`[${INJECTED_ATTR}]`); + if (injected) injected.remove(); + } + + const existing = toolbar.querySelector(`[${INJECTED_ATTR}]`); + if (existing) return; + + const templateButton = toolbar.querySelector("button"); + if (!templateButton) return; + + const button = buildDefaultTaskButton(templateButton); + const firstChild = toolbar.firstElementChild; + if (firstChild) { + toolbar.insertBefore(button, firstChild); + } else { + toolbar.appendChild(button); + } +} + function extractPostingText() { const contents = [...document.getElementsByClassName("modal__content")]; if (!contents.length) { @@ -46,3 +126,15 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { const result = extractPostingText(); sendResponse(result); }); + +chrome.runtime.onConnect.addListener((port) => { + if (port.name !== "wwcompanion-keepalive") return; + port.onDisconnect.addListener(() => {}); +}); + +const observer = new MutationObserver(() => { + ensureDefaultTaskButton(); +}); + +observer.observe(document.documentElement, { childList: true, subtree: true }); +ensureDefaultTaskButton(); diff --git a/manifest.json b/manifest.json index 2084734..5466dfd 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, "name": "WWCompanion", - "version": "0.1.4", - "description": "Manual reasoning companion for WaterlooWorks job postings.", + "version": "0.2.1", + "description": "AI companion for WaterlooWorks job postings.", "permissions": ["storage", "activeTab"], "host_permissions": ["https://waterlooworks.uwaterloo.ca/*"], "action": { diff --git a/popup.js b/popup.js index 70b4d18..293a044 100644 --- a/popup.js +++ b/popup.js @@ -15,14 +15,15 @@ const copyRawBtn = document.getElementById("copyRawBtn"); const clearOutputBtn = document.getElementById("clearOutputBtn"); const OUTPUT_STORAGE_KEY = "lastOutput"; -let persistTimer = null; +const AUTO_RUN_KEY = "autoRunDefaultTask"; const state = { postingText: "", tasks: [], port: null, isAnalyzing: false, - outputRaw: "" + outputRaw: "", + autoRunPending: false }; function getStorage(keys) { @@ -230,20 +231,9 @@ function renderOutput() { } function persistOutputNow() { - if (persistTimer) { - clearTimeout(persistTimer); - persistTimer = null; - } return chrome.storage.local.set({ [OUTPUT_STORAGE_KEY]: state.outputRaw }); } -function schedulePersistOutput() { - if (persistTimer) clearTimeout(persistTimer); - persistTimer = setTimeout(() => { - void persistOutputNow(); - }, 250); -} - function setStatus(message) { statusEl.textContent = message; } @@ -263,6 +253,7 @@ function setAnalyzing(isAnalyzing) { buttonRow.classList.toggle("hidden", isAnalyzing); stopRow.classList.toggle("hidden", !isAnalyzing); } + updateTaskSelectState(); } function updatePostingCount() { @@ -282,17 +273,30 @@ function renderTasks(tasks) { option.textContent = "No tasks configured"; option.value = ""; taskSelect.appendChild(option); - taskSelect.disabled = true; + updateTaskSelectState(); return; } - taskSelect.disabled = false; for (const task of tasks) { const option = document.createElement("option"); option.value = task.id; option.textContent = task.name || "Untitled task"; taskSelect.appendChild(option); } + updateTaskSelectState(); +} + +function updateTaskSelectState() { + const hasTasks = state.tasks.length > 0; + taskSelect.disabled = state.isAnalyzing || !hasTasks; +} + +function isWaterlooWorksUrl(url) { + try { + return new URL(url).hostname === "waterlooworks.uwaterloo.ca"; + } catch { + return false; + } } function sendToActiveTab(message) { @@ -304,6 +308,11 @@ function sendToActiveTab(message) { return; } + if (!isWaterlooWorksUrl(tab.url || "")) { + reject(new Error("Open waterlooworks.uwaterloo.ca to use this.")); + return; + } + chrome.tabs.sendMessage(tab.id, message, (response) => { const error = chrome.runtime.lastError; if (error) { @@ -328,28 +337,34 @@ function ensurePort() { if (message?.type === "DELTA") { state.outputRaw += message.text; renderOutput(); - schedulePersistOutput(); + return; + } + + if (message?.type === "SYNC") { + state.outputRaw = message.text || ""; + renderOutput(); + if (message.streaming) { + setAnalyzing(true); + setStatus("Analyzing..."); + } return; } if (message?.type === "DONE") { setAnalyzing(false); setStatus("Done"); - void persistOutputNow(); return; } if (message?.type === "ABORTED") { setAnalyzing(false); setStatus("Aborted."); - void persistOutputNow(); return; } if (message?.type === "ERROR") { setAnalyzing(false); setStatus(message.message || "Error during analysis."); - void persistOutputNow(); } }); @@ -365,6 +380,7 @@ function ensurePort() { async function loadTasks() { const { tasks = [] } = await getStorage(["tasks"]); renderTasks(tasks); + maybeRunDefaultTask(); } async function loadTheme() { @@ -398,6 +414,13 @@ async function handleAnalyze() { return; } + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tab = tabs[0]; + if (!tab?.url || !isWaterlooWorksUrl(tab.url)) { + setStatus("Open waterlooworks.uwaterloo.ca to run tasks."); + return; + } + const taskId = taskSelect.value; const task = state.tasks.find((item) => item.id === taskId); if (!task) { @@ -436,7 +459,6 @@ async function handleAnalyze() { state.outputRaw = ""; renderOutput(); - void persistOutputNow(); setAnalyzing(true); setStatus("Analyzing..."); @@ -452,7 +474,8 @@ async function handleAnalyze() { systemPrompt: systemPrompt || "", resume: resume || "", taskText: task.text || "", - postingText: state.postingText + postingText: state.postingText, + tabId: tab.id } }); } @@ -526,9 +549,42 @@ async function loadSavedOutput() { renderOutput(); } +async function loadAutoRunRequest() { + const stored = await getStorage([AUTO_RUN_KEY]); + if (stored[AUTO_RUN_KEY]) { + state.autoRunPending = true; + await chrome.storage.local.remove(AUTO_RUN_KEY); + } + maybeRunDefaultTask(); +} + +function maybeRunDefaultTask() { + if (!state.autoRunPending) return; + if (state.isAnalyzing) return; + if (!state.tasks.length) return; + taskSelect.value = state.tasks[0].id; + state.autoRunPending = false; + void handleExtractAndAnalyze(); +} + loadSavedOutput(); +loadAutoRunRequest(); +ensurePort(); chrome.storage.onChanged.addListener((changes) => { + if (changes[AUTO_RUN_KEY]?.newValue) { + state.autoRunPending = true; + void chrome.storage.local.remove(AUTO_RUN_KEY); + maybeRunDefaultTask(); + } + + if (changes[OUTPUT_STORAGE_KEY]?.newValue !== undefined) { + if (!state.isAnalyzing || !state.port) { + state.outputRaw = changes[OUTPUT_STORAGE_KEY].newValue || ""; + renderOutput(); + } + } + if (changes.theme) { applyTheme(changes.theme.newValue || "system"); }