New release: added background persistence, page checking, and also a button injected to the WW posting toolbar

This commit is contained in:
2026-01-17 00:40:49 -05:00
parent 2e6dc5c028
commit 24370c0826
4 changed files with 292 additions and 56 deletions

View File

@@ -20,6 +20,46 @@ const DEFAULT_SETTINGS = {
theme: "system" 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 () => { chrome.runtime.onInstalled.addListener(async () => {
const stored = await chrome.storage.local.get(Object.keys(DEFAULT_SETTINGS)); const stored = await chrome.storage.local.get(Object.keys(DEFAULT_SETTINGS));
const updates = {}; const updates = {};
@@ -42,29 +82,42 @@ chrome.runtime.onInstalled.addListener(async () => {
chrome.runtime.onConnect.addListener((port) => { chrome.runtime.onConnect.addListener((port) => {
if (port.name !== "analysis") return; if (port.name !== "analysis") return;
let abortController = null; streamState.subscribers.add(port);
port.onDisconnect.addListener(() => {
streamState.subscribers.delete(port);
});
const resetAbort = () => { if (streamState.active) {
if (abortController) abortController.abort(); safePost(port, {
abortController = null; type: "SYNC",
}; text: streamState.outputText,
streaming: true
});
}
port.onMessage.addListener((message) => { port.onMessage.addListener((message) => {
if (message?.type === "START_ANALYSIS") { if (message?.type === "START_ANALYSIS") {
streamState.outputText = "";
resetAbort(); resetAbort();
abortController = new AbortController(); const controller = new AbortController();
void handleAnalysisRequest(port, message.payload, abortController.signal).catch( activeAbortController = controller;
(error) => { const request = handleAnalysisRequest(port, message.payload, controller.signal);
void request
.catch((error) => {
if (error?.name === "AbortError") { if (error?.name === "AbortError") {
port.postMessage({ type: "ABORTED" }); safePost(port, { type: "ABORTED" });
return; return;
} }
port.postMessage({ safePost(port, {
type: "ERROR", type: "ERROR",
message: error?.message || "Unknown error during analysis." message: error?.message || "Unknown error during analysis."
}); });
} })
); .finally(() => {
if (activeAbortController === controller) {
activeAbortController = null;
}
});
return; 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) { function buildUserMessage(resume, task, posting) {
@@ -91,7 +149,24 @@ function buildUserMessage(resume, task, posting) {
].join("\n"); ].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) { async function handleAnalysisRequest(port, payload, signal) {
streamState.outputText = "";
streamState.active = true;
const { const {
apiKey, apiKey,
apiBaseUrl, apiBaseUrl,
@@ -101,49 +176,62 @@ async function handleAnalysisRequest(port, payload, signal) {
systemPrompt, systemPrompt,
resume, resume,
taskText, taskText,
postingText postingText,
tabId
} = payload || {}; } = payload || {};
if (!apiBaseUrl) { if (!apiBaseUrl) {
port.postMessage({ type: "ERROR", message: "Missing API base URL." }); safePost(port, { type: "ERROR", message: "Missing API base URL." });
return; return;
} }
if (apiKeyHeader && !apiKey) { if (apiKeyHeader && !apiKey) {
port.postMessage({ type: "ERROR", message: "Missing API key." }); safePost(port, { type: "ERROR", message: "Missing API key." });
return; return;
} }
if (!model) { if (!model) {
port.postMessage({ type: "ERROR", message: "Missing model name." }); safePost(port, { type: "ERROR", message: "Missing model name." });
return; return;
} }
if (!postingText) { if (!postingText) {
port.postMessage({ type: "ERROR", message: "No job posting text provided." }); safePost(port, { type: "ERROR", message: "No job posting text provided." });
return; return;
} }
if (!taskText) { if (!taskText) {
port.postMessage({ type: "ERROR", message: "No task prompt selected." }); safePost(port, { type: "ERROR", message: "No task prompt selected." });
return; return;
} }
const userMessage = buildUserMessage(resume, taskText, postingText); const userMessage = buildUserMessage(resume, taskText, postingText);
await streamChatCompletion({ await chrome.storage.local.set({ [OUTPUT_STORAGE_KEY]: "" });
apiKey, openKeepalive(tabId);
apiBaseUrl,
apiKeyHeader,
apiKeyPrefix,
model,
systemPrompt: systemPrompt || "",
userMessage,
signal,
onDelta: (text) => port.postMessage({ type: "DELTA", text })
});
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) { function buildChatUrl(apiBaseUrl) {

View File

@@ -8,6 +8,14 @@ const HEADER_LINES = new Set([
"SERVICE TEAM" "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) { function sanitizePostingText(text) {
let cleaned = text.replaceAll("fiber_manual_record", ""); let cleaned = text.replaceAll("fiber_manual_record", "");
const lines = cleaned.split(/\r?\n/); const lines = cleaned.split(/\r?\n/);
@@ -23,6 +31,78 @@ function sanitizePostingText(text) {
return cleaned.trim(); 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() { function extractPostingText() {
const contents = [...document.getElementsByClassName("modal__content")]; const contents = [...document.getElementsByClassName("modal__content")];
if (!contents.length) { if (!contents.length) {
@@ -46,3 +126,15 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
const result = extractPostingText(); const result = extractPostingText();
sendResponse(result); 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();

View File

@@ -1,8 +1,8 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "WWCompanion", "name": "WWCompanion",
"version": "0.1.4", "version": "0.2.1",
"description": "Manual reasoning companion for WaterlooWorks job postings.", "description": "AI companion for WaterlooWorks job postings.",
"permissions": ["storage", "activeTab"], "permissions": ["storage", "activeTab"],
"host_permissions": ["https://waterlooworks.uwaterloo.ca/*"], "host_permissions": ["https://waterlooworks.uwaterloo.ca/*"],
"action": { "action": {

View File

@@ -15,14 +15,15 @@ const copyRawBtn = document.getElementById("copyRawBtn");
const clearOutputBtn = document.getElementById("clearOutputBtn"); const clearOutputBtn = document.getElementById("clearOutputBtn");
const OUTPUT_STORAGE_KEY = "lastOutput"; const OUTPUT_STORAGE_KEY = "lastOutput";
let persistTimer = null; const AUTO_RUN_KEY = "autoRunDefaultTask";
const state = { const state = {
postingText: "", postingText: "",
tasks: [], tasks: [],
port: null, port: null,
isAnalyzing: false, isAnalyzing: false,
outputRaw: "" outputRaw: "",
autoRunPending: false
}; };
function getStorage(keys) { function getStorage(keys) {
@@ -230,20 +231,9 @@ function renderOutput() {
} }
function persistOutputNow() { function persistOutputNow() {
if (persistTimer) {
clearTimeout(persistTimer);
persistTimer = null;
}
return chrome.storage.local.set({ [OUTPUT_STORAGE_KEY]: state.outputRaw }); return chrome.storage.local.set({ [OUTPUT_STORAGE_KEY]: state.outputRaw });
} }
function schedulePersistOutput() {
if (persistTimer) clearTimeout(persistTimer);
persistTimer = setTimeout(() => {
void persistOutputNow();
}, 250);
}
function setStatus(message) { function setStatus(message) {
statusEl.textContent = message; statusEl.textContent = message;
} }
@@ -263,6 +253,7 @@ function setAnalyzing(isAnalyzing) {
buttonRow.classList.toggle("hidden", isAnalyzing); buttonRow.classList.toggle("hidden", isAnalyzing);
stopRow.classList.toggle("hidden", !isAnalyzing); stopRow.classList.toggle("hidden", !isAnalyzing);
} }
updateTaskSelectState();
} }
function updatePostingCount() { function updatePostingCount() {
@@ -282,17 +273,30 @@ function renderTasks(tasks) {
option.textContent = "No tasks configured"; option.textContent = "No tasks configured";
option.value = ""; option.value = "";
taskSelect.appendChild(option); taskSelect.appendChild(option);
taskSelect.disabled = true; updateTaskSelectState();
return; return;
} }
taskSelect.disabled = false;
for (const task of tasks) { for (const task of tasks) {
const option = document.createElement("option"); const option = document.createElement("option");
option.value = task.id; option.value = task.id;
option.textContent = task.name || "Untitled task"; option.textContent = task.name || "Untitled task";
taskSelect.appendChild(option); 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) { function sendToActiveTab(message) {
@@ -304,6 +308,11 @@ function sendToActiveTab(message) {
return; return;
} }
if (!isWaterlooWorksUrl(tab.url || "")) {
reject(new Error("Open waterlooworks.uwaterloo.ca to use this."));
return;
}
chrome.tabs.sendMessage(tab.id, message, (response) => { chrome.tabs.sendMessage(tab.id, message, (response) => {
const error = chrome.runtime.lastError; const error = chrome.runtime.lastError;
if (error) { if (error) {
@@ -328,28 +337,34 @@ function ensurePort() {
if (message?.type === "DELTA") { if (message?.type === "DELTA") {
state.outputRaw += message.text; state.outputRaw += message.text;
renderOutput(); renderOutput();
schedulePersistOutput(); return;
}
if (message?.type === "SYNC") {
state.outputRaw = message.text || "";
renderOutput();
if (message.streaming) {
setAnalyzing(true);
setStatus("Analyzing...");
}
return; return;
} }
if (message?.type === "DONE") { if (message?.type === "DONE") {
setAnalyzing(false); setAnalyzing(false);
setStatus("Done"); setStatus("Done");
void persistOutputNow();
return; return;
} }
if (message?.type === "ABORTED") { if (message?.type === "ABORTED") {
setAnalyzing(false); setAnalyzing(false);
setStatus("Aborted."); setStatus("Aborted.");
void persistOutputNow();
return; return;
} }
if (message?.type === "ERROR") { if (message?.type === "ERROR") {
setAnalyzing(false); setAnalyzing(false);
setStatus(message.message || "Error during analysis."); setStatus(message.message || "Error during analysis.");
void persistOutputNow();
} }
}); });
@@ -365,6 +380,7 @@ function ensurePort() {
async function loadTasks() { async function loadTasks() {
const { tasks = [] } = await getStorage(["tasks"]); const { tasks = [] } = await getStorage(["tasks"]);
renderTasks(tasks); renderTasks(tasks);
maybeRunDefaultTask();
} }
async function loadTheme() { async function loadTheme() {
@@ -398,6 +414,13 @@ async function handleAnalyze() {
return; 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 taskId = taskSelect.value;
const task = state.tasks.find((item) => item.id === taskId); const task = state.tasks.find((item) => item.id === taskId);
if (!task) { if (!task) {
@@ -436,7 +459,6 @@ async function handleAnalyze() {
state.outputRaw = ""; state.outputRaw = "";
renderOutput(); renderOutput();
void persistOutputNow();
setAnalyzing(true); setAnalyzing(true);
setStatus("Analyzing..."); setStatus("Analyzing...");
@@ -452,7 +474,8 @@ async function handleAnalyze() {
systemPrompt: systemPrompt || "", systemPrompt: systemPrompt || "",
resume: resume || "", resume: resume || "",
taskText: task.text || "", taskText: task.text || "",
postingText: state.postingText postingText: state.postingText,
tabId: tab.id
} }
}); });
} }
@@ -526,9 +549,42 @@ async function loadSavedOutput() {
renderOutput(); 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(); loadSavedOutput();
loadAutoRunRequest();
ensurePort();
chrome.storage.onChanged.addListener((changes) => { 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) { if (changes.theme) {
applyTheme(changes.theme.newValue || "system"); applyTheme(changes.theme.newValue || "system");
} }