Spaces:
Running
Running
feat(phase-10): V2 natural sentence resume mode β Kimi LLM generates sentences, same V1 waterfall
f057ca2 | // background.js β ATS Resume Generator service worker | |
| // No ATS or model-inference logic here. Only: read storage, call API, trigger download. | |
| // R20: bundled default resume (self.DEFAULT_RESUME_LATEX) so a brand-new user can | |
| // Run before configuring anything. importScripts works in a classic MV3 worker. | |
| try { importScripts('default_resume.js'); } catch (_) { /* default optional */ } | |
| function safeRespond(sendResponse, data) { | |
| try { sendResponse(data); } catch (_) { /* popup closed before reply */ } | |
| } | |
| // βββ Storage source-of-truth (resilient Run; survives popup close + worker suspend) ββ | |
| // The background service worker OWNS generation. It persists the run lifecycle to | |
| // chrome.storage.local keyed by the normalized job URL, so a reopened popup (or a | |
| // recycled MV3 worker) can always surface the running marker or the final result. | |
| // Mirrors the popup's `ats_results` map + `normalizeUrl` contract exactly. | |
| const RESULTS_KEY = 'ats_results'; // map: { [urlKey]: {status, result, job_title, company, startedAt, savedAt} } | |
| const MAX_SAVED = 40; // retained history entries (R19); quota-guarded below | |
| const BYTE_BUDGET = 4500000; // ~4.5MB headroom under the ~5MB local quota | |
| function normalizeUrl(u) { | |
| // Drop the #fragment so an in-page refresh still matches; keep the query. | |
| return (u || '').split('#')[0]; | |
| } | |
| // Approximate serialized size of the results map (bytes ~= JSON length). | |
| function approxSize(map) { | |
| try { return JSON.stringify(map).length; } catch (_) { return 0; } | |
| } | |
| // Strip base64 artifacts from the OLDEST entries (keeping the newest `keepNewest` | |
| // full) so the history row still renders (metadata + scores) but the heavy | |
| // docx/pdf/tex payloads are dropped. Flags pruned entries as metadata-only. | |
| function stripOldestArtifacts(map, keepNewest) { | |
| const ks = Object.keys(map).sort((a, b) => (map[a].savedAt || 0) - (map[b].savedAt || 0)); | |
| for (const k of ks.slice(0, Math.max(0, ks.length - keepNewest))) { | |
| const r = map[k] && map[k].result; | |
| if (r && (r.docx_b64 || r.pdf_b64 || r.tex_b64)) { | |
| delete r.docx_b64; delete r.pdf_b64; delete r.tex_b64; | |
| r.artifacts_pruned = true; | |
| } | |
| } | |
| } | |
| async function resolveActiveTabUrl() { | |
| try { | |
| const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); | |
| return tab ? tab.url : ''; | |
| } catch (_) { | |
| return ''; | |
| } | |
| } | |
| function normalizeText(s) { | |
| return String(s || '') | |
| .toLowerCase() | |
| .replace(/[_-]+/g, ' ') | |
| .replace(/[^a-z0-9\s]/g, ' ') | |
| .replace(/\s+/g, ' ') | |
| .trim(); | |
| } | |
| function parseProfileJson(raw) { | |
| if (!raw || !String(raw).trim()) return {}; | |
| try { | |
| const data = JSON.parse(raw); | |
| return data && typeof data === 'object' ? data : {}; | |
| } catch (_) { | |
| return {}; | |
| } | |
| } | |
| function profileIndex(profile) { | |
| const out = { ...(profile || {}) }; | |
| const full = String(out.full_name || '').trim(); | |
| if (full) { | |
| const parts = full.split(/\s+/); | |
| if (!out.first_name && parts.length) out.first_name = parts[0]; | |
| if (!out.last_name && parts.length > 1) out.last_name = parts.slice(1).join(' '); | |
| } | |
| return out; | |
| } | |
| function valueOf(profile, key) { | |
| const v = profile ? profile[key] : ''; | |
| return typeof v === 'string' || typeof v === 'number' ? String(v).trim() : ''; | |
| } | |
| function fieldText(field) { | |
| return normalizeText([ | |
| field.label, field.name, field.placeholder, field.help_text, field.type, | |
| ].filter(Boolean).join(' ')); | |
| } | |
| function isLongAnswerField(field) { | |
| return field.tag === 'textarea' | |
| || field.type === 'textarea' | |
| || (field.type === 'text' && /cover letter|why|tell us|describe|summary|experience|motivation|fit/.test(fieldText(field))); | |
| } | |
| function localAutofillValue(field, profile) { | |
| const text = fieldText(field); | |
| const fullName = valueOf(profile, 'full_name'); | |
| const firstName = valueOf(profile, 'first_name'); | |
| const lastName = valueOf(profile, 'last_name'); | |
| if (!text) return ''; | |
| if (/first name|given name/.test(text)) return firstName; | |
| if (/last name|family name|surname/.test(text)) return lastName; | |
| if (/full name|your name|applicant name|candidate name/.test(text)) return fullName; | |
| if (/email|e mail/.test(text)) return valueOf(profile, 'email'); | |
| if (/phone|mobile|contact number|telephone/.test(text)) return valueOf(profile, 'phone'); | |
| if (/linkedin/.test(text)) return valueOf(profile, 'linkedin_url'); | |
| if (/github/.test(text)) return valueOf(profile, 'github_url'); | |
| if (/portfolio|website|personal site/.test(text)) return valueOf(profile, 'portfolio_url'); | |
| if (/current company|current employer|employer|organization/.test(text)) return valueOf(profile, 'current_company'); | |
| if (/current title|job title|designation|headline/.test(text)) return valueOf(profile, 'current_title'); | |
| if (/location|current city|city state|address/.test(text)) return valueOf(profile, 'location'); | |
| if (/years of experience|total experience|experience in years|how many years/.test(text)) { | |
| return valueOf(profile, 'years_experience'); | |
| } | |
| if (/notice period|joining period|available to join|when can you start|start date/.test(text)) { | |
| return valueOf(profile, 'notice_period'); | |
| } | |
| if (/authorized to work|work authorization/.test(text)) return valueOf(profile, 'work_authorization'); | |
| if (/require sponsorship|need sponsorship|visa sponsorship/.test(text)) { | |
| return valueOf(profile, 'requires_sponsorship'); | |
| } | |
| if (/visa status/.test(text)) return valueOf(profile, 'visa_status'); | |
| if (/current salary|current ctc/.test(text)) return valueOf(profile, 'current_salary'); | |
| if (/expected salary|salary expectation|expected ctc|compensation expectation/.test(text)) { | |
| return valueOf(profile, 'expected_salary'); | |
| } | |
| if (/notice|relocation|work mode|remote|hybrid/.test(text)) return valueOf(profile, 'additional_notes'); | |
| return ''; | |
| } | |
| function pickFieldsForApi(fields, localAnswers) { | |
| const answered = new Set((localAnswers || []).map((a) => a.id)); | |
| return (fields || []).filter((field) => { | |
| if (!field || !field.id || answered.has(field.id)) return false; | |
| const text = fieldText(field); | |
| if (!text) return false; | |
| if (/search|filter|sort/.test(text)) return false; | |
| return isLongAnswerField(field) | |
| || (field.tag === 'select' || field.type === 'radio') | |
| || /why|motivation|about you|introduce yourself|cover letter|summary|fit|salary|authorization|sponsorship|relocate/.test(text); | |
| }); | |
| } | |
| async function runFormAssist({ data, resumeLatex, profile, fields, jd_text, job_title, company }) { | |
| const formData = new FormData(); | |
| formData.append('resume_latex', resumeLatex || ''); | |
| formData.append('profile_json', JSON.stringify(profile || {})); | |
| formData.append('fields_json', JSON.stringify(fields || [])); | |
| formData.append('jd_text', jd_text || ''); | |
| formData.append('job_title', job_title || ''); | |
| formData.append('company', company || ''); | |
| let resp; | |
| try { | |
| resp = await fetch(`${data.api_url.replace(/\/$/, '')}/api/form-assist`, { | |
| method: 'POST', | |
| headers: { 'X-Api-Token': data.api_token }, | |
| body: formData, | |
| }); | |
| } catch (networkErr) { | |
| return { error: 'network_error', detail: `Cannot reach API: ${networkErr.message}` }; | |
| } | |
| let result; | |
| try { | |
| result = await resp.json(); | |
| } catch { | |
| return { error: 'parse_error', detail: `API returned non-JSON (status ${resp.status})` }; | |
| } | |
| if (!resp.ok) { | |
| return { error: result.error || 'api_error', detail: result.detail || `HTTP ${resp.status}` }; | |
| } | |
| return result; | |
| } | |
| async function handleAutofillForm({ fields, jd_text, job_title, company }) { | |
| const data = await new Promise(res => chrome.storage.local.get( | |
| ['api_url', 'api_token', 'resume_latex', 'autofill_profile_json'], res | |
| )); | |
| let resumeLatex = data.resume_latex; | |
| if ((!resumeLatex || !resumeLatex.trim()) && self.DEFAULT_RESUME_LATEX) { | |
| resumeLatex = self.DEFAULT_RESUME_LATEX; | |
| } | |
| const profile = profileIndex(parseProfileJson(data.autofill_profile_json)); | |
| const localAnswers = []; | |
| for (const field of (fields || [])) { | |
| const value = localAutofillValue(field, profile); | |
| if (value) { | |
| localAnswers.push({ | |
| id: field.id, | |
| value, | |
| confidence: 'high', | |
| source: 'profile', | |
| }); | |
| } | |
| } | |
| const needsApi = pickFieldsForApi(fields, localAnswers); | |
| let apiAnswers = []; | |
| let warning = ''; | |
| if (needsApi.length) { | |
| if (!data.api_url || !data.api_token) { | |
| warning = 'API not configured. Filled only the common profile fields.'; | |
| } else if (!resumeLatex || !resumeLatex.trim()) { | |
| warning = 'Resume LaTeX is not configured. Filled only the common profile fields.'; | |
| } else { | |
| const apiResult = await runFormAssist({ | |
| data, resumeLatex, profile, fields: needsApi, jd_text, job_title, company, | |
| }); | |
| if (apiResult && apiResult.error) { | |
| warning = apiResult.detail || apiResult.error; | |
| } else { | |
| apiAnswers = Array.isArray(apiResult?.answers) ? apiResult.answers : []; | |
| } | |
| } | |
| } | |
| const merged = new Map(); | |
| for (const answer of [...apiAnswers, ...localAnswers]) { | |
| if (!answer || !answer.id || !String(answer.value || '').trim()) continue; | |
| merged.set(answer.id, answer); | |
| } | |
| return { | |
| answers: Array.from(merged.values()), | |
| local_count: localAnswers.length, | |
| api_count: apiAnswers.filter((a) => String(a?.value || '').trim()).length, | |
| field_count: (fields || []).length, | |
| warning, | |
| }; | |
| } | |
| // Shallow-merge a patch into the entry for `urlKey`, preserving existing fields, | |
| // stamping savedAt, and pruning to the most-recent MAX_SAVED entries. | |
| async function writeEntry(urlKey, patch) { | |
| if (!urlKey) return; | |
| try { | |
| const store = await new Promise(res => chrome.storage.local.get([RESULTS_KEY], res)); | |
| const map = store[RESULTS_KEY] || {}; | |
| map[urlKey] = { ...(map[urlKey] || {}), ...patch, savedAt: Date.now() }; | |
| // 1. Prune to the MAX_SAVED most-recent entries by recency. | |
| let ks = Object.keys(map); | |
| if (ks.length > MAX_SAVED) { | |
| ks.sort((a, b) => (map[a].savedAt || 0) - (map[b].savedAt || 0)); | |
| for (const k of ks.slice(0, ks.length - MAX_SAVED)) delete map[k]; | |
| } | |
| // 2. Byte-budget guard: progressively strip artifacts from the oldest | |
| // entries (keeping fewer full each pass), then drop whole oldest entries, | |
| // until under budget. The just-written (newest) entry stays full. | |
| let keep = MAX_SAVED; | |
| while (approxSize(map) > BYTE_BUDGET && keep > 1) { | |
| keep = Math.max(1, Math.floor(keep / 2)); | |
| stripOldestArtifacts(map, keep); | |
| } | |
| while (approxSize(map) > BYTE_BUDGET && Object.keys(map).length > 1) { | |
| const oldest = Object.keys(map) | |
| .sort((a, b) => (map[a].savedAt || 0) - (map[b].savedAt || 0))[0]; | |
| delete map[oldest]; | |
| } | |
| // 3. Write; on a QUOTA_BYTES error, degrade gracefully (strip oldest | |
| // artifacts, else drop oldest entry) and retry β never lose the newest. | |
| let attempts = 0; | |
| while (attempts++ < MAX_SAVED + 2) { | |
| try { | |
| await new Promise((res, rej) => chrome.storage.local.set({ [RESULTS_KEY]: map }, () => { | |
| const e = chrome.runtime.lastError; | |
| if (e) rej(new Error(e.message)); else res(); | |
| })); | |
| return; // success | |
| } catch (_) { | |
| const ksOld = Object.keys(map) | |
| .sort((a, b) => (map[a].savedAt || 0) - (map[b].savedAt || 0)); | |
| if (ksOld.length <= 1) return; // can't reduce further without losing newest | |
| const oldest = ksOld[0]; | |
| const r = map[oldest] && map[oldest].result; | |
| if (r && (r.docx_b64 || r.pdf_b64 || r.tex_b64)) { | |
| delete r.docx_b64; delete r.pdf_b64; delete r.tex_b64; r.artifacts_pruned = true; | |
| } else { | |
| delete map[oldest]; | |
| } | |
| } | |
| } | |
| } catch (_) { /* quota or transient storage error β non-fatal */ } | |
| } | |
| chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { | |
| if (msg.type === 'GENERATE') { | |
| handleGenerate(msg) | |
| .then((data) => safeRespond(sendResponse, data)) | |
| .catch((err) => safeRespond(sendResponse, { error: 'runtime_error', detail: err.message })); | |
| return true; | |
| } | |
| if (msg.type === 'REPAIR') { | |
| handleRepair(msg) | |
| .then((data) => safeRespond(sendResponse, data)) | |
| .catch((err) => safeRespond(sendResponse, { error: 'runtime_error', detail: err.message })); | |
| return true; | |
| } | |
| if (msg.type === 'DOWNLOAD') { | |
| handleDownload(msg).catch(console.error); | |
| return false; | |
| } | |
| if (msg.type === 'AUTOFILL_FORM') { | |
| handleAutofillForm(msg) | |
| .then((data) => safeRespond(sendResponse, data)) | |
| .catch((err) => safeRespond(sendResponse, { error: 'runtime_error', detail: err.message })); | |
| return true; | |
| } | |
| }); | |
| // βββ REPAIR (External ATS Feedback Repair Mode) βββββββββββββββββββββββββββββββ | |
| async function handleRepair({ jd_text, job_title, company, feedback, maximum_ats_mode, confirmed_terms, url, version }) { | |
| const data = await new Promise(res => chrome.storage.local.get( | |
| ['resume_b64', 'resume_latex', 'api_url', 'api_token'], res | |
| )); | |
| if (!data.api_url || !data.api_token) { | |
| return { error: 'not_configured', detail: 'Please set API URL and token in Options.' }; | |
| } | |
| // R20: fall back to the bundled default resume when nothing is stored. | |
| let resumeLatex = data.resume_latex; | |
| if ((!resumeLatex || !resumeLatex.trim()) && !data.resume_b64 && self.DEFAULT_RESUME_LATEX) { | |
| resumeLatex = self.DEFAULT_RESUME_LATEX; | |
| } | |
| const hasLatex = !!(resumeLatex && resumeLatex.trim()); | |
| if (!hasLatex && !data.resume_b64) { | |
| return { error: 'no_resume', detail: 'Please paste your resume LaTeX or upload a PDF in Options first.' }; | |
| } | |
| const formData = new FormData(); | |
| formData.append('jd_text', jd_text); | |
| formData.append('job_title', job_title || ''); | |
| formData.append('company', company || ''); | |
| formData.append('feedback', feedback || ''); | |
| formData.append('maximum_ats_mode', maximum_ats_mode ? '1' : '0'); | |
| if (confirmed_terms) formData.append('confirmed_terms', confirmed_terms); | |
| formData.append('version', version || 'v1'); | |
| // LaTeX takes priority; only attach the PDF when no LaTeX is saved. | |
| if (hasLatex) { | |
| formData.append('resume_latex', resumeLatex); | |
| } else { | |
| const binaryStr = atob(data.resume_b64); | |
| const bytes = new Uint8Array(binaryStr.length); | |
| for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i); | |
| const resumeBlob = new Blob([bytes], { type: 'application/pdf' }); | |
| formData.append('resume', resumeBlob, 'resume.pdf'); | |
| } | |
| let resp; | |
| try { | |
| resp = await fetch(`${data.api_url.replace(/\/$/, '')}/api/repair-with-feedback`, { | |
| method: 'POST', | |
| headers: { 'X-Api-Token': data.api_token }, | |
| body: formData, | |
| }); | |
| } catch (networkErr) { | |
| return { error: 'network_error', detail: `Cannot reach API: ${networkErr.message}` }; | |
| } | |
| let result; | |
| try { | |
| result = await resp.json(); | |
| } catch { | |
| return { error: 'parse_error', detail: `API returned non-JSON (status ${resp.status})` }; | |
| } | |
| if (!resp.ok) { | |
| return { error: result.error || 'api_error', detail: result.detail || `HTTP ${resp.status}` }; | |
| } | |
| // An improved result is also persisted so reopening surfaces it (source of truth). | |
| const urlKey = normalizeUrl(url || await resolveActiveTabUrl()); | |
| await writeEntry(urlKey, { status: 'done', result, job_title: job_title || '', company: company || '' }); | |
| return result; | |
| } | |
| // βββ GENERATE ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function handleGenerate({ jd_text, job_title, company, maximum_ats_mode, confirmed_terms, url, version }) { | |
| // 1. Read settings from storage | |
| const data = await new Promise(res => chrome.storage.local.get( | |
| ['resume_b64', 'resume_latex', 'api_url', 'api_token'], res | |
| )); | |
| if (!data.api_url || !data.api_token) { | |
| return { error: 'not_configured', detail: 'Please set API URL and token in Options.' }; | |
| } | |
| // R20: fall back to the bundled default resume when nothing is stored. | |
| let resumeLatex = data.resume_latex; | |
| if ((!resumeLatex || !resumeLatex.trim()) && !data.resume_b64 && self.DEFAULT_RESUME_LATEX) { | |
| resumeLatex = self.DEFAULT_RESUME_LATEX; | |
| } | |
| const hasLatex = !!(resumeLatex && resumeLatex.trim()); | |
| if (!hasLatex && !data.resume_b64) { | |
| return { error: 'no_resume', detail: 'Please paste your resume LaTeX or upload a PDF in Options first.' }; | |
| } | |
| // The popup sends the job URL so persistence is keyed consistently; if it's | |
| // missing (e.g. a recycled worker handling a queued message), fall back to the | |
| // active tab. Storage β not in-memory worker state β is the source of truth. | |
| const urlKey = normalizeUrl(url || await resolveActiveTabUrl()); | |
| // 2. Write the running marker BEFORE awaiting the API so a closed/reopened | |
| // popup restores a spinner instead of resetting to idle "Run". | |
| await writeEntry(urlKey, { | |
| status: 'running', | |
| startedAt: Date.now(), | |
| job_title: job_title || '', | |
| company: company || '', | |
| }); | |
| // 3. Perform the network round-trip (no popup dependency). | |
| const result = await runGenerate({ data, hasLatex, resumeLatex, jd_text, job_title, company, maximum_ats_mode, confirmed_terms, version }); | |
| // 4. Overwrite the marker with the terminal state β done OR error β so the | |
| // popup never shows a false/forever spinner. Done regardless of popup state. | |
| if (result && result.error) { | |
| await writeEntry(urlKey, { status: 'error', result }); | |
| } else { | |
| await writeEntry(urlKey, { status: 'done', result, job_title: job_title || '', company: company || '' }); | |
| } | |
| return result; // still returned to the popup so an open popup updates immediately | |
| } | |
| // Network/parse layer for GENERATE. Returns a result object (success or {error}). | |
| async function runGenerate({ data, hasLatex, resumeLatex, jd_text, job_title, company, maximum_ats_mode, confirmed_terms, version }) { | |
| // Build multipart/form-data. LaTeX takes priority over the PDF. | |
| const formData = new FormData(); | |
| formData.append('jd_text', jd_text); | |
| formData.append('job_title', job_title || ''); | |
| formData.append('company', company || ''); | |
| formData.append('maximum_ats_mode', maximum_ats_mode ? '1' : '0'); | |
| if (confirmed_terms) formData.append('confirmed_terms', confirmed_terms); | |
| formData.append('version', version || 'v1'); | |
| if (hasLatex) { | |
| formData.append('resume_latex', resumeLatex || data.resume_latex); | |
| } else { | |
| const binaryStr = atob(data.resume_b64); | |
| const bytes = new Uint8Array(binaryStr.length); | |
| for (let i = 0; i < binaryStr.length; i++) { | |
| bytes[i] = binaryStr.charCodeAt(i); | |
| } | |
| const resumeBlob = new Blob([bytes], { type: 'application/pdf' }); | |
| formData.append('resume', resumeBlob, 'resume.pdf'); | |
| } | |
| let resp; | |
| try { | |
| resp = await fetch(`${data.api_url.replace(/\/$/, '')}/api/generate`, { | |
| method: 'POST', | |
| headers: { 'X-Api-Token': data.api_token }, | |
| body: formData, | |
| }); | |
| } catch (networkErr) { | |
| return { error: 'network_error', detail: `Cannot reach API: ${networkErr.message}` }; | |
| } | |
| let result; | |
| try { | |
| result = await resp.json(); | |
| } catch { | |
| return { error: 'parse_error', detail: `API returned non-JSON (status ${resp.status})` }; | |
| } | |
| if (!resp.ok) { | |
| return { error: result.error || 'api_error', detail: result.detail || `HTTP ${resp.status}` }; | |
| } | |
| return result; | |
| } | |
| // βββ DOWNLOAD ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function handleDownload({ format, b64, filename }) { | |
| if (!b64) return; | |
| // MV3 service workers do NOT support URL.createObjectURL β it's undefined here, | |
| // which silently broke the download. Use a data: URL instead (chrome.downloads | |
| // accepts data: URLs directly from the service worker). | |
| let mimeType; | |
| if (format === 'pdf') { | |
| mimeType = 'application/pdf'; | |
| } else if (format === 'tex') { | |
| mimeType = 'application/x-tex'; | |
| } else { | |
| mimeType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; | |
| } | |
| const url = `data:${mimeType};base64,${b64}`; | |
| try { | |
| await chrome.downloads.download({ | |
| url, | |
| filename: filename || `resume.${format}`, | |
| saveAs: false, | |
| }); | |
| } catch (err) { | |
| console.error('[download] failed:', err); | |
| } | |
| } | |