Files
wwcompanion/background.js

191 lines
4.5 KiB
JavaScript

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,
theme: "system"
};
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);
}
}
}