commit eb702551449fe36fe24b4aa6f2e02f5f9a6b9051 Author: Peisong Xiao Date: Fri Jan 16 19:44:31 2026 -0500 initial commit: wwcompanion v1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c317064 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +AGENTS.md diff --git a/background.js b/background.js new file mode 100644 index 0000000..f770200 --- /dev/null +++ b/background.js @@ -0,0 +1,189 @@ +const DEFAULT_TASKS = [ + { + id: "task-fit-summary", + name: "Fit Summary", + text: + "Summarize the role, highlight key requirements, and assess my fit using the resume. Note any gaps and what to emphasize." + } +]; + +const DEFAULT_SETTINGS = { + apiKey: "", + model: "gpt-4o-mini", + systemPrompt: + "You are a precise, honest assistant. Be concise, highlight uncertainties, and avoid inventing details.", + resume: "", + tasks: DEFAULT_TASKS +}; + +chrome.runtime.onInstalled.addListener(async () => { + const stored = await chrome.storage.local.get(Object.keys(DEFAULT_SETTINGS)); + const updates = {}; + + for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) { + const existing = stored[key]; + const missing = + existing === undefined || + existing === null || + (key === "tasks" && !Array.isArray(existing)); + + if (missing) updates[key] = value; + } + + if (Object.keys(updates).length) { + await chrome.storage.local.set(updates); + } +}); + +chrome.runtime.onConnect.addListener((port) => { + if (port.name !== "analysis") return; + + let abortController = null; + + const resetAbort = () => { + if (abortController) abortController.abort(); + abortController = null; + }; + + port.onMessage.addListener((message) => { + if (message?.type === "START_ANALYSIS") { + resetAbort(); + abortController = new AbortController(); + void handleAnalysisRequest(port, message.payload, abortController.signal).catch( + (error) => { + if (error?.name === "AbortError") { + port.postMessage({ type: "ABORTED" }); + return; + } + port.postMessage({ + type: "ERROR", + message: error?.message || "Unknown error during analysis." + }); + } + ); + return; + } + + if (message?.type === "ABORT_ANALYSIS") { + resetAbort(); + } + }); + + port.onDisconnect.addListener(() => { + resetAbort(); + }); +}); + +function buildUserMessage(resume, task, posting) { + return [ + "=== RESUME ===", + resume || "", + "", + "=== TASK ===", + task || "", + "", + "=== JOB POSTING ===", + posting || "" + ].join("\n"); +} + +async function handleAnalysisRequest(port, payload, signal) { + const { apiKey, model, systemPrompt, resume, taskText, postingText } = payload || {}; + + if (!apiKey) { + port.postMessage({ type: "ERROR", message: "Missing OpenAI API key." }); + return; + } + + if (!model) { + port.postMessage({ type: "ERROR", message: "Missing model name." }); + return; + } + + if (!postingText) { + port.postMessage({ type: "ERROR", message: "No job posting text provided." }); + return; + } + + if (!taskText) { + port.postMessage({ type: "ERROR", message: "No task prompt selected." }); + return; + } + + const userMessage = buildUserMessage(resume, taskText, postingText); + + await streamChatCompletion({ + apiKey, + model, + systemPrompt: systemPrompt || "", + userMessage, + signal, + onDelta: (text) => port.postMessage({ type: "DELTA", text }) + }); + + port.postMessage({ type: "DONE" }); +} + +async function streamChatCompletion({ + apiKey, + model, + systemPrompt, + userMessage, + signal, + onDelta +}) { + const response = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}` + }, + body: JSON.stringify({ + model, + stream: true, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userMessage } + ] + }), + signal + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`OpenAI API error ${response.status}: ${errorText}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + // OpenAI streams Server-Sent Events; parse incremental deltas from data lines. + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith("data:")) continue; + + const data = trimmed.slice(5).trim(); + if (!data) continue; + if (data === "[DONE]") return; + + let parsed; + try { + parsed = JSON.parse(data); + } catch { + continue; + } + + const delta = parsed?.choices?.[0]?.delta?.content; + if (delta) onDelta(delta); + } + } +} diff --git a/content.js b/content.js new file mode 100644 index 0000000..e3dcbdc --- /dev/null +++ b/content.js @@ -0,0 +1,48 @@ +const HEADER_LINES = new Set([ + "OVERVIEW", + "PRE-SCREENING", + "WORK TERM RATINGS", + "JOB POSTING INFORMATION", + "APPLICATION INFORMATION", + "COMPANY INFORMATION", + "SERVICE TEAM" +]); + +function sanitizePostingText(text) { + let cleaned = text.replaceAll("fiber_manual_record", ""); + const lines = cleaned.split(/\r?\n/); + const filtered = lines.filter((line) => { + const trimmed = line.trim(); + if (!trimmed) return true; + return !HEADER_LINES.has(trimmed.toUpperCase()); + }); + + cleaned = filtered.join("\n"); + cleaned = cleaned.replace(/[ \t]+/g, " "); + cleaned = cleaned.replace(/\n{3,}/g, "\n\n"); + return cleaned.trim(); +} + +function extractPostingText() { + const contents = [...document.getElementsByClassName("modal__content")]; + if (!contents.length) { + return { ok: false, error: "No modal content found on this page." }; + } + + // WaterlooWorks renders multiple modal containers; choose the longest visible text block. + const el = contents.reduce((best, cur) => + cur.innerText.length > best.innerText.length ? cur : best + ); + + const rawText = el.innerText; + const sanitized = sanitizePostingText(rawText); + + return { ok: true, rawText, sanitized }; +} + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message?.type !== "EXTRACT_POSTING") return; + + const result = extractPostingText(); + sendResponse(result); +}); diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..7de09d6 --- /dev/null +++ b/manifest.json @@ -0,0 +1,26 @@ +{ + "manifest_version": 3, + "name": "WWCompanion", + "version": "0.1.0", + "description": "Manual reasoning companion for WaterlooWorks job postings.", + "permissions": ["storage", "activeTab"], + "host_permissions": ["https://waterlooworks.uwaterloo.ca/*"], + "action": { + "default_title": "WWCompanion", + "default_popup": "popup.html" + }, + "background": { + "service_worker": "background.js", + "type": "module" + }, + "content_scripts": [ + { + "matches": ["https://waterlooworks.uwaterloo.ca/*"], + "js": ["content.js"] + } + ], + "options_ui": { + "page": "settings.html", + "open_in_tab": true + } +} diff --git a/popup.css b/popup.css new file mode 100644 index 0000000..ab185a6 --- /dev/null +++ b/popup.css @@ -0,0 +1,161 @@ +:root { + --ink: #1f1a17; + --muted: #6b5f55; + --accent: #b14d2b; + --accent-deep: #7d321b; + --panel: #fff7ec; + --border: #e4d6c5; + --glow: rgba(177, 77, 43, 0.18); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 16px; + width: 360px; + font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Palatino, + "Times New Roman", serif; + color: var(--ink); + background: radial-gradient(circle at top, #fdf2df, #f7ead6 60%, #f1dcc6 100%); +} + +.title-block { + margin-bottom: 12px; +} + +.title { + font-size: 20px; + font-weight: 700; + letter-spacing: 0.3px; +} + +.subtitle { + font-size: 12px; + color: var(--muted); +} + +.panel { + padding: 12px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--panel); + box-shadow: 0 12px 30px rgba(122, 80, 47, 0.12); +} + +.field { + margin-top: 10px; + display: grid; + gap: 6px; +} + +label { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--muted); +} + +select { + width: 100%; + padding: 8px 10px; + border-radius: 10px; + border: 1px solid var(--border); + background: #fffdf9; + font-size: 13px; +} + +.actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-top: 12px; +} + +button { + font-family: inherit; + border: none; + border-radius: 10px; + padding: 8px 12px; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; + box-shadow: none; +} + +button:active { + transform: translateY(1px); +} + +.primary { + width: 100%; + background: #fffdf9; + border: 1px solid var(--border); + box-shadow: 0 6px 16px rgba(120, 85, 55, 0.12); +} + +.accent { + background: var(--accent); + color: #fff9f3; + box-shadow: 0 8px 20px var(--glow); +} + +.ghost { + background: transparent; + border: 1px solid var(--border); +} + +.meta { + display: flex; + justify-content: space-between; + font-size: 12px; + color: var(--muted); + margin-top: 10px; +} + +.status { + margin-top: 10px; + font-size: 12px; + color: var(--accent-deep); +} + +.output { + margin-top: 12px; + border: 1px dashed var(--border); + border-radius: 12px; + padding: 10px; + background: rgba(255, 255, 255, 0.7); + min-height: 160px; + max-height: 240px; + overflow: hidden; +} + +pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + max-height: 220px; + overflow-y: auto; + font-size: 12px; + line-height: 1.4; +} + +.footer { + display: flex; + justify-content: flex-end; + margin-top: 10px; +} + +.link { + padding: 4px 6px; + background: none; + border-radius: 8px; + color: var(--accent-deep); + font-size: 12px; +} diff --git a/popup.html b/popup.html new file mode 100644 index 0000000..02ed168 --- /dev/null +++ b/popup.html @@ -0,0 +1,42 @@ + + + + + + WWCompanion + + + +
+
WWCompanion
+
Manual reasoning for WaterlooWorks
+
+ +
+ +
+ + +
+
+ + +
+
+ Posting: 0 chars + Prompt: 0 chars +
+
Idle
+
+ +
+

