21 Commits

Author SHA1 Message Date
08f7ee8055 updated version number 2026-01-17 17:37:52 -05:00
df80aee8eb reworked UI, moved environment selection to tasks 2026-01-17 17:36:44 -05:00
e3c7cbba95 Added multi-env support 2026-01-17 17:09:47 -05:00
2638e08453 Added multi-api support and advanced mode 2026-01-17 16:46:01 -05:00
3bb350f3cf Added multi-key support 2026-01-17 16:12:41 -05:00
3eb9863c3e UI/UX improvements 2026-01-17 16:01:49 -05:00
3dbdc79b54 added move to top button for task presets 2026-01-17 15:53:33 -05:00
23d2f9475b update .gitignore to ignore crx and pem files 2026-01-17 01:33:38 -05:00
e670c8b60e updated example prompt for generic fit 2026-01-17 01:31:51 -05:00
b5cd3bd426 updated README 2026-01-17 01:25:24 -05:00
411d585f84 moved extension files into subdirectory 2026-01-17 01:23:37 -05:00
be7d7a1bce New release: added README.md and example prompts to default configuration 2026-01-17 01:22:11 -05:00
24370c0826 New release: added background persistence, page checking, and also a button injected to the WW posting toolbar 2026-01-17 00:40:49 -05:00
2e6dc5c028 added --- rendering to markdown 2026-01-16 23:27:18 -05:00
ad05b63285 added quality-of-life improvements, persistent output buffer and better error messages 2026-01-16 23:15:18 -05:00
743b83deb3 added feature to copy to clipboard 2026-01-16 23:07:44 -05:00
2f7d1acaa9 updated version number 2026-01-16 22:57:43 -05:00
322a3f4488 UI/UX changes, added feature to switch to other LLM APIs 2026-01-16 22:56:11 -05:00
5829aa0269 more compact popup UI 2026-01-16 21:15:32 -05:00
08c625ae94 updated version number 2026-01-16 21:09:55 -05:00
b8cb75acf4 added some UX improvements 2026-01-16 21:08:50 -05:00
19 changed files with 3758 additions and 1010 deletions

3
.gitignore vendored
View File

