New release: added background persistence, page checking, and also a button injected to the WW posting toolbar
This commit is contained in:
130
background.js
130
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,36 +176,41 @@ 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 chrome.storage.local.set({ [OUTPUT_STORAGE_KEY]: "" });
|
||||
openKeepalive(tabId);
|
||||
|
||||
try {
|
||||
await streamChatCompletion({
|
||||
apiKey,
|
||||
apiBaseUrl,
|
||||
@@ -140,10 +220,18 @@ async function handleAnalysisRequest(port, payload, signal) {
|
||||
systemPrompt: systemPrompt || "",
|
||||
userMessage,
|
||||
signal,
|
||||
onDelta: (text) => port.postMessage({ type: "DELTA", text })
|
||||
onDelta: (text) => {
|
||||
streamState.outputText += text;
|
||||
broadcast({ type: "DELTA", text });
|
||||
}
|
||||
});
|
||||
|
||||
port.postMessage({ type: "DONE" });
|
||||
broadcast({ type: "DONE" });
|
||||
} finally {
|
||||
streamState.active = false;
|
||||
await chrome.storage.local.set({ [OUTPUT_STORAGE_KEY]: streamState.outputText });
|
||||
closeKeepalive();
|
||||
}
|
||||
}
|
||||
|
||||
function buildChatUrl(apiBaseUrl) {
|
||||
|
||||
92
content.js
92
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();
|
||||
|
||||
@@ -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": {
|
||||
|
||||
98
popup.js
98
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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user