+    
+ + + + + + diff --git a/popup.js b/popup.js new file mode 100644 index 0000000..87b0a36 --- /dev/null +++ b/popup.js @@ -0,0 +1,223 @@ +const extractBtn = document.getElementById("extractBtn"); +const analyzeBtn = document.getElementById("analyzeBtn"); +const abortBtn = document.getElementById("abortBtn"); +const taskSelect = document.getElementById("taskSelect"); +const outputEl = document.getElementById("output"); +const statusEl = document.getElementById("status"); +const postingCountEl = document.getElementById("postingCount"); +const promptCountEl = document.getElementById("promptCount"); +const settingsBtn = document.getElementById("settingsBtn"); + +const state = { + postingText: "", + tasks: [], + port: null, + isAnalyzing: false +}; + +function getStorage(keys) { + return new Promise((resolve) => chrome.storage.local.get(keys, resolve)); +} + +function buildUserMessage(resume, task, posting) { + return [ + "=== RESUME ===", + resume || "", + "", + "=== TASK ===", + task || "", + "", + "=== JOB POSTING ===", + posting || "" + ].join("\n"); +} + +function setStatus(message) { + statusEl.textContent = message; +} + +function setAnalyzing(isAnalyzing) { + state.isAnalyzing = isAnalyzing; + analyzeBtn.disabled = isAnalyzing; + abortBtn.disabled = !isAnalyzing; + extractBtn.disabled = isAnalyzing; +} + +function updatePostingCount() { + postingCountEl.textContent = `Posting: ${state.postingText.length} chars`; +} + +function updatePromptCount(count) { + promptCountEl.textContent = `Prompt: ${count} chars`; +} + +function renderTasks(tasks) { + state.tasks = tasks; + taskSelect.innerHTML = ""; + + if (!tasks.length) { + const option = document.createElement("option"); + option.textContent = "No tasks configured"; + option.value = ""; + taskSelect.appendChild(option); + taskSelect.disabled = true; + 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); + } +} + +function sendToActiveTab(message) { + return new Promise((resolve, reject) => { + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + const tab = tabs[0]; + if (!tab?.id) { + reject(new Error("No active tab found.")); + return; + } + + chrome.tabs.sendMessage(tab.id, message, (response) => { + const error = chrome.runtime.lastError; + if (error) { + reject(new Error(error.message)); + return; + } + resolve(response); + }); + }); + }); +} + +function ensurePort() { + if (state.port) return state.port; + + const port = chrome.runtime.connect({ name: "analysis" }); + port.onMessage.addListener((message) => { + if (message?.type === "DELTA") { + outputEl.textContent += message.text; + outputEl.scrollTop = outputEl.scrollHeight; + return; + } + + if (message?.type === "DONE") { + setAnalyzing(false); + setStatus("Done"); + return; + } + + if (message?.type === "ABORTED") { + setAnalyzing(false); + setStatus("Aborted."); + return; + } + + if (message?.type === "ERROR") { + setAnalyzing(false); + setStatus(message.message || "Error during analysis."); + } + }); + + port.onDisconnect.addListener(() => { + state.port = null; + setAnalyzing(false); + }); + + state.port = port; + return port; +} + +async function loadTasks() { + const { tasks = [] } = await getStorage(["tasks"]); + renderTasks(tasks); +} + +async function handleExtract() { + setStatus("Extracting..."); + try { + const response = await sendToActiveTab({ type: "EXTRACT_POSTING" }); + if (!response?.ok) { + setStatus(response?.error || "No posting detected."); + return; + } + + state.postingText = response.sanitized || ""; + updatePostingCount(); + updatePromptCount(0); + setStatus("Posting extracted."); + } catch (error) { + setStatus(error.message || "Unable to extract posting."); + } +} + +async function handleAnalyze() { + if (!state.postingText) { + setStatus("Extract a job posting first."); + return; + } + + const taskId = taskSelect.value; + const task = state.tasks.find((item) => item.id === taskId); + if (!task) { + setStatus("Select a task prompt."); + return; + } + + const { apiKey, model, systemPrompt, resume } = await getStorage([ + "apiKey", + "model", + "systemPrompt", + "resume" + ]); + + if (!apiKey) { + setStatus("Add your OpenAI API key in Settings."); + return; + } + + if (!model) { + setStatus("Set a model name in Settings."); + return; + } + + const promptText = buildUserMessage(resume || "", task.text || "", state.postingText); + updatePromptCount(promptText.length); + + outputEl.textContent = ""; + setAnalyzing(true); + setStatus("Analyzing..."); + + const port = ensurePort(); + port.postMessage({ + type: "START_ANALYSIS", + payload: { + apiKey, + model, + systemPrompt: systemPrompt || "", + resume: resume || "", + taskText: task.text || "", + postingText: state.postingText + } + }); +} + +function handleAbort() { + if (!state.port) return; + state.port.postMessage({ type: "ABORT_ANALYSIS" }); + setAnalyzing(false); + setStatus("Aborted."); +} + +extractBtn.addEventListener("click", handleExtract); +analyzeBtn.addEventListener("click", handleAnalyze); +abortBtn.addEventListener("click", handleAbort); +settingsBtn.addEventListener("click", () => chrome.runtime.openOptionsPage()); + +updatePostingCount(); +updatePromptCount(0); +loadTasks(); diff --git a/settings.css b/settings.css new file mode 100644 index 0000000..72753a9 --- /dev/null +++ b/settings.css @@ -0,0 +1,143 @@ +:root { + --ink: #221b15; + --muted: #6b5f55; + --accent: #b14d2b; + --panel: #fffaf1; + --border: #eadbc8; + --bg: #f5ead7; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 24px; + font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Palatino, + "Times New Roman", serif; + color: var(--ink); + background: linear-gradient(160deg, #f8efe1, #efe0c9); +} + +.title-block { + margin-bottom: 16px; +} + +.title { + font-size: 26px; + font-weight: 700; +} + +.subtitle { + font-size: 13px; + color: var(--muted); +} + +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 16px; + padding: 16px; + margin-bottom: 16px; + box-shadow: 0 18px 40px rgba(120, 85, 55, 0.12); +} + +.row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +h2 { + margin: 0; + font-size: 16px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--muted); +} + +.field { + display: grid; + gap: 6px; + margin-bottom: 12px; +} + +label { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--muted); +} + +input, +textarea { + width: 100%; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--border); + background: #fffdf9; + font-family: inherit; + font-size: 13px; +} + +textarea { + resize: vertical; + min-height: 120px; +} + +.inline { + display: flex; + gap: 8px; +} + +button { + font-family: inherit; + border: none; + border-radius: 10px; + padding: 8px 12px; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +button:active { + transform: translateY(1px); +} + +.accent { + background: var(--accent); + color: #fff9f3; + box-shadow: 0 8px 20px rgba(177, 77, 43, 0.2); +} + +.ghost { + background: transparent; + border: 1px solid var(--border); +} + +.status { + font-size: 12px; + color: var(--accent); +} + +.tasks { + display: grid; + gap: 12px; +} + +.task-card { + padding: 12px; + border-radius: 12px; + border: 1px solid var(--border); + background: #fffefb; + display: grid; + gap: 8px; +} + +.task-actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} diff --git a/settings.html b/settings.html new file mode 100644 index 0000000..1e131ae --- /dev/null +++ b/settings.html @@ -0,0 +1,54 @@ + + + + + + WWCompanion Settings + + + +
+
WWCompanion Settings
+
Configure prompts, resume, and API access
+
+ +
+
+

OpenAI

+ +
+
+ +
+ + +
+
+
+ + +
+
+
+ +
+

System Prompt

+ +
+ +
+

Resume

+ +
+ +
+
+

Task Presets

+ +
+
+
+ + + + diff --git a/settings.js b/settings.js new file mode 100644 index 0000000..77d7bc8 --- /dev/null +++ b/settings.js @@ -0,0 +1,148 @@ +const apiKeyInput = document.getElementById("apiKey"); +const modelInput = document.getElementById("model"); +const systemPromptInput = document.getElementById("systemPrompt"); +const resumeInput = document.getElementById("resume"); +const saveBtn = document.getElementById("saveBtn"); +const addTaskBtn = document.getElementById("addTaskBtn"); +const tasksContainer = document.getElementById("tasks"); +const statusEl = document.getElementById("status"); +const toggleKeyBtn = document.getElementById("toggleKey"); + +function getStorage(keys) { + return new Promise((resolve) => chrome.storage.local.get(keys, resolve)); +} + +function setStatus(message) { + statusEl.textContent = message; + if (!message) return; + setTimeout(() => { + if (statusEl.textContent === message) statusEl.textContent = ""; + }, 2000); +} + +function newTaskId() { + if (crypto?.randomUUID) return crypto.randomUUID(); + return `task-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; +} + +function buildTaskCard(task) { + const card = document.createElement("div"); + card.className = "task-card"; + card.dataset.id = task.id || newTaskId(); + + const nameField = document.createElement("div"); + nameField.className = "field"; + const nameLabel = document.createElement("label"); + nameLabel.textContent = "Name"; + const nameInput = document.createElement("input"); + nameInput.type = "text"; + nameInput.value = task.name || ""; + nameInput.className = "task-name"; + nameField.appendChild(nameLabel); + nameField.appendChild(nameInput); + + const textField = document.createElement("div"); + textField.className = "field"; + const textLabel = document.createElement("label"); + textLabel.textContent = "Task prompt"; + const textArea = document.createElement("textarea"); + textArea.rows = 6; + textArea.value = task.text || ""; + textArea.className = "task-text"; + textField.appendChild(textLabel); + textField.appendChild(textArea); + + const actions = document.createElement("div"); + actions.className = "task-actions"; + const duplicateBtn = document.createElement("button"); + duplicateBtn.type = "button"; + duplicateBtn.className = "ghost"; + duplicateBtn.textContent = "Duplicate"; + const deleteBtn = document.createElement("button"); + deleteBtn.type = "button"; + deleteBtn.className = "ghost"; + deleteBtn.textContent = "Delete"; + + duplicateBtn.addEventListener("click", () => { + const copy = { + id: newTaskId(), + name: `${nameInput.value || "Untitled"} Copy`, + text: textArea.value + }; + const newCard = buildTaskCard(copy); + card.insertAdjacentElement("afterend", newCard); + }); + + deleteBtn.addEventListener("click", () => { + card.remove(); + }); + + actions.appendChild(duplicateBtn); + actions.appendChild(deleteBtn); + + card.appendChild(nameField); + card.appendChild(textField); + card.appendChild(actions); + + return card; +} + +function collectTasks() { + const cards = [...tasksContainer.querySelectorAll(".task-card")]; + return cards.map((card) => { + const nameInput = card.querySelector(".task-name"); + const textArea = card.querySelector(".task-text"); + return { + id: card.dataset.id || newTaskId(), + name: (nameInput?.value || "Untitled Task").trim(), + text: (textArea?.value || "").trim() + }; + }); +} + +async function loadSettings() { + const { apiKey = "", model = "", systemPrompt = "", resume = "", tasks = [] } = + await getStorage(["apiKey", "model", "systemPrompt", "resume", "tasks"]); + + apiKeyInput.value = apiKey; + modelInput.value = model; + systemPromptInput.value = systemPrompt; + resumeInput.value = resume; + + tasksContainer.innerHTML = ""; + if (!tasks.length) { + tasksContainer.appendChild( + buildTaskCard({ id: newTaskId(), name: "", text: "" }) + ); + return; + } + + for (const task of tasks) { + tasksContainer.appendChild(buildTaskCard(task)); + } +} + +async function saveSettings() { + const tasks = collectTasks(); + await chrome.storage.local.set({ + apiKey: apiKeyInput.value.trim(), + model: modelInput.value.trim(), + systemPrompt: systemPromptInput.value, + resume: resumeInput.value, + tasks + }); + setStatus("Saved."); +} + +saveBtn.addEventListener("click", () => void saveSettings()); +addTaskBtn.addEventListener("click", () => { + tasksContainer.appendChild(buildTaskCard({ id: newTaskId(), name: "", text: "" })); +}); + +toggleKeyBtn.addEventListener("click", () => { + const isPassword = apiKeyInput.type === "password"; + apiKeyInput.type = isPassword ? "text" : "password"; + toggleKeyBtn.textContent = isPassword ? "Hide" : "Show"; +}); + +loadSettings();