@@ -1 +1,4 @@
AGENTS.md
**/*.crx
**/*.pem
**/*.zip

61
README.md Normal file
View File

@@ -0,0 +1,61 @@
# WWCompanion
WWCompanion is a manual, user-triggered browser extension for
LLM-enabled AI companionship with WaterlooWorks job postings.
## Table of Contents
- [Usage](#usage)
- [Configuration](#configuration)
- [Example prompts](#example-prompts)
- [Example system prompt (default if not configured)](#example-system-prompt-default-if-not-configured)
- [Example task prompt: Generic Fit](#example-task-prompt-generic-fit)
- [Example task prompt: Ratings Only](#example-task-prompt-ratings-only)
## Usage
- Load the extension into a Chromium-based browser (root directory at [wwcompanion-extension](wwcompanion-extension/))
- Configure API settings, prompts, and tasks in **Settings**.
- Open a WaterlooWorks job posting modal.
- Click **Extract** or **Extract & Run** in the popup.
- Review the streamed response, then copy or clear it as needed.
## Configuration
- Open **Settings** from the popup.
- Add your API key, base URL, and model (defaults target
OpenAI-compatible APIs).
- Set your system prompt and resume text.
- Customize task presets; the top task is used as the default.
## Example prompts
### Example system prompt (default if not configured)
Concise, critical evaluation style with a short summary and emphasized
key points.
```
You are a precise, honest assistant. Be concise and avoid inventing details, be critical about evaluations. You should put in a small summary of all the sections at the end. You should answer in no longer than 3 sections including the summary. And remember to bold or italicize key points.
```
### Example task prompt: Generic Fit
Single-section fit assessment tuned for compact output without
interview prep.
```
You should evaluate for my fit to the job. You don't need to suggest interview prep, we'll leave those for later. a bit of tuning to your answers: please keep things more compact, a single section for the evaluation is enough, you don't need to analyze every bullet point in the posting.
```
### Example task prompt: Ratings Only
Structured numeric ratings with headings and no extra commentary.
```
Give ratings out of 10 with headings and do not include any other text.
1. Fit evaluation: my fit to the role.
2. Company status: how well this company offers career development for me.
3. Pay: use $25 CAD per hour as a baseline; rate the compensation.
```

View File

@@ -1,189 +0,0 @@
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);
}
}
}

View File

@@ -1,48 +0,0 @@
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);
});

161
popup.css
View File

@@ -1,161 +0,0 @@
: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;
}

View File

@@ -1,42 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>WWCompanion</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<header class="title-block">
<div class="title">WWCompanion</div>
<div class="subtitle">Manual reasoning for WaterlooWorks</div>
</header>
<section class="panel">
<button id="extractBtn" class="primary">Extract Posting</button>
<div class="field">
<label for="taskSelect">Task</label>
<select id="taskSelect"></select>
</div>
<div class="actions">
<button id="analyzeBtn" class="accent">Analyze</button>
<button id="abortBtn" class="ghost" disabled>Stop</button>
</div>
<div class="meta">
<span id="postingCount">Posting: 0 chars</span>
<span id="promptCount">Prompt: 0 chars</span>
</div>
<div id="status" class="status">Idle</div>
</section>
<section class="output">
<pre id="output" aria-live="polite"></pre>
</section>
<footer class="footer">
<button id="settingsBtn" class="link">Open Settings</button>
</footer>
<script src="popup.js"></script>
</body>
</html>

223
popup.js
View File

@@ -1,223 +0,0 @@
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();

View File

@@ -1,143 +0,0 @@
: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;
}

View File

@@ -1,54 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>WWCompanion Settings</title>
<link rel="stylesheet" href="settings.css" />
</head>
<body>
<header class="title-block">
<div class="title">WWCompanion Settings</div>
<div class="subtitle">Configure prompts, resume, and API access</div>
</header>
<section class="panel">
<div class="row">
<h2>OpenAI</h2>
<button id="saveBtn" class="accent">Save Settings</button>
</div>
<div class="field">
<label for="apiKey">API Key</label>
<div class="inline">
<input id="apiKey" type="password" autocomplete="off" placeholder="sk-..." />
<button id="toggleKey" class="ghost" type="button">Show</button>
</div>
</div>
<div class="field">
<label for="model">Model name</label>
<input id="model" type="text" placeholder="gpt-4o-mini" />
</div>
<div id="status" class="status"></div>
</section>
<section class="panel">
<h2>System Prompt</h2>
<textarea id="systemPrompt" rows="8" placeholder="Define tone and standards..."></textarea>
</section>
<section class="panel">
<h2>Resume</h2>
<textarea id="resume" rows="10" placeholder="Paste your resume text..."></textarea>
</section>
<section class="panel">
<div class="row">
<h2>Task Presets</h2>
<button id="addTaskBtn" class="ghost" type="button">Add Task</button>
</div>
<div id="tasks" class="tasks"></div>
</section>
<script src="settings.js"></script>
</body>
</html>

View File

@@ -1,148 +0,0 @@
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();

View File

@@ -0,0 +1,640 @@
const DEFAULT_TASKS = [
{
id: "task-generic-fit",
name: "Generic Fit",
text:
"You should evaluate for my fit to the job. You don't need to suggest interview prep, we'll leave those for later. a bit of tuning to your answers: please keep things more compact, a single section for the evaluation is enough, you don't need to analyze every bullet point in the posting."
},
{
id: "task-ratings-only",
name: "Ratings Only",
text:
"Give ratings out of 10 with headings and do not include any other text.\n\n1. Fit evaluation: my fit to the role.\n2. Company status: how well this company offers career development for me.\n3. Pay: use $25 CAD per hour as a baseline; rate the compensation."
}
];
const DEFAULT_SETTINGS = {
apiKey: "",
apiKeys: [],
activeApiKeyId: "",
apiConfigs: [],
activeApiConfigId: "",
envConfigs: [],
activeEnvConfigId: "",
apiBaseUrl: "https://api.openai.com/v1",
apiKeyHeader: "Authorization",
apiKeyPrefix: "Bearer ",
model: "gpt-4o-mini",
systemPrompt:
"You are a precise, honest assistant. Be concise and avoid inventing details, be critical about evaluations. You should put in a small summary of all the sections at the end. You should answer in no longer than 3 sections including the summary. And remember to bold or italicize key points.",
resume: "",
tasks: DEFAULT_TASKS,
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 = {};
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;
}
const hasApiKeys =
Array.isArray(stored.apiKeys) && stored.apiKeys.length > 0;
if (!hasApiKeys && stored.apiKey) {
const id = crypto?.randomUUID
? crypto.randomUUID()
: `key-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
updates.apiKeys = [{ id, name: "Default", key: stored.apiKey }];
updates.activeApiKeyId = id;
} else if (hasApiKeys && stored.activeApiKeyId) {
const exists = stored.apiKeys.some((key) => key.id === stored.activeApiKeyId);
if (!exists) {
updates.activeApiKeyId = stored.apiKeys[0].id;
}
} else if (hasApiKeys && !stored.activeApiKeyId) {
updates.activeApiKeyId = stored.apiKeys[0].id;
}
const hasApiConfigs =
Array.isArray(stored.apiConfigs) && stored.apiConfigs.length > 0;
if (!hasApiConfigs) {
const fallbackKeyId =
updates.activeApiKeyId ||
stored.activeApiKeyId ||
stored.apiKeys?.[0]?.id ||
"";
const id = crypto?.randomUUID
? crypto.randomUUID()
: `config-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
updates.apiConfigs = [
{
id,
name: "Default",
apiBaseUrl: stored.apiBaseUrl || DEFAULT_SETTINGS.apiBaseUrl,
apiKeyHeader: stored.apiKeyHeader || DEFAULT_SETTINGS.apiKeyHeader,
apiKeyPrefix: stored.apiKeyPrefix || DEFAULT_SETTINGS.apiKeyPrefix,
model: stored.model || DEFAULT_SETTINGS.model,
apiKeyId: fallbackKeyId,
apiUrl: "",
requestTemplate: "",
advanced: false
}
];
updates.activeApiConfigId = id;
} else if (stored.activeApiConfigId) {
const exists = stored.apiConfigs.some(
(config) => config.id === stored.activeApiConfigId
);
if (!exists) {
updates.activeApiConfigId = stored.apiConfigs[0].id;
}
const fallbackKeyId =
updates.activeApiKeyId ||
stored.activeApiKeyId ||
stored.apiKeys?.[0]?.id ||
"";
const normalizedConfigs = stored.apiConfigs.map((config) => ({
...config,
apiKeyId: config.apiKeyId || fallbackKeyId,
apiUrl: config.apiUrl || "",
requestTemplate: config.requestTemplate || "",
advanced: Boolean(config.advanced)
}));
const needsUpdate = normalizedConfigs.some((config, index) => {
const original = stored.apiConfigs[index];
return (
config.apiKeyId !== original.apiKeyId ||
(config.apiUrl || "") !== (original.apiUrl || "") ||
(config.requestTemplate || "") !== (original.requestTemplate || "") ||
Boolean(config.advanced) !== Boolean(original.advanced)
);
});
if (needsUpdate) {
updates.apiConfigs = normalizedConfigs;
}
} else {
updates.activeApiConfigId = stored.apiConfigs[0].id;
const fallbackKeyId =
updates.activeApiKeyId ||
stored.activeApiKeyId ||
stored.apiKeys?.[0]?.id ||
"";
const normalizedConfigs = stored.apiConfigs.map((config) => ({
...config,
apiKeyId: config.apiKeyId || fallbackKeyId,
apiUrl: config.apiUrl || "",
requestTemplate: config.requestTemplate || "",
advanced: Boolean(config.advanced)
}));
const needsUpdate = normalizedConfigs.some((config, index) => {
const original = stored.apiConfigs[index];
return (
config.apiKeyId !== original.apiKeyId ||
(config.apiUrl || "") !== (original.apiUrl || "") ||
(config.requestTemplate || "") !== (original.requestTemplate || "") ||
Boolean(config.advanced) !== Boolean(original.advanced)
);
});
if (needsUpdate) {
updates.apiConfigs = normalizedConfigs;
}
}
const resolvedApiConfigs = updates.apiConfigs || stored.apiConfigs || [];
const resolvedActiveApiConfigId =
updates.activeApiConfigId ||
stored.activeApiConfigId ||
resolvedApiConfigs[0]?.id ||
"";
const hasEnvConfigs =
Array.isArray(stored.envConfigs) && stored.envConfigs.length > 0;
if (!hasEnvConfigs) {
const id = crypto?.randomUUID
? crypto.randomUUID()
: `env-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
updates.envConfigs = [
{
id,
name: "Default",
apiConfigId: resolvedActiveApiConfigId,
systemPrompt: stored.systemPrompt || DEFAULT_SETTINGS.systemPrompt
}
];
updates.activeEnvConfigId = id;
} else {
const normalizedEnvs = stored.envConfigs.map((config) => ({
...config,
apiConfigId: config.apiConfigId || resolvedActiveApiConfigId,
systemPrompt: config.systemPrompt ?? ""
}));
const envNeedsUpdate = normalizedEnvs.some((config, index) => {
const original = stored.envConfigs[index];
return (
config.apiConfigId !== original.apiConfigId ||
(config.systemPrompt || "") !== (original.systemPrompt || "")
);
});
if (envNeedsUpdate) {
updates.envConfigs = normalizedEnvs;
}
const envActiveId = updates.activeEnvConfigId || stored.activeEnvConfigId;
if (envActiveId) {
const exists = stored.envConfigs.some(
(config) => config.id === envActiveId
);
if (!exists) {
updates.activeEnvConfigId = stored.envConfigs[0].id;
}
} else {
updates.activeEnvConfigId = stored.envConfigs[0].id;
}
}
const resolvedEnvConfigs = updates.envConfigs || stored.envConfigs || [];
const defaultEnvId =
resolvedEnvConfigs[0]?.id ||
updates.activeEnvConfigId ||
stored.activeEnvConfigId ||
"";
const taskSource = Array.isArray(updates.tasks)
? updates.tasks
: Array.isArray(stored.tasks)
? stored.tasks
: [];
if (taskSource.length) {
const normalizedTasks = taskSource.map((task) => ({
...task,
defaultEnvId: task.defaultEnvId || defaultEnvId
}));
const needsTaskUpdate = normalizedTasks.some(
(task, index) => task.defaultEnvId !== taskSource[index]?.defaultEnvId
);
if (needsTaskUpdate) {
updates.tasks = normalizedTasks;
}
}
if (Object.keys(updates).length) {
await chrome.storage.local.set(updates);
}
});
chrome.runtime.onConnect.addListener((port) => {
if (port.name !== "analysis") return;
streamState.subscribers.add(port);
port.onDisconnect.addListener(() => {
streamState.subscribers.delete(port);
});
if (streamState.active) {
safePost(port, {
type: "SYNC",
text: streamState.outputText,
streaming: true
});
}
port.onMessage.addListener((message) => {
if (message?.type === "START_ANALYSIS") {
streamState.outputText = "";
resetAbort();
const controller = new AbortController();
activeAbortController = controller;
const request = handleAnalysisRequest(port, message.payload, controller.signal);
void request
.catch((error) => {
if (error?.name === "AbortError") {
safePost(port, { type: "ABORTED" });
return;
}
safePost(port, {
type: "ERROR",
message: error?.message || "Unknown error during analysis."
});
})
.finally(() => {
if (activeAbortController === controller) {
activeAbortController = null;
}
});
return;
}
if (message?.type === "ABORT_ANALYSIS") {
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) {
return [
"=== RESUME ===",
resume || "",
"",
"=== TASK ===",
task || "",
"",
"=== JOB POSTING ===",
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,
apiMode,
apiUrl,
requestTemplate,
apiBaseUrl,
apiKeyHeader,
apiKeyPrefix,
model,
systemPrompt,
resume,
taskText,
postingText,
tabId
} = payload || {};
const isAdvanced = apiMode === "advanced";
if (isAdvanced) {
if (!apiUrl) {
safePost(port, { type: "ERROR", message: "Missing API URL." });
return;
}
if (!requestTemplate) {
safePost(port, { type: "ERROR", message: "Missing request template." });
return;
}
if (apiKeyHeader && !apiKey) {
safePost(port, { type: "ERROR", message: "Missing API key." });
return;
}
} else {
if (!apiBaseUrl) {
safePost(port, { type: "ERROR", message: "Missing API base URL." });
return;
}
if (apiKeyHeader && !apiKey) {
safePost(port, { type: "ERROR", message: "Missing API key." });
return;
}
if (!model) {
safePost(port, { type: "ERROR", message: "Missing model name." });
return;
}
}
if (!postingText) {
safePost(port, { type: "ERROR", message: "No job posting text provided." });
return;
}
if (!taskText) {
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 {
if (isAdvanced) {
await streamCustomCompletion({
apiKey,
apiUrl,
requestTemplate,
apiKeyHeader,
apiKeyPrefix,
apiBaseUrl,
model,
systemPrompt: systemPrompt || "",
userMessage,
signal,
onDelta: (text) => {
streamState.outputText += text;
broadcast({ type: "DELTA", text });
}
});
} else {
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) {
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 || ""}`
};
}
function replaceQuotedToken(template, token, value) {
const quoted = `"${token}"`;
const jsonValue = JSON.stringify(value ?? "");
return template.split(quoted).join(jsonValue);
}
function replaceTemplateTokens(template, replacements) {
let output = template || "";
for (const [token, value] of Object.entries(replacements)) {
output = replaceQuotedToken(output, token, value ?? "");
output = output.split(token).join(value ?? "");
}
return output;
}
function replaceUrlTokens(url, replacements) {
let output = url || "";
for (const [token, value] of Object.entries(replacements)) {
output = output.split(token).join(encodeURIComponent(value ?? ""));
}
return output;
}
function buildTemplateBody(template, replacements) {
const filled = replaceTemplateTokens(template, replacements);
try {
return JSON.parse(filled);
} catch {
throw new Error("Invalid request template JSON.");
}
}
async function readSseStream(response, onDelta) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
// OpenAI-compatible SSE stream; 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);
}
}
}
async function streamChatCompletion({
apiKey,
apiBaseUrl,
apiKeyHeader,
apiKeyPrefix,
model,
systemPrompt,
userMessage,
signal,
onDelta
}) {
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,
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(`API error ${response.status}: ${errorText}`);
}
await readSseStream(response, onDelta);
}
async function streamCustomCompletion({
apiKey,
apiUrl,
requestTemplate,
apiKeyHeader,
apiKeyPrefix,
apiBaseUrl,
model,
systemPrompt,
userMessage,
signal,
onDelta
}) {
const replacements = {
PROMPT_GOES_HERE: userMessage,
SYSTEM_PROMPT_GOES_HERE: systemPrompt,
API_KEY_GOES_HERE: apiKey,
MODEL_GOES_HERE: model || "",
API_BASE_URL_GOES_HERE: apiBaseUrl || ""
};
const resolvedUrl = replaceUrlTokens(apiUrl, replacements);
const body = buildTemplateBody(requestTemplate, replacements);
const headers = {
"Content-Type": "application/json"
};
const authHeader = buildAuthHeader(apiKeyHeader, apiKeyPrefix, apiKey);
if (authHeader) {
headers[authHeader.name] = authHeader.value;
}
const response = await fetch(resolvedUrl, {
method: "POST",
headers,
body: JSON.stringify(body),
signal
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`API error ${response.status}: ${errorText}`);
}
await readSseStream(response, onDelta);
}

View File

@@ -0,0 +1,140 @@
const HEADER_LINES = new Set([
"OVERVIEW",
"PRE-SCREENING",
"WORK TERM RATINGS",
"JOB POSTING INFORMATION",
"APPLICATION INFORMATION",
"COMPANY INFORMATION",
"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/);
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 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) {
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);
});
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,
"name": "WWCompanion",
"version": "0.1.0",
"description": "Manual reasoning companion for WaterlooWorks job postings.",
"version": "0.3.0",
"description": "AI companion for WaterlooWorks job postings.",
"permissions": ["storage", "activeTab"],
"host_permissions": ["https://waterlooworks.uwaterloo.ca/*"],
"action": {

View File

@@ -0,0 +1,338 @@
:root {
--ink: #1f1a17;
--muted: #6b5f55;
--accent: #b14d2b;
--accent-deep: #7d321b;
--panel: #fff7ec;
--border: #e4d6c5;
--glow: rgba(177, 77, 43, 0.18);
--output-bg: rgba(255, 255, 255, 0.7);
--code-bg: #f4e7d2;
--page-bg: radial-gradient(circle at top, #fdf2df, #f7ead6 60%, #f1dcc6 100%);
--input-bg: #fffdf9;
--input-fg: var(--ink);
--panel-shadow: 0 12px 30px rgba(122, 80, 47, 0.12);
color-scheme: light;
}
:root[data-theme="dark"] {
--ink: #abb2bf;
--muted: #8b93a5;
--accent: #61afef;
--accent-deep: #56b6c2;
--panel: #2f343f;
--border: #3e4451;
--glow: rgba(97, 175, 239, 0.2);
--output-bg: rgba(47, 52, 63, 0.85);
--code-bg: #3a3f4b;
--page-bg: radial-gradient(circle at top, #2a2f3a, #21252b 60%, #1b1f25 100%);
--input-bg: #2b303b;
--input-fg: var(--ink);
--panel-shadow: 0 12px 30px rgba(0, 0, 0, 0.35);
color-scheme: dark;
}
:root[data-theme="light"] {
color-scheme: light;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 12px;
width: 360px;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color: var(--ink);
background: var(--page-bg);
}
.title-block {
margin-bottom: 6px;
}
.title-line {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.title {
font-size: 18px;
font-weight: 700;
letter-spacing: 0.3px;
}
.subtitle {
font-size: 11px;
color: var(--muted);
}
.panel {
padding: 10px;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--panel);
box-shadow: var(--panel-shadow);
}
.controls-block {
display: grid;
gap: 8px;
}
.config-block {
display: grid;
gap: 8px;
}
.field {
margin-top: 8px;
display: grid;
gap: 4px;
}
.inline-field {
display: flex;
align-items: center;
gap: 8px;
}
.inline-field label {
margin: 0;
flex: 0 0 auto;
}
.inline-field select {
flex: 1;
}
label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--muted);
}
select {
width: 100%;
padding: 6px 8px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--input-bg);
color: var(--input-fg);
font-size: 12px;
}
.env-row {
display: flex;
align-items: flex-end;
}
.env-row .env-field {
flex: 1;
margin: 0;
}
.task-row {
display: flex;
align-items: flex-end;
gap: 8px;
}
.task-row button {
padding: 6px 15px;
}
.task-row .task-field {
flex: 1;
margin: 0;
}
.task-row select {
min-width: 0;
}
.hidden {
display: none;
}
button {
font-family: inherit;
border: none;
border-radius: 10px;
padding: 6px 10px;
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: var(--input-bg);
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);
}
.stop-btn {
background: #c0392b;
border-color: #c0392b;
color: #fff6f2;
}
.meta {
display: flex;
justify-content: space-between;
gap: 6px;
flex-wrap: wrap;
font-size: 11px;
color: var(--muted);
margin-top: 8px;
}
.status {
font-size: 11px;
color: var(--accent-deep);
}
.output {
margin-top: 8px;
border: 1px dashed var(--border);
border-radius: 12px;
padding: 8px;
background: var(--output-bg);
min-height: 210px;
max-height: 360px;
overflow: hidden;
}
.output-body {
margin: 0;
word-break: break-word;
max-height: 340px;
overflow-y: auto;
font-size: 11px;
line-height: 1.45;
}
.output-body h1,
.output-body h2,
.output-body h3,
.output-body h4 {
margin: 0 0 6px;
font-size: 13px;
letter-spacing: 0.3px;
}
.output-body p {
margin: 0 0 8px;
}
.output-body ul,
.output-body ol {
margin: 0 0 8px 18px;
padding: 0;
}
.output-body li {
margin-bottom: 4px;
}
.output-body blockquote {
margin: 0 0 8px;
padding-left: 10px;
border-left: 2px solid var(--border);
color: var(--muted);
}
.output-body hr {
border: none;
border-top: 1px solid var(--border);
margin: 8px 0;
}
.output-body pre {
margin: 0 0 10px;
padding: 8px;
border-radius: 10px;
background: var(--code-bg);
overflow-x: auto;
}
.output-body code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 11px;
background: var(--code-bg);
padding: 1px 4px;
border-radius: 6px;
}
.output-body a {
color: var(--accent-deep);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]),
:root[data-theme="system"] {
--ink: #abb2bf;
--muted: #8b93a5;
--accent: #61afef;
--accent-deep: #56b6c2;
--panel: #2f343f;
--border: #3e4451;
--glow: rgba(97, 175, 239, 0.2);
--output-bg: rgba(47, 52, 63, 0.85);
--code-bg: #3a3f4b;
--page-bg: radial-gradient(circle at top, #2a2f3a, #21252b 60%, #1b1f25 100%);
--input-bg: #2b303b;
--input-fg: var(--ink);
--panel-shadow: 0 12px 30px rgba(0, 0, 0, 0.35);
color-scheme: dark;
}
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 6px;
gap: 8px;
}
.footer-left {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.link {
padding: 4px 6px;
background: none;
border-radius: 8px;
color: var(--accent-deep);
font-size: 11px;
}

View File

@@ -0,0 +1,58 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>WWCompanion</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<header class="title-block">
<div class="title-line">
<span class="title">WWCompanion</span>
<span class="subtitle">AI companion for WaterlooWorks.</span>
</div>
</header>
<section class="panel">
<div class="controls-block">
<div class="config-block">
<div class="env-row">
<div class="field inline-field env-field">
<label for="envSelect">Environment</label>
<select id="envSelect"></select>
</div>
</div>
<div class="task-row">
<div class="field inline-field task-field">
<label for="taskSelect">Task</label>
<select id="taskSelect"></select>
</div>
<button id="runBtn" class="accent">Run</button>
<button id="abortBtn" class="ghost stop-btn hidden" disabled>Stop</button>
</div>
</div>
</div>
<div class="meta">
<span id="postingCount">Posting: 0 chars</span>
<span id="promptCount">Prompt: 0 chars</span>
<span id="status" class="status">Idle</span>
</div>
</section>
<section class="output">
<div id="output" class="output-body" aria-live="polite"></div>
</section>
<footer class="footer">
<div class="footer-left">
<button id="copyRenderedBtn" class="ghost" type="button">Copy</button>
<button id="copyRawBtn" class="ghost" type="button">Copy Markdown</button>
<button id="clearOutputBtn" class="ghost" type="button">Clear</button>
</div>
<button id="settingsBtn" class="link">Open Settings</button>
</footer>
<script src="popup.js"></script>
</body>
</html>

View File

@@ -0,0 +1,761 @@
const runBtn = document.getElementById("runBtn");
const abortBtn = document.getElementById("abortBtn");
const taskSelect = document.getElementById("taskSelect");
const envSelect = document.getElementById("envSelect");
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 copyRenderedBtn = document.getElementById("copyRenderedBtn");
const copyRawBtn = document.getElementById("copyRawBtn");
const clearOutputBtn = document.getElementById("clearOutputBtn");
const OUTPUT_STORAGE_KEY = "lastOutput";
const AUTO_RUN_KEY = "autoRunDefaultTask";
const LAST_TASK_KEY = "lastSelectedTaskId";
const LAST_ENV_KEY = "lastSelectedEnvId";
const state = {
postingText: "",
tasks: [],
envs: [],
port: null,
isAnalyzing: false,
outputRaw: "",
autoRunPending: false,
selectedTaskId: "",
selectedEnvId: ""
};
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 escapeHtml(text) {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function escapeAttribute(text) {
return text.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function sanitizeUrl(url) {
const trimmed = url.trim().replace(/&amp;/g, "&");
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return "";
}
function applyInline(text) {
if (!text) return "";
const codeSpans = [];
let output = text.replace(/`([^`]+)`/g, (_match, code) => {
const id = codeSpans.length;
codeSpans.push(code);
return `@@CODESPAN${id}@@`;
});
output = output.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label, url) => {
const safeUrl = sanitizeUrl(url);
if (!safeUrl) return label;
return `<a href="${escapeAttribute(
safeUrl
)}" target="_blank" rel="noreferrer">${label}</a>`;
});
output = output.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
output = output.replace(/\*([^*]+)\*/g, "<em>$1</em>");
output = output.replace(/_([^_]+)_/g, "<em>$1</em>");
output = output.replace(/@@CODESPAN(\d+)@@/g, (_match, id) => {
const code = codeSpans[Number(id)] || "";
return `<code>${code}</code>`;
});
return output;
}
function renderMarkdown(rawText) {
const { text, blocks } = (() => {
const escaped = escapeHtml(rawText || "");
const codeBlocks = [];
const replaced = escaped.replace(/```([\s\S]*?)```/g, (_match, code) => {
let content = code;
if (content.startsWith("\n")) content = content.slice(1);
const firstLine = content.split("\n")[0] || "";
if (/^[a-z0-9+.#-]+$/i.test(firstLine.trim()) && content.includes("\n")) {
content = content.split("\n").slice(1).join("\n");
}
const id = codeBlocks.length;
codeBlocks.push(content);
return `@@CODEBLOCK${id}@@`;
});
return { text: replaced, blocks: codeBlocks };
})();
const lines = text.split(/\r?\n/);
const result = [];
let paragraph = [];
let listType = null;
let inBlockquote = false;
let quoteLines = [];
const flushParagraph = () => {
if (!paragraph.length) return;
result.push(`<p>${applyInline(paragraph.join("<br>"))}</p>`);
paragraph = [];
};
const closeList = () => {
if (!listType) return;
result.push(`</${listType}>`);
listType = null;
};
const openList = (type) => {
if (listType === type) return;
if (listType) result.push(`</${listType}>`);
listType = type;
result.push(`<${type}>`);
};
const closeBlockquote = () => {
if (!inBlockquote) return;
result.push(`<blockquote>${applyInline(quoteLines.join("<br>"))}</blockquote>`);
inBlockquote = false;
quoteLines = [];
};
for (const line of lines) {
const trimmed = line.trim();
const isQuoteLine = /^\s*>\s?/.test(line);
if (trimmed === "") {
flushParagraph();
closeList();
closeBlockquote();
continue;
}
if (inBlockquote && !isQuoteLine) {
closeBlockquote();
}
if (/^@@CODEBLOCK\d+@@$/.test(trimmed)) {
flushParagraph();
closeList();
closeBlockquote();
result.push(trimmed);
continue;
}
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
if (headingMatch) {
flushParagraph();
closeList();
closeBlockquote();
const level = headingMatch[1].length;
result.push(`<h${level}>${applyInline(headingMatch[2])}</h${level}>`);
continue;
}
if (/^(\s*[-*_])\1{2,}\s*$/.test(line)) {
flushParagraph();
closeList();
closeBlockquote();
result.push("<hr>");
continue;
}
if (isQuoteLine) {
if (!inBlockquote) {
flushParagraph();
closeList();
inBlockquote = true;
quoteLines = [];
}
quoteLines.push(line.replace(/^\s*>\s?/, ""));
continue;
}
const unorderedMatch = line.match(/^[-*+]\s+(.+)$/);
if (unorderedMatch) {
flushParagraph();
closeBlockquote();
openList("ul");
result.push(`<li>${applyInline(unorderedMatch[1])}</li>`);
continue;
}
const orderedMatch = line.match(/^\d+\.\s+(.+)$/);
if (orderedMatch) {
flushParagraph();
closeBlockquote();
openList("ol");
result.push(`<li>${applyInline(orderedMatch[1])}</li>`);
continue;
}
paragraph.push(line);
}
flushParagraph();
closeList();
closeBlockquote();
return result
.join("\n")
.replace(/@@CODEBLOCK(\d+)@@/g, (_match, id) => {
const code = blocks[Number(id)] || "";
return `<pre><code>${code}</code></pre>`;
});
}
function renderOutput() {
outputEl.innerHTML = renderMarkdown(state.outputRaw);
outputEl.scrollTop = outputEl.scrollHeight;
}
function persistOutputNow() {
return chrome.storage.local.set({ [OUTPUT_STORAGE_KEY]: state.outputRaw });
}
function setStatus(message) {
statusEl.textContent = message;
}
function applyTheme(theme) {
const value = theme || "system";
document.documentElement.dataset.theme = value;
}
function setAnalyzing(isAnalyzing) {
state.isAnalyzing = isAnalyzing;
runBtn.disabled = isAnalyzing;
abortBtn.disabled = !isAnalyzing;
runBtn.classList.toggle("hidden", isAnalyzing);
abortBtn.classList.toggle("hidden", !isAnalyzing);
updateTaskSelectState();
updateEnvSelectState();
}
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);
updateTaskSelectState();
return;
}
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 renderEnvironments(envs) {
state.envs = envs;
envSelect.innerHTML = "";
if (!envs.length) {
const option = document.createElement("option");
option.textContent = "No environments configured";
option.value = "";
envSelect.appendChild(option);
updateEnvSelectState();
return;
}
for (const env of envs) {
const option = document.createElement("option");
option.value = env.id;
option.textContent = env.name || "Default";
envSelect.appendChild(option);
}
updateEnvSelectState();
}
function updateTaskSelectState() {
const hasTasks = state.tasks.length > 0;
taskSelect.disabled = state.isAnalyzing || !hasTasks;
}
function updateEnvSelectState() {
const hasEnvs = state.envs.length > 0;
envSelect.disabled = state.isAnalyzing || !hasEnvs;
}
function getTaskDefaultEnvId(task) {
return task?.defaultEnvId || state.envs[0]?.id || "";
}
function setEnvironmentSelection(envId) {
const target =
envId && state.envs.some((env) => env.id === envId)
? envId
: state.envs[0]?.id || "";
if (target) {
envSelect.value = target;
}
state.selectedEnvId = target;
}
function selectTask(taskId, { resetEnv } = { resetEnv: false }) {
if (!taskId) return;
taskSelect.value = taskId;
state.selectedTaskId = taskId;
const task = state.tasks.find((item) => item.id === taskId);
if (resetEnv) {
setEnvironmentSelection(getTaskDefaultEnvId(task));
}
}
async function persistSelections() {
await chrome.storage.local.set({
[LAST_TASK_KEY]: state.selectedTaskId,
[LAST_ENV_KEY]: state.selectedEnvId
});
}
function isWaterlooWorksUrl(url) {
try {
return new URL(url).hostname === "waterlooworks.uwaterloo.ca";
} catch {
return false;
}
}
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;
}
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) {
const msg =
error.message && error.message.includes("Receiving end does not exist")
? "Couldn't reach the page. Try refreshing WaterlooWorks and retry."
: error.message;
reject(new Error(msg));
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") {
state.outputRaw += message.text;
renderOutput();
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");
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 loadConfig() {
const stored = await getStorage([
"tasks",
"envConfigs",
LAST_TASK_KEY,
LAST_ENV_KEY
]);
const tasks = Array.isArray(stored.tasks) ? stored.tasks : [];
const envs = Array.isArray(stored.envConfigs) ? stored.envConfigs : [];
renderTasks(tasks);
renderEnvironments(envs);
if (!tasks.length) {
state.selectedTaskId = "";
state.selectedEnvId = envs[0]?.id || "";
return;
}
const storedTaskId = stored[LAST_TASK_KEY];
const storedEnvId = stored[LAST_ENV_KEY];
const initialTaskId = tasks.some((task) => task.id === storedTaskId)
? storedTaskId
: tasks[0].id;
selectTask(initialTaskId, { resetEnv: false });
const task = tasks.find((item) => item.id === initialTaskId);
if (storedEnvId && envs.some((env) => env.id === storedEnvId)) {
setEnvironmentSelection(storedEnvId);
} else {
setEnvironmentSelection(getTaskDefaultEnvId(task));
}
if (
storedTaskId !== state.selectedTaskId ||
storedEnvId !== state.selectedEnvId
) {
await persistSelections();
}
maybeRunDefaultTask();
}
async function loadTheme() {
const { theme = "system" } = await getStorage(["theme"]);
applyTheme(theme);
}
async function handleExtract() {
setStatus("Extracting...");
try {
const response = await sendToActiveTab({ type: "EXTRACT_POSTING" });
if (!response?.ok) {
setStatus(response?.error || "No posting detected.");
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;
}
}
async function handleAnalyze() {
if (!state.postingText) {
setStatus("Extract a job posting first.");
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) {
setStatus("Select a task prompt.");
return;
}
const {
apiKeys = [],
activeApiKeyId = "",
apiConfigs = [],
activeApiConfigId = "",
envConfigs = [],
apiBaseUrl,
apiKeyHeader,
apiKeyPrefix,
model,
systemPrompt,
resume
} = await getStorage([
"apiKeys",
"activeApiKeyId",
"apiConfigs",
"activeApiConfigId",
"envConfigs",
"apiBaseUrl",
"apiKeyHeader",
"apiKeyPrefix",
"model",
"systemPrompt",
"resume"
]);
const resolvedConfigs = Array.isArray(apiConfigs) ? apiConfigs : [];
const resolvedEnvs = Array.isArray(envConfigs) ? envConfigs : [];
const selectedEnvId = envSelect.value;
const activeEnv =
resolvedEnvs.find((entry) => entry.id === selectedEnvId) ||
resolvedEnvs[0];
if (!activeEnv) {
setStatus("Add an environment in Settings.");
return;
}
const resolvedSystemPrompt =
activeEnv.systemPrompt ?? systemPrompt ?? "";
const resolvedApiConfigId =
activeEnv.apiConfigId || activeApiConfigId || resolvedConfigs[0]?.id || "";
const activeConfig =
resolvedConfigs.find((entry) => entry.id === resolvedApiConfigId) ||
resolvedConfigs[0];
if (!activeConfig) {
setStatus("Add an API configuration in Settings.");
return;
}
const isAdvanced = Boolean(activeConfig?.advanced);
const resolvedApiUrl = activeConfig?.apiUrl || "";
const resolvedTemplate = activeConfig?.requestTemplate || "";
const resolvedApiBaseUrl = activeConfig?.apiBaseUrl || apiBaseUrl || "";
const resolvedApiKeyHeader = activeConfig?.apiKeyHeader ?? apiKeyHeader ?? "";
const resolvedApiKeyPrefix = activeConfig?.apiKeyPrefix ?? apiKeyPrefix ?? "";
const resolvedModel = activeConfig?.model || model || "";
const resolvedKeys = Array.isArray(apiKeys) ? apiKeys : [];
const resolvedKeyId =
activeConfig?.apiKeyId || activeApiKeyId || resolvedKeys[0]?.id || "";
const activeKey = resolvedKeys.find((entry) => entry.id === resolvedKeyId);
const apiKey = activeKey?.key || "";
if (isAdvanced) {
if (!resolvedApiUrl) {
setStatus("Set an API URL in Settings.");
return;
}
if (!resolvedTemplate) {
setStatus("Set a request template in Settings.");
return;
}
const needsKey =
Boolean(resolvedApiKeyHeader) ||
resolvedTemplate.includes("API_KEY_GOES_HERE");
if (needsKey && !apiKey) {
setStatus("Add an API key in Settings.");
return;
}
} else {
if (!resolvedApiBaseUrl) {
setStatus("Set an API base URL in Settings.");
return;
}
if (resolvedApiKeyHeader && !apiKey) {
setStatus("Add an API key in Settings.");
return;
}
if (!resolvedModel) {
setStatus("Set a model name in Settings.");
return;
}
}
const promptText = buildUserMessage(resume || "", task.text || "", state.postingText);
updatePromptCount(promptText.length);
state.outputRaw = "";
renderOutput();
setAnalyzing(true);
setStatus("Analyzing...");
const port = ensurePort();
port.postMessage({
type: "START_ANALYSIS",
payload: {
apiKey,
apiMode: isAdvanced ? "advanced" : "basic",
apiUrl: resolvedApiUrl,
requestTemplate: resolvedTemplate,
apiBaseUrl: resolvedApiBaseUrl,
apiKeyHeader: resolvedApiKeyHeader,
apiKeyPrefix: resolvedApiKeyPrefix,
model: resolvedModel,
systemPrompt: resolvedSystemPrompt,
resume: resume || "",
taskText: task.text || "",
postingText: state.postingText,
tabId: tab.id
}
});
}
async function handleExtractAndAnalyze() {
const extracted = await handleExtract();
if (!extracted) return;
await handleAnalyze();
}
function handleAbort() {
if (!state.port) return;
state.port.postMessage({ type: "ABORT_ANALYSIS" });
setAnalyzing(false);
setStatus("Aborted.");
}
async function handleClearOutput() {
state.outputRaw = "";
renderOutput();
await persistOutputNow();
setStatus("Output cleared.");
}
async function copyTextToClipboard(text, label) {
try {
await navigator.clipboard.writeText(text);
setStatus(`${label} copied.`);
} catch (error) {
setStatus(`Unable to copy ${label.toLowerCase()}.`);
}
}
function handleCopyRendered() {
const text = outputEl.innerText || "";
if (!text.trim()) {
setStatus("Nothing to copy.");
return;
}
void copyTextToClipboard(text, "Output");
}
function handleCopyRaw() {
const text = state.outputRaw || "";
if (!text.trim()) {
setStatus("Nothing to copy.");
return;
}
void copyTextToClipboard(text, "Markdown");
}
runBtn.addEventListener("click", handleExtractAndAnalyze);
abortBtn.addEventListener("click", handleAbort);
settingsBtn.addEventListener("click", () => chrome.runtime.openOptionsPage());
copyRenderedBtn.addEventListener("click", handleCopyRendered);
copyRawBtn.addEventListener("click", handleCopyRaw);
clearOutputBtn.addEventListener("click", () => void handleClearOutput());
taskSelect.addEventListener("change", () => {
selectTask(taskSelect.value, { resetEnv: true });
void persistSelections();
});
envSelect.addEventListener("change", () => {
setEnvironmentSelection(envSelect.value);
void persistSelections();
});
updatePostingCount();
updatePromptCount(0);
renderOutput();
setAnalyzing(false);
loadConfig();
loadTheme();
async function loadSavedOutput() {
const stored = await getStorage([OUTPUT_STORAGE_KEY]);
state.outputRaw = stored[OUTPUT_STORAGE_KEY] || "";
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;
selectTask(state.tasks[0].id, { resetEnv: true });
void persistSelections();
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");
}
});

