221 lines
8.3 KiB
TypeScript
221 lines
8.3 KiB
TypeScript
"use client";
|
|
|
|
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
|
import { scoreTone } from "@/lib/format";
|
|
|
|
export type JobStep = { kind: "tool" | "status"; label: string; ts: number };
|
|
export type JobResult = { score: number | null; summary: string; tone: "good" | "warn" | "bad" | "muted" };
|
|
|
|
export type Job = {
|
|
id: string;
|
|
title: string;
|
|
subtitle?: string;
|
|
page?: string; // route the job was launched from / refers to
|
|
input?: string; // the URL/posting it processed (links inbox rows to their worker)
|
|
kind?: string;
|
|
batchId?: string; // groups jobs fired together (e.g. "evaluate all Anthropic")
|
|
status: "running" | "done" | "error";
|
|
steps: JobStep[];
|
|
text: string;
|
|
result?: JobResult;
|
|
cost?: { tokens: number; usd?: number }; // per-run token cost (Claude result event) — local only
|
|
startedAt: number;
|
|
endedAt?: number;
|
|
};
|
|
|
|
type StartOpts = { title: string; subtitle?: string; kind: string; input: string; page?: string; batchId?: string };
|
|
|
|
type Ctx = {
|
|
jobs: Job[];
|
|
startJob: (opts: StartOpts) => string | null;
|
|
removeJob: (id: string) => void;
|
|
clearFinished: () => void;
|
|
};
|
|
|
|
const JobsContext = createContext<Ctx | null>(null);
|
|
export function useJobs() {
|
|
const c = useContext(JobsContext);
|
|
if (!c) throw new Error("useJobs must be used within <JobsProvider>");
|
|
return c;
|
|
}
|
|
|
|
const CONFIG_KEY = "career-ops:config";
|
|
const JOBS_KEY = "career-ops:jobs";
|
|
|
|
function parseVerdict(text: string): JobResult {
|
|
const m = text.match(/VERDICT:\s*([\d.]+)\s*\/\s*5\s*[—:|-]+\s*(.+)/i);
|
|
if (m) {
|
|
const score = parseFloat(m[1]);
|
|
return { score, summary: m[2].trim().replace(/\s+/g, " ").slice(0, 90), tone: scoreTone(`${score}`) };
|
|
}
|
|
const s = text.match(/\b([0-5](?:\.\d)?)\s*\/\s*5\b/);
|
|
if (s) {
|
|
const score = parseFloat(s[1]);
|
|
return { score, summary: "", tone: scoreTone(`${score}`) };
|
|
}
|
|
return { score: null, summary: "", tone: "muted" };
|
|
}
|
|
|
|
export function JobsProvider({ children }: { children: React.ReactNode }) {
|
|
const [jobs, setJobs] = useState<Job[]>([]);
|
|
const seq = useRef(0);
|
|
const loaded = useRef(false);
|
|
|
|
// restore history
|
|
useEffect(() => {
|
|
try {
|
|
const raw = localStorage.getItem(JOBS_KEY);
|
|
const arr = raw ? JSON.parse(raw) : null;
|
|
if (Array.isArray(arr)) {
|
|
// anything left "running" from a previous session is stale → mark interrupted
|
|
setJobs(arr.map((j: Job) => (j.status === "running" ? { ...j, status: "error", steps: [...(j.steps || []), { kind: "status", label: "Interrupted (page reloaded)", ts: Date.now() }] } : j)));
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
loaded.current = true;
|
|
}, []);
|
|
|
|
// persist
|
|
useEffect(() => {
|
|
if (!loaded.current) return;
|
|
try {
|
|
localStorage.setItem(JOBS_KEY, JSON.stringify(jobs.slice(0, 40)));
|
|
} catch {
|
|
/* quota */
|
|
}
|
|
}, [jobs]);
|
|
|
|
const patch = useCallback((id: string, fn: (j: Job) => Job) => {
|
|
setJobs((js) => js.map((j) => (j.id === id ? fn(j) : j)));
|
|
}, []);
|
|
|
|
const startJob = useCallback(
|
|
(opts: StartOpts): string | null => {
|
|
let cliId: string | null = null;
|
|
try {
|
|
const raw = localStorage.getItem(CONFIG_KEY);
|
|
cliId = raw ? JSON.parse(raw).cliId || null : null;
|
|
} catch {
|
|
cliId = null;
|
|
}
|
|
const id = `job-${Date.now()}-${seq.current++}`;
|
|
const job: Job = {
|
|
id,
|
|
title: opts.title,
|
|
subtitle: opts.subtitle,
|
|
page: opts.page,
|
|
input: opts.input,
|
|
kind: opts.kind,
|
|
batchId: opts.batchId,
|
|
status: "running",
|
|
steps: [{ kind: "status", label: "Starting…", ts: Date.now() }],
|
|
text: "",
|
|
startedAt: Date.now(),
|
|
};
|
|
setJobs((js) => [job, ...js]);
|
|
|
|
if (!cliId) {
|
|
patch(id, (j) => ({ ...j, status: "error", endedAt: Date.now(), steps: [...j.steps, { kind: "status", label: "No CLI configured — open Config", ts: Date.now() }] }));
|
|
return id;
|
|
}
|
|
|
|
(async () => {
|
|
let text = "";
|
|
let verdictLine = ""; // latched separately so the 8000-char tail can't drop it
|
|
let doneTokens = 0; // per-run token cost, forwarded on the done event (#6)
|
|
let doneCostUsd: number | null = null;
|
|
const steps: JobStep[] = [];
|
|
const finish = (status: "done" | "error", lastLabel?: string) => {
|
|
const result = status === "done" ? parseVerdict(verdictLine || text) : undefined;
|
|
const cost = status === "done" && doneTokens > 0 ? { tokens: doneTokens, usd: doneCostUsd ?? undefined } : undefined;
|
|
patch(id, (j) => ({
|
|
...j,
|
|
status,
|
|
result,
|
|
cost,
|
|
endedAt: Date.now(),
|
|
steps: lastLabel ? [...j.steps, { kind: "status", label: lastLabel, ts: Date.now() }] : j.steps,
|
|
}));
|
|
// persist a readable log file so the CLI/assistant can read past runs
|
|
if (status === "done") {
|
|
fetch("/api/runs/save", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ id, title: opts.title, subtitle: opts.subtitle, page: opts.page, input: opts.input, result, cost, steps, output: text }),
|
|
}).catch(() => {});
|
|
// Tell server-snapshot surfaces (Today, pipeline) to refetch — the
|
|
// worker just wrote a real tracker row / report they don't yet see.
|
|
if (typeof window !== "undefined" && (opts.kind === "evaluate" || opts.kind === "pdf")) {
|
|
window.dispatchEvent(new CustomEvent("co-job-done", { detail: { kind: opts.kind, input: opts.input } }));
|
|
}
|
|
}
|
|
};
|
|
|
|
try {
|
|
const res = await fetch("/api/run", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ kind: opts.kind, input: opts.input, cliId }),
|
|
});
|
|
if (!res.ok || !res.body) {
|
|
const e = await res.json().catch(() => ({}));
|
|
finish("error", e.error || "Failed to start");
|
|
return;
|
|
}
|
|
const reader = res.body.getReader();
|
|
const dec = new TextDecoder();
|
|
let buf = "";
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buf += dec.decode(value, { stream: true });
|
|
let nl: number;
|
|
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
const line = buf.slice(0, nl).trim();
|
|
buf = buf.slice(nl + 1);
|
|
if (!line) continue;
|
|
try {
|
|
const ev = JSON.parse(line);
|
|
if (ev.type === "tool") {
|
|
steps.push({ kind: "tool", label: ev.name, ts: Date.now() });
|
|
patch(id, (j) => ({ ...j, steps: [...j.steps, { kind: "tool", label: ev.name, ts: Date.now() }] }));
|
|
} else if (ev.type === "status") {
|
|
steps.push({ kind: "status", label: ev.label, ts: Date.now() });
|
|
patch(id, (j) => ({ ...j, steps: [...j.steps, { kind: "status", label: ev.label, ts: Date.now() }] }));
|
|
} else if (ev.type === "text") {
|
|
const full = text + ev.text;
|
|
const vm = full.match(/VERDICT:[^\n]*/i);
|
|
if (vm) verdictLine = vm[0];
|
|
text = full.slice(-8000);
|
|
patch(id, (j) => ({ ...j, text }));
|
|
} else if (ev.type === "done") {
|
|
// finish happens on stream-close; capture the per-run cost it carries
|
|
if (typeof ev.tokens === "number") doneTokens = ev.tokens;
|
|
if (typeof ev.costUsd === "number") doneCostUsd = ev.costUsd;
|
|
} else if (ev.type === "error") {
|
|
finish("error", ev.msg || "Error");
|
|
return;
|
|
}
|
|
} catch {
|
|
/* skip */
|
|
}
|
|
}
|
|
}
|
|
finish("done", "Done");
|
|
} catch {
|
|
finish("error", "Connection error");
|
|
}
|
|
})();
|
|
|
|
return id;
|
|
},
|
|
[patch],
|
|
);
|
|
|
|
const removeJob = useCallback((id: string) => setJobs((js) => js.filter((j) => j.id !== id)), []);
|
|
const clearFinished = useCallback(() => setJobs((js) => js.filter((j) => j.status === "running")), []);
|
|
|
|
return <JobsContext.Provider value={{ jobs, startJob, removeJob, clearFinished }}>{children}</JobsContext.Provider>;
|
|
}
|