moved extension files into subdirectory
This commit is contained in:
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. No comparisons between postings and 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user