View File

@@ -0,0 +1,335 @@
:root {
--ink: #221b15;
--muted: #6b5f55;
--accent: #b14d2b;
--panel: #fffaf1;
--border: #eadbc8;
--bg: #f5ead7;
--input-bg: #fffdf9;
--input-fg: var(--ink);
--card-bg: #fffefb;
--panel-shadow: 0 18px 40px rgba(120, 85, 55, 0.12);
color-scheme: light;
}
:root[data-theme="dark"] {
--ink: #abb2bf;
--muted: #8b93a5;
--accent: #61afef;
--panel: #2f343f;
--border: #3e4451;
--bg: linear-gradient(160deg, #2a2f3a, #1f232b);
--input-bg: #2b303b;
--input-fg: var(--ink);
--card-bg: #2b303b;
--panel-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
color-scheme: dark;
}
:root[data-theme="light"] {
color-scheme: light;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 24px;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color: var(--ink);
background: var(--bg);
}
.title-block {
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;
}
.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: var(--panel-shadow);
}
.panel-summary {
list-style: none;
cursor: pointer;
display: flex;
align-items: baseline;
justify-content: flex-start;
gap: 12px;
}
.panel-summary::-webkit-details-marker {
display: none;
}
.panel-caret {
display: inline-flex;
align-items: center;
width: 16px;
justify-content: center;
color: var(--muted);
font-weight: 700;
font-family: "Segoe UI Symbol", "Apple Symbols", system-ui, sans-serif;
}
.panel-caret .caret-open {
display: none;
}
.panel[open] .panel-caret .caret-open {
display: inline;
}
.panel[open] .panel-caret .caret-closed {
display: none;
}
.panel-body {
margin-top: 12px;
}
.panel[open] .panel-summary {
margin-bottom: 6px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.row-actions {
display: flex;
gap: 8px;
}
.row-title {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
h2 {
margin: 0;
font-size: 16px;
text-transform: uppercase;
letter-spacing: 1px;
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;
margin-bottom: 12px;
}
label {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--muted);
}
input,
textarea,
select {
width: 100%;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--input-bg);
color: var(--input-fg);
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: var(--card-bg);
display: grid;
gap: 8px;
}
.api-keys {
display: grid;
gap: 12px;
}
.api-key-card {
padding: 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--card-bg);
display: grid;
gap: 8px;
}
.api-key-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.api-key-actions .delete,
.api-config-actions .delete,
.env-config-actions .delete,
.task-actions .delete {
background: #c0392b;
border-color: #c0392b;
color: #fff6f2;
}
.api-configs {
display: grid;
gap: 12px;
}
.api-config-card {
padding: 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--card-bg);
display: grid;
gap: 8px;
}
.api-config-card.is-advanced .basic-only {
display: none;
}
.api-config-card:not(.is-advanced) .advanced-only {
display: none;
}
.api-config-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.env-configs {
display: grid;
gap: 12px;
}
.env-config-card {
padding: 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--card-bg);
display: grid;
gap: 8px;
}
.env-config-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]),
:root[data-theme="system"] {
--ink: #abb2bf;
--muted: #8b93a5;
--accent: #61afef;
--panel: #2f343f;
--border: #3e4451;
--bg: linear-gradient(160deg, #2a2f3a, #1f232b);
--input-bg: #2b303b;
--input-fg: var(--ink);
--card-bg: #2b303b;
--panel-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
color-scheme: dark;
}
}
.task-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
}

