Compare commits
14 Commits
33b3e414ae
...
wwcompanio
| Author | SHA1 | Date | |
|---|---|---|---|
| 23d2f9475b | |||
| e670c8b60e | |||
| b5cd3bd426 | |||
| 411d585f84 | |||
| be7d7a1bce | |||
| 24370c0826 | |||
| 2e6dc5c028 | |||
| ad05b63285 | |||
| 743b83deb3 | |||
| 2f7d1acaa9 | |||
| 322a3f4488 | |||
| 5829aa0269 | |||
| 08c625ae94 | |||
| b8cb75acf4 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1 +1,3 @@
|
|||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
**/*.crx
|
||||||
|
**/*.pem
|
||||||
|
|||||||
61
README.md
Normal file
61
README.md
Normal 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.
|
||||||
|
```
|
||||||
189
background.js
189
background.js
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
48
content.js
48
content.js
@@ -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
161
popup.css
@@ -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;
|
|
||||||
}
|
|
||||||
42
popup.html
42
popup.html
@@ -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
223
popup.js
@@ -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();
|
|
||||||
334
wwcompanion-extension/background.js
Normal file
334
wwcompanion-extension/background.js
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
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: "",
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
apiBaseUrl,
|
||||||
|
apiKeyHeader,
|
||||||
|
apiKeyPrefix,
|
||||||
|
model,
|
||||||
|
systemPrompt,
|
||||||
|
resume,
|
||||||
|
taskText,
|
||||||
|
postingText,
|
||||||
|
tabId
|
||||||
|
} = payload || {};
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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 || ""}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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(`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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
140
wwcompanion-extension/content.js
Normal file
140
wwcompanion-extension/content.js
Normal 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();
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "WWCompanion",
|
"name": "WWCompanion",
|
||||||
"version": "0.1.0",
|
"version": "0.2.2",
|
||||||
"description": "Manual reasoning companion for WaterlooWorks job postings.",
|
"description": "AI companion for WaterlooWorks job postings.",
|
||||||
"permissions": ["storage", "activeTab"],
|
"permissions": ["storage", "activeTab"],
|
||||||
"host_permissions": ["https://waterlooworks.uwaterloo.ca/*"],
|
"host_permissions": ["https://waterlooworks.uwaterloo.ca/*"],
|
||||||
"action": {
|
"action": {
|
||||||
333
wwcompanion-extension/popup.css
Normal file
333
wwcompanion-extension/popup.css
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
: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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(64px, 0.8fr) minmax(0, 1.4fr) minmax(64px, 0.8fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row button {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row .primary {
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: stretch;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-row button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-row .ghost {
|
||||||
|
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;
|
||||||
|
}
|
||||||
56
wwcompanion-extension/popup.html
Normal file
56
wwcompanion-extension/popup.html
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<!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="field inline-field">
|
||||||
|
<label for="taskSelect">Task</label>
|
||||||
|
<select id="taskSelect"></select>
|
||||||
|
</div>
|
||||||
|
<div class="button-row">
|
||||||
|
<button id="extractBtn" class="primary">Extract</button>
|
||||||
|
<button id="extractRunBtn" class="accent">Extract & Run</button>
|
||||||
|
<button id="analyzeBtn" class="ghost">Run Task</button>
|
||||||
|
</div>
|
||||||
|
<div id="stopRow" class="stop-row hidden">
|
||||||
|
<button id="abortBtn" class="ghost" 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>
|
||||||
591
wwcompanion-extension/popup.js
Normal file
591
wwcompanion-extension/popup.js
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
const extractBtn = document.getElementById("extractBtn");
|
||||||
|
const analyzeBtn = document.getElementById("analyzeBtn");
|
||||||
|
const abortBtn = document.getElementById("abortBtn");
|
||||||
|
const extractRunBtn = document.getElementById("extractRunBtn");
|
||||||
|
const stopRow = document.getElementById("stopRow");
|
||||||
|
const buttonRow = document.querySelector(".button-row");
|
||||||
|
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 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 state = {
|
||||||
|
postingText: "",
|
||||||
|
tasks: [],
|
||||||
|
port: null,
|
||||||
|
isAnalyzing: false,
|
||||||
|
outputRaw: "",
|
||||||
|
autoRunPending: 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 escapeHtml(text) {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttribute(text) {
|
||||||
|
return text.replace(/&/g, "&").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeUrl(url) {
|
||||||
|
const trimmed = url.trim().replace(/&/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;
|
||||||
|
analyzeBtn.disabled = isAnalyzing;
|
||||||
|
abortBtn.disabled = !isAnalyzing;
|
||||||
|
extractBtn.disabled = isAnalyzing;
|
||||||
|
extractRunBtn.disabled = isAnalyzing;
|
||||||
|
if (buttonRow && stopRow) {
|
||||||
|
buttonRow.classList.toggle("hidden", isAnalyzing);
|
||||||
|
stopRow.classList.toggle("hidden", !isAnalyzing);
|
||||||
|
}
|
||||||
|
updateTaskSelectState();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 updateTaskSelectState() {
|
||||||
|
const hasTasks = state.tasks.length > 0;
|
||||||
|
taskSelect.disabled = state.isAnalyzing || !hasTasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWaterlooWorksUrl(url) {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname === "waterlooworks.uwaterloo.ca";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendToActiveTab(message) {
|
||||||
|
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 loadTasks() {
|
||||||
|
const { tasks = [] } = await getStorage(["tasks"]);
|
||||||
|
renderTasks(tasks);
|
||||||
|
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 { apiKey, apiBaseUrl, apiKeyHeader, apiKeyPrefix, model, systemPrompt, resume } =
|
||||||
|
await getStorage([
|
||||||
|
"apiKey",
|
||||||
|
"apiBaseUrl",
|
||||||
|
"apiKeyHeader",
|
||||||
|
"apiKeyPrefix",
|
||||||
|
"model",
|
||||||
|
"systemPrompt",
|
||||||
|
"resume"
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!apiBaseUrl) {
|
||||||
|
setStatus("Set an API base URL in Settings.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiKeyHeader && !apiKey) {
|
||||||
|
setStatus("Add your 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);
|
||||||
|
|
||||||
|
state.outputRaw = "";
|
||||||
|
renderOutput();
|
||||||
|
setAnalyzing(true);
|
||||||
|
setStatus("Analyzing...");
|
||||||
|
|
||||||
|
const port = ensurePort();
|
||||||
|
port.postMessage({
|
||||||
|
type: "START_ANALYSIS",
|
||||||
|
payload: {
|
||||||
|
apiKey,
|
||||||
|
apiBaseUrl,
|
||||||
|
apiKeyHeader,
|
||||||
|
apiKeyPrefix,
|
||||||
|
model,
|
||||||
|
systemPrompt: systemPrompt || "",
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
extractBtn.addEventListener("click", handleExtract);
|
||||||
|
analyzeBtn.addEventListener("click", handleAnalyze);
|
||||||
|
extractRunBtn.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());
|
||||||
|
|
||||||
|
updatePostingCount();
|
||||||
|
updatePromptCount(0);
|
||||||
|
renderOutput();
|
||||||
|
setAnalyzing(false);
|
||||||
|
loadTasks();
|
||||||
|
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;
|
||||||
|
taskSelect.value = state.tasks[0].id;
|
||||||
|
state.autoRunPending = false;
|
||||||
|
void handleExtractAndAnalyze();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSavedOutput();
|
||||||
|
loadAutoRunRequest();
|
||||||
|
ensurePort();
|
||||||
|
|
||||||
|
chrome.storage.onChanged.addListener((changes) => {
|
||||||
|
if (changes[AUTO_RUN_KEY]?.newValue) {
|
||||||
|
state.autoRunPending = true;
|
||||||
|
void chrome.storage.local.remove(AUTO_RUN_KEY);
|
||||||
|
maybeRunDefaultTask();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changes[OUTPUT_STORAGE_KEY]?.newValue !== undefined) {
|
||||||
|
if (!state.isAnalyzing || !state.port) {
|
||||||
|
state.outputRaw = changes[OUTPUT_STORAGE_KEY].newValue || "";
|
||||||
|
renderOutput();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changes.theme) {
|
||||||
|
applyTheme(changes.theme.newValue || "system");
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -5,6 +5,29 @@
|
|||||||
--panel: #fffaf1;
|
--panel: #fffaf1;
|
||||||
--border: #eadbc8;
|
--border: #eadbc8;
|
||||||
--bg: #f5ead7;
|
--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;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -14,16 +37,23 @@
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Palatino,
|
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
"Times New Roman", serif;
|
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background: linear-gradient(160deg, #f8efe1, #efe0c9);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-block {
|
.title-block {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 26px;
|
font-size: 26px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -40,7 +70,7 @@ body {
|
|||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
box-shadow: 0 18px 40px rgba(120, 85, 55, 0.12);
|
box-shadow: var(--panel-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
@@ -51,6 +81,13 @@ body {
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@@ -59,6 +96,17 @@ h2 {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-accent {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -73,12 +121,14 @@ label {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
textarea {
|
textarea,
|
||||||
|
select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: #fffdf9;
|
background: var(--input-bg);
|
||||||
|
color: var(--input-fg);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
@@ -131,13 +181,41 @@ button:active {
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: #fffefb;
|
background: var(--card-bg);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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 {
|
.task-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 34px;
|
||||||
|
padding: 6px 0;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.delete {
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
@@ -11,11 +11,15 @@
|
|||||||
<div class="title">WWCompanion Settings</div>
|
<div class="title">WWCompanion Settings</div>
|
||||||
<div class="subtitle">Configure prompts, resume, and API access</div>
|
<div class="subtitle">Configure prompts, resume, and API access</div>
|
||||||
</header>
|
</header>
|
||||||
|
<div class="page-bar">
|
||||||
|
<div id="status" class="status"></div>
|
||||||
|
<button id="saveBtn" class="accent">Save Settings</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<h2>OpenAI</h2>
|
<h2>API</h2>
|
||||||
<button id="saveBtn" class="accent">Save Settings</button>
|
<button id="resetApiBtn" class="ghost" type="button">Reset to OpenAI</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="apiKey">API Key</label>
|
<label for="apiKey">API Key</label>
|
||||||
@@ -24,11 +28,38 @@
|
|||||||
<button id="toggleKey" class="ghost" type="button">Show</button>
|
<button id="toggleKey" class="ghost" type="button">Show</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="apiBaseUrl">API Base URL</label>
|
||||||
|
<input
|
||||||
|
id="apiBaseUrl"
|
||||||
|
type="text"
|
||||||
|
placeholder="https://api.openai.com/v1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="apiKeyHeader">API Key Header</label>
|
||||||
|
<input id="apiKeyHeader" type="text" placeholder="Authorization" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="apiKeyPrefix">API Key Prefix</label>
|
||||||
|
<input id="apiKeyPrefix" type="text" placeholder="Bearer " />
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="model">Model name</label>
|
<label for="model">Model name</label>
|
||||||
<input id="model" type="text" placeholder="gpt-4o-mini" />
|
<input id="model" type="text" placeholder="gpt-4o-mini" />
|
||||||
</div>
|
</div>
|
||||||
<div id="status" class="status"></div>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Appearance</h2>
|
||||||
|
<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>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
@@ -43,7 +74,10 @@
|
|||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<h2>Task Presets</h2>
|
<div class="row-title">
|
||||||
|
<h2>Task Presets</h2>
|
||||||
|
<span class="hint hint-accent">Top task is the default</span>
|
||||||
|
</div>
|
||||||
<button id="addTaskBtn" class="ghost" type="button">Add Task</button>
|
<button id="addTaskBtn" class="ghost" type="button">Add Task</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="tasks" class="tasks"></div>
|
<div id="tasks" class="tasks"></div>
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
const apiKeyInput = document.getElementById("apiKey");
|
const apiKeyInput = document.getElementById("apiKey");
|
||||||
|
const apiBaseUrlInput = document.getElementById("apiBaseUrl");
|
||||||
|
const apiKeyHeaderInput = document.getElementById("apiKeyHeader");
|
||||||
|
const apiKeyPrefixInput = document.getElementById("apiKeyPrefix");
|
||||||
const modelInput = document.getElementById("model");
|
const modelInput = document.getElementById("model");
|
||||||
const systemPromptInput = document.getElementById("systemPrompt");
|
const systemPromptInput = document.getElementById("systemPrompt");
|
||||||
const resumeInput = document.getElementById("resume");
|
const resumeInput = document.getElementById("resume");
|
||||||
@@ -7,6 +10,14 @@ const addTaskBtn = document.getElementById("addTaskBtn");
|
|||||||
const tasksContainer = document.getElementById("tasks");
|
const tasksContainer = document.getElementById("tasks");
|
||||||
const statusEl = document.getElementById("status");
|
const statusEl = document.getElementById("status");
|
||||||
const toggleKeyBtn = document.getElementById("toggleKey");
|
const toggleKeyBtn = document.getElementById("toggleKey");
|
||||||
|
const themeSelect = document.getElementById("themeSelect");
|
||||||
|
const resetApiBtn = document.getElementById("resetApiBtn");
|
||||||
|
|
||||||
|
const OPENAI_DEFAULTS = {
|
||||||
|
apiBaseUrl: "https://api.openai.com/v1",
|
||||||
|
apiKeyHeader: "Authorization",
|
||||||
|
apiKeyPrefix: "Bearer "
|
||||||
|
};
|
||||||
|
|
||||||
function getStorage(keys) {
|
function getStorage(keys) {
|
||||||
return new Promise((resolve) => chrome.storage.local.get(keys, resolve));
|
return new Promise((resolve) => chrome.storage.local.get(keys, resolve));
|
||||||
@@ -20,6 +31,11 @@ function setStatus(message) {
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme) {
|
||||||
|
const value = theme || "system";
|
||||||
|
document.documentElement.dataset.theme = value;
|
||||||
|
}
|
||||||
|
|
||||||
function newTaskId() {
|
function newTaskId() {
|
||||||
if (crypto?.randomUUID) return crypto.randomUUID();
|
if (crypto?.randomUUID) return crypto.randomUUID();
|
||||||
return `task-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
return `task-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||||
@@ -54,14 +70,56 @@ function buildTaskCard(task) {
|
|||||||
|
|
||||||
const actions = document.createElement("div");
|
const actions = document.createElement("div");
|
||||||
actions.className = "task-actions";
|
actions.className = "task-actions";
|
||||||
|
const moveUpBtn = document.createElement("button");
|
||||||
|
moveUpBtn.type = "button";
|
||||||
|
moveUpBtn.className = "ghost icon-btn move-up";
|
||||||
|
moveUpBtn.textContent = "↑";
|
||||||
|
moveUpBtn.setAttribute("aria-label", "Move task up");
|
||||||
|
moveUpBtn.setAttribute("title", "Move up");
|
||||||
|
const moveDownBtn = document.createElement("button");
|
||||||
|
moveDownBtn.type = "button";
|
||||||
|
moveDownBtn.className = "ghost icon-btn move-down";
|
||||||
|
moveDownBtn.textContent = "↓";
|
||||||
|
moveDownBtn.setAttribute("aria-label", "Move task down");
|
||||||
|
moveDownBtn.setAttribute("title", "Move down");
|
||||||
|
const addBelowBtn = document.createElement("button");
|
||||||
|
addBelowBtn.type = "button";
|
||||||
|
addBelowBtn.className = "ghost icon-btn add-below";
|
||||||
|
addBelowBtn.textContent = "+";
|
||||||
|
addBelowBtn.setAttribute("aria-label", "Add task below");
|
||||||
|
addBelowBtn.setAttribute("title", "Add below");
|
||||||
const duplicateBtn = document.createElement("button");
|
const duplicateBtn = document.createElement("button");
|
||||||
duplicateBtn.type = "button";
|
duplicateBtn.type = "button";
|
||||||
duplicateBtn.className = "ghost";
|
duplicateBtn.className = "ghost duplicate";
|
||||||
duplicateBtn.textContent = "Duplicate";
|
duplicateBtn.textContent = "Duplicate";
|
||||||
|
duplicateBtn.setAttribute("aria-label", "Duplicate task");
|
||||||
|
duplicateBtn.setAttribute("title", "Duplicate");
|
||||||
const deleteBtn = document.createElement("button");
|
const deleteBtn = document.createElement("button");
|
||||||
deleteBtn.type = "button";
|
deleteBtn.type = "button";
|
||||||
deleteBtn.className = "ghost";
|
deleteBtn.className = "ghost icon-btn delete";
|
||||||
deleteBtn.textContent = "Delete";
|
deleteBtn.textContent = "🗑";
|
||||||
|
deleteBtn.setAttribute("aria-label", "Delete task");
|
||||||
|
deleteBtn.setAttribute("title", "Delete");
|
||||||
|
|
||||||
|
moveUpBtn.addEventListener("click", () => {
|
||||||
|
const previous = card.previousElementSibling;
|
||||||
|
if (!previous) return;
|
||||||
|
tasksContainer.insertBefore(card, previous);
|
||||||
|
updateTaskControls();
|
||||||
|
});
|
||||||
|
|
||||||
|
moveDownBtn.addEventListener("click", () => {
|
||||||
|
const next = card.nextElementSibling;
|
||||||
|
if (!next) return;
|
||||||
|
tasksContainer.insertBefore(card, next.nextElementSibling);
|
||||||
|
updateTaskControls();
|
||||||
|
});
|
||||||
|
|
||||||
|
addBelowBtn.addEventListener("click", () => {
|
||||||
|
const newCard = buildTaskCard({ id: newTaskId(), name: "", text: "" });
|
||||||
|
card.insertAdjacentElement("afterend", newCard);
|
||||||
|
updateTaskControls();
|
||||||
|
});
|
||||||
|
|
||||||
duplicateBtn.addEventListener("click", () => {
|
duplicateBtn.addEventListener("click", () => {
|
||||||
const copy = {
|
const copy = {
|
||||||
@@ -71,12 +129,17 @@ function buildTaskCard(task) {
|
|||||||
};
|
};
|
||||||
const newCard = buildTaskCard(copy);
|
const newCard = buildTaskCard(copy);
|
||||||
card.insertAdjacentElement("afterend", newCard);
|
card.insertAdjacentElement("afterend", newCard);
|
||||||
|
updateTaskControls();
|
||||||
});
|
});
|
||||||
|
|
||||||
deleteBtn.addEventListener("click", () => {
|
deleteBtn.addEventListener("click", () => {
|
||||||
card.remove();
|
card.remove();
|
||||||
|
updateTaskControls();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
actions.appendChild(moveUpBtn);
|
||||||
|
actions.appendChild(moveDownBtn);
|
||||||
|
actions.appendChild(addBelowBtn);
|
||||||
actions.appendChild(duplicateBtn);
|
actions.appendChild(duplicateBtn);
|
||||||
actions.appendChild(deleteBtn);
|
actions.appendChild(deleteBtn);
|
||||||
|
|
||||||
@@ -87,6 +150,16 @@ function buildTaskCard(task) {
|
|||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateTaskControls() {
|
||||||
|
const cards = [...tasksContainer.querySelectorAll(".task-card")];
|
||||||
|
cards.forEach((card, index) => {
|
||||||
|
const moveUpBtn = card.querySelector(".move-up");
|
||||||
|
const moveDownBtn = card.querySelector(".move-down");
|
||||||
|
if (moveUpBtn) moveUpBtn.disabled = index === 0;
|
||||||
|
if (moveDownBtn) moveDownBtn.disabled = index === cards.length - 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function collectTasks() {
|
function collectTasks() {
|
||||||
const cards = [...tasksContainer.querySelectorAll(".task-card")];
|
const cards = [...tasksContainer.querySelectorAll(".task-card")];
|
||||||
return cards.map((card) => {
|
return cards.map((card) => {
|
||||||
@@ -101,35 +174,65 @@ function collectTasks() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
const { apiKey = "", model = "", systemPrompt = "", resume = "", tasks = [] } =
|
const {
|
||||||
await getStorage(["apiKey", "model", "systemPrompt", "resume", "tasks"]);
|
apiKey = "",
|
||||||
|
apiBaseUrl = "",
|
||||||
|
apiKeyHeader = "",
|
||||||
|
apiKeyPrefix = "",
|
||||||
|
model = "",
|
||||||
|
systemPrompt = "",
|
||||||
|
resume = "",
|
||||||
|
tasks = [],
|
||||||
|
theme = "system"
|
||||||
|
} = await getStorage([
|
||||||
|
"apiKey",
|
||||||
|
"apiBaseUrl",
|
||||||
|
"apiKeyHeader",
|
||||||
|
"apiKeyPrefix",
|
||||||
|
"model",
|
||||||
|
"systemPrompt",
|
||||||
|
"resume",
|
||||||
|
"tasks",
|
||||||
|
"theme"
|
||||||
|
]);
|
||||||
|
|
||||||
apiKeyInput.value = apiKey;
|
apiKeyInput.value = apiKey;
|
||||||
|
apiBaseUrlInput.value = apiBaseUrl;
|
||||||
|
apiKeyHeaderInput.value = apiKeyHeader;
|
||||||
|
apiKeyPrefixInput.value = apiKeyPrefix;
|
||||||
modelInput.value = model;
|
modelInput.value = model;
|
||||||
systemPromptInput.value = systemPrompt;
|
systemPromptInput.value = systemPrompt;
|
||||||
resumeInput.value = resume;
|
resumeInput.value = resume;
|
||||||
|
themeSelect.value = theme;
|
||||||
|
applyTheme(theme);
|
||||||
|
|
||||||
tasksContainer.innerHTML = "";
|
tasksContainer.innerHTML = "";
|
||||||
if (!tasks.length) {
|
if (!tasks.length) {
|
||||||
tasksContainer.appendChild(
|
tasksContainer.appendChild(
|
||||||
buildTaskCard({ id: newTaskId(), name: "", text: "" })
|
buildTaskCard({ id: newTaskId(), name: "", text: "" })
|
||||||
);
|
);
|
||||||
|
updateTaskControls();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
tasksContainer.appendChild(buildTaskCard(task));
|
tasksContainer.appendChild(buildTaskCard(task));
|
||||||
}
|
}
|
||||||
|
updateTaskControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSettings() {
|
async function saveSettings() {
|
||||||
const tasks = collectTasks();
|
const tasks = collectTasks();
|
||||||
await chrome.storage.local.set({
|
await chrome.storage.local.set({
|
||||||
apiKey: apiKeyInput.value.trim(),
|
apiKey: apiKeyInput.value.trim(),
|
||||||
|
apiBaseUrl: apiBaseUrlInput.value.trim(),
|
||||||
|
apiKeyHeader: apiKeyHeaderInput.value.trim(),
|
||||||
|
apiKeyPrefix: apiKeyPrefixInput.value,
|
||||||
model: modelInput.value.trim(),
|
model: modelInput.value.trim(),
|
||||||
systemPrompt: systemPromptInput.value,
|
systemPrompt: systemPromptInput.value,
|
||||||
resume: resumeInput.value,
|
resume: resumeInput.value,
|
||||||
tasks
|
tasks,
|
||||||
|
theme: themeSelect.value
|
||||||
});
|
});
|
||||||
setStatus("Saved.");
|
setStatus("Saved.");
|
||||||
}
|
}
|
||||||
@@ -137,6 +240,7 @@ async function saveSettings() {
|
|||||||
saveBtn.addEventListener("click", () => void saveSettings());
|
saveBtn.addEventListener("click", () => void saveSettings());
|
||||||
addTaskBtn.addEventListener("click", () => {
|
addTaskBtn.addEventListener("click", () => {
|
||||||
tasksContainer.appendChild(buildTaskCard({ id: newTaskId(), name: "", text: "" }));
|
tasksContainer.appendChild(buildTaskCard({ id: newTaskId(), name: "", text: "" }));
|
||||||
|
updateTaskControls();
|
||||||
});
|
});
|
||||||
|
|
||||||
toggleKeyBtn.addEventListener("click", () => {
|
toggleKeyBtn.addEventListener("click", () => {
|
||||||
@@ -145,4 +249,17 @@ toggleKeyBtn.addEventListener("click", () => {
|
|||||||
toggleKeyBtn.textContent = isPassword ? "Hide" : "Show";
|
toggleKeyBtn.textContent = isPassword ? "Hide" : "Show";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
themeSelect.addEventListener("change", () => applyTheme(themeSelect.value));
|
||||||
|
resetApiBtn.addEventListener("click", async () => {
|
||||||
|
apiBaseUrlInput.value = OPENAI_DEFAULTS.apiBaseUrl;
|
||||||
|
apiKeyHeaderInput.value = OPENAI_DEFAULTS.apiKeyHeader;
|
||||||
|
apiKeyPrefixInput.value = OPENAI_DEFAULTS.apiKeyPrefix;
|
||||||
|
await chrome.storage.local.set({
|
||||||
|
apiBaseUrl: OPENAI_DEFAULTS.apiBaseUrl,
|
||||||
|
apiKeyHeader: OPENAI_DEFAULTS.apiKeyHeader,
|
||||||
|
apiKeyPrefix: OPENAI_DEFAULTS.apiKeyPrefix
|
||||||
|
});
|
||||||
|
setStatus("OpenAI defaults restored.");
|
||||||
|
});
|
||||||
|
|
||||||
loadSettings();
|
loadSettings();
|
||||||
Reference in New Issue
Block a user