initial commit: wwcompanion v1
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
189
background.js
Normal file
189
background.js
Normal file
@@ -0,0 +1,189 @@
|
||||
const DEFAULT_TASKS = [
|
||||
{
|
||||
id: "task-fit-summary",
|
||||
name: "Fit Summary",
|
||||
text:
|
||||
"Summarize the role, highlight key requirements, and assess my fit using the resume. Note any gaps and what to emphasize."
|
||||
}
|
||||
];
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
apiKey: "",
|
||||
model: "gpt-4o-mini",
|
||||
systemPrompt:
|
||||
"You are a precise, honest assistant. Be concise, highlight uncertainties, and avoid inventing details.",
|
||||
resume: "",
|
||||
tasks: DEFAULT_TASKS
|
||||
};
|
||||
|
||||
chrome.runtime.onInstalled.addListener(async () => {
|
||||
const stored = await chrome.storage.local.get(Object.keys(DEFAULT_SETTINGS));
|
||||
const updates = {};
|
||||
|
||||
for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) {
|
||||
const existing = stored[key];
|
||||
const missing =
|
||||
existing === undefined ||
|
||||
existing === null ||
|
||||
(key === "tasks" && !Array.isArray(existing));
|
||||
|
||||
if (missing) updates[key] = value;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length) {
|
||||
await chrome.storage.local.set(updates);
|
||||
}
|
||||
});
|
||||
|
||||
chrome.runtime.onConnect.addListener((port) => {
|
||||
if (port.name !== "analysis") return;
|
||||
|
||||
let abortController = null;
|
||||
|
||||
const resetAbort = () => {
|
||||
if (abortController) abortController.abort();
|
||||
abortController = null;
|
||||
};
|
||||
|
||||
port.onMessage.addListener((message) => {
|
||||
if (message?.type === "START_ANALYSIS") {
|
||||
resetAbort();
|
||||
abortController = new AbortController();
|
||||
void handleAnalysisRequest(port, message.payload, abortController.signal).catch(
|
||||
(error) => {
|
||||
if (error?.name === "AbortError") {
|
||||
port.postMessage({ type: "ABORTED" });
|
||||
return;
|
||||
}
|
||||
port.postMessage({
|
||||
type: "ERROR",
|
||||
message: error?.message || "Unknown error during analysis."
|
||||
});
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message?.type === "ABORT_ANALYSIS") {
|
||||
resetAbort();
|
||||
}
|
||||
});
|
||||
|
||||
port.onDisconnect.addListener(() => {
|
||||
resetAbort();
|
||||
});
|
||||
});
|
||||
|
||||
function buildUserMessage(resume, task, posting) {
|
||||
return [
|
||||
"=== RESUME ===",
|
||||
resume || "",
|
||||
"",
|
||||
"=== TASK ===",
|
||||
task || "",
|
||||
"",
|
||||
"=== JOB POSTING ===",
|
||||
posting || ""
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function handleAnalysisRequest(port, payload, signal) {
|
||||
const { apiKey, model, systemPrompt, resume, taskText, postingText } = payload || {};
|
||||
|
||||
if (!apiKey) {
|
||||
port.postMessage({ type: "ERROR", message: "Missing OpenAI API key." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
port.postMessage({ type: "ERROR", message: "Missing model name." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!postingText) {
|
||||
port.postMessage({ type: "ERROR", message: "No job posting text provided." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!taskText) {
|
||||
port.postMessage({ type: "ERROR", message: "No task prompt selected." });
|
||||
return;
|
||||
}
|
||||
|
||||
const userMessage = buildUserMessage(resume, taskText, postingText);
|
||||
|
||||
await streamChatCompletion({
|
||||
apiKey,
|
||||
model,
|
||||
systemPrompt: systemPrompt || "",
|
||||
userMessage,
|
||||
signal,
|
||||
onDelta: (text) => port.postMessage({ type: "DELTA", text })
|
||||
});
|
||||
|
||||
port.postMessage({ type: "DONE" });
|
||||
}
|
||||
|
||||
async function streamChatCompletion({
|
||||
apiKey,
|
||||
model,
|
||||
systemPrompt,
|
||||
userMessage,
|
||||
signal,
|
||||
onDelta
|
||||
}) {
|
||||
const response = await fetch("https://api.openai.com/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
stream: true,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userMessage }
|
||||
]
|
||||
}),
|
||||
signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`OpenAI API error ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
// OpenAI streams Server-Sent Events; parse incremental deltas from data lines.
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("data:")) continue;
|
||||
|
||||
const data = trimmed.slice(5).trim();
|
||||
if (!data) continue;
|
||||
if (data === "[DONE]") return;
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(data);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const delta = parsed?.choices?.[0]?.delta?.content;
|
||||
if (delta) onDelta(delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
48
content.js
Normal file
48
content.js
Normal file
@@ -0,0 +1,48 @@
|
||||
const HEADER_LINES = new Set([
|
||||
"OVERVIEW",
|
||||
"PRE-SCREENING",
|
||||
"WORK TERM RATINGS",
|
||||
"JOB POSTING INFORMATION",
|
||||
"APPLICATION INFORMATION",
|
||||
"COMPANY INFORMATION",
|
||||
"SERVICE TEAM"
|
||||
]);
|
||||
|
||||
function sanitizePostingText(text) {
|
||||
let cleaned = text.replaceAll("fiber_manual_record", "");
|
||||
const lines = cleaned.split(/\r?\n/);
|
||||
const filtered = lines.filter((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return true;
|
||||
return !HEADER_LINES.has(trimmed.toUpperCase());
|
||||
});
|
||||
|
||||
cleaned = filtered.join("\n");
|
||||
cleaned = cleaned.replace(/[ \t]+/g, " ");
|
||||
cleaned = cleaned.replace(/\n{3,}/g, "\n\n");
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
function extractPostingText() {
|
||||
const contents = [...document.getElementsByClassName("modal__content")];
|
||||
if (!contents.length) {
|
||||
return { ok: false, error: "No modal content found on this page." };
|
||||
}
|
||||
|
||||
// WaterlooWorks renders multiple modal containers; choose the longest visible text block.
|
||||
const el = contents.reduce((best, cur) =>
|
||||
cur.innerText.length > best.innerText.length ? cur : best
|
||||
);
|
||||
|
||||
const rawText = el.innerText;
|
||||
const sanitized = sanitizePostingText(rawText);
|
||||
|
||||
return { ok: true, rawText, sanitized };
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
if (message?.type !== "EXTRACT_POSTING") return;
|
||||
|
||||
const result = extractPostingText();
|
||||
sendResponse(result);
|
||||
});
|
||||
26
manifest.json
Normal file
26
manifest.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "WWCompanion",
|
||||
"version": "0.1.0",
|
||||
"description": "Manual reasoning companion for WaterlooWorks job postings.",
|
||||
"permissions": ["storage", "activeTab"],
|
||||
"host_permissions": ["https://waterlooworks.uwaterloo.ca/*"],
|
||||
"action": {
|
||||
"default_title": "WWCompanion",
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background.js",
|
||||
"type": "module"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["https://waterlooworks.uwaterloo.ca/*"],
|
||||
"js": ["content.js"]
|
||||
}
|
||||
],
|
||||
"options_ui": {
|
||||
"page": "settings.html",
|
||||
"open_in_tab": true
|
||||
}
|
||||
}
|
||||
161
popup.css
Normal file
161
popup.css
Normal file
@@ -0,0 +1,161 @@
|
||||
:root {
|
||||
--ink: #1f1a17;
|
||||
--muted: #6b5f55;
|
||||
--accent: #b14d2b;
|
||||
--accent-deep: #7d321b;
|
||||
--panel: #fff7ec;
|
||||
--border: #e4d6c5;
|
||||
--glow: rgba(177, 77, 43, 0.18);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
width: 360px;
|
||||
font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Palatino,
|
||||
"Times New Roman", serif;
|
||||
color: var(--ink);
|
||||
background: radial-gradient(circle at top, #fdf2df, #f7ead6 60%, #f1dcc6 100%);
|
||||
}
|
||||
|
||||
.title-block {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 12px 30px rgba(122, 80, 47, 0.12);
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-top: 10px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: #fffdf9;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.primary {
|
||||
width: 100%;
|
||||
background: #fffdf9;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 6px 16px rgba(120, 85, 55, 0.12);
|
||||
}
|
||||
|
||||
.accent {
|
||||
background: var(--accent);
|
||||
color: #fff9f3;
|
||||
box-shadow: 0 8px 20px var(--glow);
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.output {
|
||||
margin-top: 12px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
min-height: 160px;
|
||||
max-height: 240px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.link {
|
||||
padding: 4px 6px;
|
||||
background: none;
|
||||
border-radius: 8px;
|
||||
color: var(--accent-deep);
|
||||
font-size: 12px;
|
||||
}
|
||||
42
popup.html
Normal file
42
popup.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<!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
Normal file
223
popup.js
Normal file
@@ -0,0 +1,223 @@
|
||||
const extractBtn = document.getElementById("extractBtn");
|
||||
const analyzeBtn = document.getElementById("analyzeBtn");
|
||||
const abortBtn = document.getElementById("abortBtn");
|
||||
const taskSelect = document.getElementById("taskSelect");
|
||||
const outputEl = document.getElementById("output");
|
||||
const statusEl = document.getElementById("status");
|
||||
const postingCountEl = document.getElementById("postingCount");
|
||||
const promptCountEl = document.getElementById("promptCount");
|
||||
const settingsBtn = document.getElementById("settingsBtn");
|
||||
|
||||
const state = {
|
||||
postingText: "",
|
||||
tasks: [],
|
||||
port: null,
|
||||
isAnalyzing: false
|
||||
};
|
||||
|
||||
function getStorage(keys) {
|
||||
return new Promise((resolve) => chrome.storage.local.get(keys, resolve));
|
||||
}
|
||||
|
||||
function buildUserMessage(resume, task, posting) {
|
||||
return [
|
||||
"=== RESUME ===",
|
||||
resume || "",
|
||||
"",
|
||||
"=== TASK ===",
|
||||
task || "",
|
||||
"",
|
||||
"=== JOB POSTING ===",
|
||||
posting || ""
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function setStatus(message) {
|
||||
statusEl.textContent = message;
|
||||
}
|
||||
|
||||
function setAnalyzing(isAnalyzing) {
|
||||
state.isAnalyzing = isAnalyzing;
|
||||
analyzeBtn.disabled = isAnalyzing;
|
||||
abortBtn.disabled = !isAnalyzing;
|
||||
extractBtn.disabled = isAnalyzing;
|
||||
}
|
||||
|
||||
function updatePostingCount() {
|
||||
postingCountEl.textContent = `Posting: ${state.postingText.length} chars`;
|
||||
}
|
||||
|
||||
function updatePromptCount(count) {
|
||||
promptCountEl.textContent = `Prompt: ${count} chars`;
|
||||
}
|
||||
|
||||
function renderTasks(tasks) {
|
||||
state.tasks = tasks;
|
||||
taskSelect.innerHTML = "";
|
||||
|
||||
if (!tasks.length) {
|
||||
const option = document.createElement("option");
|
||||
option.textContent = "No tasks configured";
|
||||
option.value = "";
|
||||
taskSelect.appendChild(option);
|
||||
taskSelect.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
taskSelect.disabled = false;
|
||||
for (const task of tasks) {
|
||||
const option = document.createElement("option");
|
||||
option.value = task.id;
|
||||
option.textContent = task.name || "Untitled task";
|
||||
taskSelect.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
function sendToActiveTab(message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
const tab = tabs[0];
|
||||
if (!tab?.id) {
|
||||
reject(new Error("No active tab found."));
|
||||
return;
|
||||
}
|
||||
|
||||
chrome.tabs.sendMessage(tab.id, message, (response) => {
|
||||
const error = chrome.runtime.lastError;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ensurePort() {
|
||||
if (state.port) return state.port;
|
||||
|
||||
const port = chrome.runtime.connect({ name: "analysis" });
|
||||
port.onMessage.addListener((message) => {
|
||||
if (message?.type === "DELTA") {
|
||||
outputEl.textContent += message.text;
|
||||
outputEl.scrollTop = outputEl.scrollHeight;
|
||||
return;
|
||||
}
|
||||
|
||||
if (message?.type === "DONE") {
|
||||
setAnalyzing(false);
|
||||
setStatus("Done");
|
||||
return;
|
||||
}
|
||||
|
||||
if (message?.type === "ABORTED") {
|
||||
setAnalyzing(false);
|
||||
setStatus("Aborted.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (message?.type === "ERROR") {
|
||||
setAnalyzing(false);
|
||||
setStatus(message.message || "Error during analysis.");
|
||||
}
|
||||
});
|
||||
|
||||
port.onDisconnect.addListener(() => {
|
||||
state.port = null;
|
||||
setAnalyzing(false);
|
||||
});
|
||||
|
||||
state.port = port;
|
||||
return port;
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
const { tasks = [] } = await getStorage(["tasks"]);
|
||||
renderTasks(tasks);
|
||||
}
|
||||
|
||||
async function handleExtract() {
|
||||
setStatus("Extracting...");
|
||||
try {
|
||||
const response = await sendToActiveTab({ type: "EXTRACT_POSTING" });
|
||||
if (!response?.ok) {
|
||||
setStatus(response?.error || "No posting detected.");
|
||||
return;
|
||||
}
|
||||
|
||||
state.postingText = response.sanitized || "";
|
||||
updatePostingCount();
|
||||
updatePromptCount(0);
|
||||
setStatus("Posting extracted.");
|
||||
} catch (error) {
|
||||
setStatus(error.message || "Unable to extract posting.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAnalyze() {
|
||||
if (!state.postingText) {
|
||||
setStatus("Extract a job posting first.");
|
||||
return;
|
||||
}
|
||||
|
||||
const taskId = taskSelect.value;
|
||||
const task = state.tasks.find((item) => item.id === taskId);
|
||||
if (!task) {
|
||||
setStatus("Select a task prompt.");
|
||||
return;
|
||||
}
|
||||
|
||||
const { apiKey, model, systemPrompt, resume } = await getStorage([
|
||||
"apiKey",
|
||||
"model",
|
||||
"systemPrompt",
|
||||
"resume"
|
||||
]);
|
||||
|
||||
if (!apiKey) {
|
||||
setStatus("Add your OpenAI API key in Settings.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
setStatus("Set a model name in Settings.");
|
||||
return;
|
||||
}
|
||||
|
||||
const promptText = buildUserMessage(resume || "", task.text || "", state.postingText);
|
||||
updatePromptCount(promptText.length);
|
||||
|
||||
outputEl.textContent = "";
|
||||
setAnalyzing(true);
|
||||
setStatus("Analyzing...");
|
||||
|
||||
const port = ensurePort();
|
||||
port.postMessage({
|
||||
type: "START_ANALYSIS",
|
||||
payload: {
|
||||
apiKey,
|
||||
model,
|
||||
systemPrompt: systemPrompt || "",
|
||||
resume: resume || "",
|
||||
taskText: task.text || "",
|
||||
postingText: state.postingText
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleAbort() {
|
||||
if (!state.port) return;
|
||||
state.port.postMessage({ type: "ABORT_ANALYSIS" });
|
||||
setAnalyzing(false);
|
||||
setStatus("Aborted.");
|
||||
}
|
||||
|
||||
extractBtn.addEventListener("click", handleExtract);
|
||||
analyzeBtn.addEventListener("click", handleAnalyze);
|
||||
abortBtn.addEventListener("click", handleAbort);
|
||||
settingsBtn.addEventListener("click", () => chrome.runtime.openOptionsPage());
|
||||
|
||||
updatePostingCount();
|
||||
updatePromptCount(0);
|
||||
loadTasks();
|
||||
143
settings.css
Normal file
143
settings.css
Normal file
@@ -0,0 +1,143 @@
|
||||
:root {
|
||||
--ink: #221b15;
|
||||
--muted: #6b5f55;
|
||||
--accent: #b14d2b;
|
||||
--panel: #fffaf1;
|
||||
--border: #eadbc8;
|
||||
--bg: #f5ead7;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Palatino,
|
||||
"Times New Roman", serif;
|
||||
color: var(--ink);
|
||||
background: linear-gradient(160deg, #f8efe1, #efe0c9);
|
||||
}
|
||||
|
||||
.title-block {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 18px 40px rgba(120, 85, 55, 0.12);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: #fffdf9;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.inline {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.accent {
|
||||
background: var(--accent);
|
||||
color: #fff9f3;
|
||||
box-shadow: 0 8px 20px rgba(177, 77, 43, 0.2);
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tasks {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: #fffefb;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
54
settings.html
Normal file
54
settings.html
Normal file
@@ -0,0 +1,54 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>WWCompanion Settings</title>
|
||||
<link rel="stylesheet" href="settings.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="title-block">
|
||||
<div class="title">WWCompanion Settings</div>
|
||||
<div class="subtitle">Configure prompts, resume, and API access</div>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="row">
|
||||
<h2>OpenAI</h2>
|
||||
<button id="saveBtn" class="accent">Save Settings</button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="apiKey">API Key</label>
|
||||
<div class="inline">
|
||||
<input id="apiKey" type="password" autocomplete="off" placeholder="sk-..." />
|
||||
<button id="toggleKey" class="ghost" type="button">Show</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="model">Model name</label>
|
||||
<input id="model" type="text" placeholder="gpt-4o-mini" />
|
||||
</div>
|
||||
<div id="status" class="status"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>System Prompt</h2>
|
||||
<textarea id="systemPrompt" rows="8" placeholder="Define tone and standards..."></textarea>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Resume</h2>
|
||||
<textarea id="resume" rows="10" placeholder="Paste your resume text..."></textarea>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="row">
|
||||
<h2>Task Presets</h2>
|
||||
<button id="addTaskBtn" class="ghost" type="button">Add Task</button>
|
||||
</div>
|
||||
<div id="tasks" class="tasks"></div>
|
||||
</section>
|
||||
|
||||
<script src="settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
148
settings.js
Normal file
148
settings.js
Normal file
@@ -0,0 +1,148 @@
|
||||
const apiKeyInput = document.getElementById("apiKey");
|
||||
const modelInput = document.getElementById("model");
|
||||
const systemPromptInput = document.getElementById("systemPrompt");
|
||||
const resumeInput = document.getElementById("resume");
|
||||
const saveBtn = document.getElementById("saveBtn");
|
||||
const addTaskBtn = document.getElementById("addTaskBtn");
|
||||
const tasksContainer = document.getElementById("tasks");
|
||||
const statusEl = document.getElementById("status");
|
||||
const toggleKeyBtn = document.getElementById("toggleKey");
|
||||
|
||||
function getStorage(keys) {
|
||||
return new Promise((resolve) => chrome.storage.local.get(keys, resolve));
|
||||
}
|
||||
|
||||
function setStatus(message) {
|
||||
statusEl.textContent = message;
|
||||
if (!message) return;
|
||||
setTimeout(() => {
|
||||
if (statusEl.textContent === message) statusEl.textContent = "";
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function newTaskId() {
|
||||
if (crypto?.randomUUID) return crypto.randomUUID();
|
||||
return `task-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function buildTaskCard(task) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "task-card";
|
||||
card.dataset.id = task.id || newTaskId();
|
||||
|
||||
const nameField = document.createElement("div");
|
||||
nameField.className = "field";
|
||||
const nameLabel = document.createElement("label");
|
||||
nameLabel.textContent = "Name";
|
||||
const nameInput = document.createElement("input");
|
||||
nameInput.type = "text";
|
||||
nameInput.value = task.name || "";
|
||||
nameInput.className = "task-name";
|
||||
nameField.appendChild(nameLabel);
|
||||
nameField.appendChild(nameInput);
|
||||
|
||||
const textField = document.createElement("div");
|
||||
textField.className = "field";
|
||||
const textLabel = document.createElement("label");
|
||||
textLabel.textContent = "Task prompt";
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.rows = 6;
|
||||
textArea.value = task.text || "";
|
||||
textArea.className = "task-text";
|
||||
textField.appendChild(textLabel);
|
||||
textField.appendChild(textArea);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "task-actions";
|
||||
const duplicateBtn = document.createElement("button");
|
||||
duplicateBtn.type = "button";
|
||||
duplicateBtn.className = "ghost";
|
||||
duplicateBtn.textContent = "Duplicate";
|
||||
const deleteBtn = document.createElement("button");
|
||||
deleteBtn.type = "button";
|
||||
deleteBtn.className = "ghost";
|
||||
deleteBtn.textContent = "Delete";
|
||||
|
||||
duplicateBtn.addEventListener("click", () => {
|
||||
const copy = {
|
||||
id: newTaskId(),
|
||||
name: `${nameInput.value || "Untitled"} Copy`,
|
||||
text: textArea.value
|
||||
};
|
||||
const newCard = buildTaskCard(copy);
|
||||
card.insertAdjacentElement("afterend", newCard);
|
||||
});
|
||||
|
||||
deleteBtn.addEventListener("click", () => {
|
||||
card.remove();
|
||||
});
|
||||
|
||||
actions.appendChild(duplicateBtn);
|
||||
actions.appendChild(deleteBtn);
|
||||
|
||||
card.appendChild(nameField);
|
||||
card.appendChild(textField);
|
||||
card.appendChild(actions);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function collectTasks() {
|
||||
const cards = [...tasksContainer.querySelectorAll(".task-card")];
|
||||
return cards.map((card) => {
|
||||
const nameInput = card.querySelector(".task-name");
|
||||
const textArea = card.querySelector(".task-text");
|
||||
return {
|
||||
id: card.dataset.id || newTaskId(),
|
||||
name: (nameInput?.value || "Untitled Task").trim(),
|
||||
text: (textArea?.value || "").trim()
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const { apiKey = "", model = "", systemPrompt = "", resume = "", tasks = [] } =
|
||||
await getStorage(["apiKey", "model", "systemPrompt", "resume", "tasks"]);
|
||||
|
||||
apiKeyInput.value = apiKey;
|
||||
modelInput.value = model;
|
||||
systemPromptInput.value = systemPrompt;
|
||||
resumeInput.value = resume;
|
||||
|
||||
tasksContainer.innerHTML = "";
|
||||
if (!tasks.length) {
|
||||
tasksContainer.appendChild(
|
||||
buildTaskCard({ id: newTaskId(), name: "", text: "" })
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const task of tasks) {
|
||||
tasksContainer.appendChild(buildTaskCard(task));
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const tasks = collectTasks();
|
||||
await chrome.storage.local.set({
|
||||
apiKey: apiKeyInput.value.trim(),
|
||||
model: modelInput.value.trim(),
|
||||
systemPrompt: systemPromptInput.value,
|
||||
resume: resumeInput.value,
|
||||
tasks
|
||||
});
|
||||
setStatus("Saved.");
|
||||
}
|
||||
|
||||
saveBtn.addEventListener("click", () => void saveSettings());
|
||||
addTaskBtn.addEventListener("click", () => {
|
||||
tasksContainer.appendChild(buildTaskCard({ id: newTaskId(), name: "", text: "" }));
|
||||
});
|
||||
|
||||
toggleKeyBtn.addEventListener("click", () => {
|
||||
const isPassword = apiKeyInput.type === "password";
|
||||
apiKeyInput.type = isPassword ? "text" : "password";
|
||||
toggleKeyBtn.textContent = isPassword ? "Hide" : "Show";
|
||||
});
|
||||
|
||||
loadSettings();
|
||||
Reference in New Issue
Block a user