View File

@@ -0,0 +1,133 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>WWCompanion Settings</title>
<link rel="stylesheet" href="settings.css" />
</head>
<body>
<header class="title-block">
<div class="title">WWCompanion Settings</div>
<div class="subtitle">Configure prompts, resume, and API access</div>
</header>
<div class="page-bar">
<div id="status" class="status"></div>
<button id="saveBtn" class="accent">Save Settings</button>
</div>
<details class="panel">
<summary class="panel-summary">
<span class="panel-caret" aria-hidden="true">
<span class="caret-closed"></span>
<span class="caret-open"></span>
</span>
<h2>Appearance</h2>
</summary>
<div class="panel-body">
<div class="field">
<label for="themeSelect">Theme</label>
<select id="themeSelect">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
</div>
</details>
<details class="panel">
<summary class="panel-summary">
<span class="panel-caret" aria-hidden="true">
<span class="caret-closed"></span>
<span class="caret-open"></span>
</span>
<h2>API KEYS</h2>
</summary>
<div class="panel-body">
<div class="row">
<div></div>
<button id="addApiKeyBtn" class="ghost" type="button">Add Key</button>
</div>
<div id="apiKeys" class="api-keys"></div>
</div>
</details>
<details class="panel">
<summary class="panel-summary">
<span class="panel-caret" aria-hidden="true">
<span class="caret-closed"></span>
<span class="caret-open"></span>
</span>
<h2>API</h2>
</summary>
<div class="panel-body">
<div class="row">
<div></div>
<div class="row-actions">
<button id="addApiConfigBtn" class="ghost" type="button">Add Config</button>
</div>
</div>
<div id="apiConfigs" class="api-configs"></div>
</div>
</details>
<details class="panel">
<summary class="panel-summary">
<span class="panel-caret" aria-hidden="true">
<span class="caret-closed"></span>
<span class="caret-open"></span>
</span>
<div class="row-title">
<h2>Environment</h2>
<span class="hint hint-accent">API configuration and system prompt go here</span>
</div>
</summary>
<div class="panel-body">
<div class="row">
<div></div>
<button id="addEnvConfigBtn" class="ghost" type="button">Add Config</button>
</div>
<div id="envConfigs" class="env-configs"></div>
</div>
</details>
<details class="panel">
<summary class="panel-summary">
<span class="panel-caret" aria-hidden="true">
<span class="caret-closed"></span>
<span class="caret-open"></span>
</span>
<div class="row-title">
<h2>Resume</h2>
<span class="hint hint-accent">Text to your profile goes here</span>
</div>
</summary>
<div class="panel-body">
<textarea id="resume" rows="10" placeholder="Paste your resume text..."></textarea>
</div>
</details>
<details class="panel">
<summary class="panel-summary">
<span class="panel-caret" aria-hidden="true">
<span class="caret-closed"></span>
<span class="caret-open"></span>
</span>
<div class="row-title">
<h2>Task Presets</h2>
<span class="hint hint-accent">Top task is the default</span>
</div>
</summary>
<div class="panel-body">
<div class="row">
<div></div>
<button id="addTaskBtn" class="ghost" type="button">Add Task</button>
</div>
<div id="tasks" class="tasks"></div>
</div>
</details>
<script src="settings.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff