mirror of
https://github.com/prdlk/cv.git
synced 2026-08-02 17:31:41 +00:00
199 lines
7.1 KiB
TypeScript
199 lines
7.1 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
||||
|
|
import type { ChatMessage, JobExtract, MatchResult, PlanResponse } from "../../shared/types";
|
|||
|
|
import type { GeneratedResume } from "../App";
|
|||
|
|
import { ApiRequestError, compilePdf, post } from "../api";
|
|||
|
|
|
|||
|
|
interface WorkspaceProps {
|
|||
|
|
jd: JobExtract;
|
|||
|
|
messages: ChatMessage[];
|
|||
|
|
onMessagesChange: (messages: ChatMessage[]) => void;
|
|||
|
|
onBack: () => void;
|
|||
|
|
onGenerated: (result: GeneratedResume) => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function Workspace({ jd, messages, onMessagesChange, onBack, onGenerated }: WorkspaceProps) {
|
|||
|
|
const [match, setMatch] = useState<MatchResult | null>(null);
|
|||
|
|
const [matchError, setMatchError] = useState("");
|
|||
|
|
const [draft, setDraft] = useState("");
|
|||
|
|
const [chatBusy, setChatBusy] = useState(false);
|
|||
|
|
const [genStep, setGenStep] = useState("");
|
|||
|
|
const [genError, setGenError] = useState("");
|
|||
|
|
const chatEndRef = useRef<HTMLDivElement | null>(null);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
let cancelled = false;
|
|||
|
|
post<MatchResult>("/api/match", { text: jd.text })
|
|||
|
|
.then((m) => {
|
|||
|
|
if (!cancelled) setMatch(m);
|
|||
|
|
})
|
|||
|
|
.catch((err: unknown) => {
|
|||
|
|
if (!cancelled) setMatchError(err instanceof Error ? err.message : String(err));
|
|||
|
|
});
|
|||
|
|
return () => {
|
|||
|
|
cancelled = true;
|
|||
|
|
};
|
|||
|
|
}, [jd.text]);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
chatEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|||
|
|
}, [messages, chatBusy]);
|
|||
|
|
|
|||
|
|
async function sendChat(): Promise<void> {
|
|||
|
|
const content = draft.trim();
|
|||
|
|
if (content.length === 0 || chatBusy || !match) return;
|
|||
|
|
const next: ChatMessage[] = [...messages, { role: "user", content }];
|
|||
|
|
onMessagesChange(next);
|
|||
|
|
setDraft("");
|
|||
|
|
setChatBusy(true);
|
|||
|
|
try {
|
|||
|
|
const { reply } = await post<{ reply: string }>("/api/chat", { jd, match, messages: next });
|
|||
|
|
onMessagesChange([...next, { role: "assistant", content: reply }]);
|
|||
|
|
} catch (err) {
|
|||
|
|
const message = err instanceof Error ? err.message : String(err);
|
|||
|
|
onMessagesChange([...next, { role: "assistant", content: `(error) ${message}` }]);
|
|||
|
|
} finally {
|
|||
|
|
setChatBusy(false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function generate(): Promise<void> {
|
|||
|
|
if (!match || genStep) return;
|
|||
|
|
setGenError("");
|
|||
|
|
try {
|
|||
|
|
setGenStep("Planning selection with AI…");
|
|||
|
|
const planRes = await post<PlanResponse>("/api/plan", { jd, match, chat: messages });
|
|||
|
|
setGenStep("Rendering LaTeX…");
|
|||
|
|
const { latex } = await post<{ latex: string }>("/api/latex", { plan: planRes.plan });
|
|||
|
|
setGenStep("Compiling PDF…");
|
|||
|
|
const blob = await compilePdf(latex);
|
|||
|
|
onGenerated({
|
|||
|
|
plan: planRes.plan,
|
|||
|
|
source: planRes.source,
|
|||
|
|
note: planRes.note,
|
|||
|
|
latex,
|
|||
|
|
pdfUrl: URL.createObjectURL(blob),
|
|||
|
|
});
|
|||
|
|
} catch (err) {
|
|||
|
|
if (err instanceof ApiRequestError && err.log) {
|
|||
|
|
setGenError(`${err.message}\n\n${err.log}`);
|
|||
|
|
} else {
|
|||
|
|
setGenError(err instanceof Error ? err.message : String(err));
|
|||
|
|
}
|
|||
|
|
setGenStep("");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const matchedItems = match?.items.filter((i) => i.score > 0) ?? [];
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="workspace">
|
|||
|
|
<header className="topbar">
|
|||
|
|
<button type="button" className="btn ghost" onClick={onBack}>
|
|||
|
|
← New posting
|
|||
|
|
</button>
|
|||
|
|
<div className="topbar-title">
|
|||
|
|
<strong>{jd.title}</strong>
|
|||
|
|
<span className="muted">
|
|||
|
|
{jd.source === "manual" ? "pasted manually" : `${jd.url} · extracted via ${jd.source}`}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<button type="button" className="btn primary" onClick={() => void generate()} disabled={!match || genStep !== ""}>
|
|||
|
|
{genStep !== "" ? genStep : "Generate PDF"}
|
|||
|
|
</button>
|
|||
|
|
</header>
|
|||
|
|
|
|||
|
|
{genError && <pre className="error banner">{genError}</pre>}
|
|||
|
|
|
|||
|
|
<div className="workspace-grid">
|
|||
|
|
<section className="panel">
|
|||
|
|
<h2>Job Posting</h2>
|
|||
|
|
<pre className="jd-text">{jd.text}</pre>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<section className="panel">
|
|||
|
|
<h2>
|
|||
|
|
Matched Material
|
|||
|
|
{match && <span className="muted"> — {match.skills.length} skills hit</span>}
|
|||
|
|
</h2>
|
|||
|
|
{matchError && <div className="error">{matchError}</div>}
|
|||
|
|
{!match && !matchError && <div className="loading">Matching against the corpus…</div>}
|
|||
|
|
{match && (
|
|||
|
|
<>
|
|||
|
|
<div className="chips">
|
|||
|
|
{match.skills.map((s) => (
|
|||
|
|
<span key={s.name} className={`chip ${s.category}`} title={s.category}>
|
|||
|
|
{s.name} <em>×{s.hits}</em>
|
|||
|
|
</span>
|
|||
|
|
))}
|
|||
|
|
{match.skills.length === 0 && <span className="muted">No skill overlap found.</span>}
|
|||
|
|
</div>
|
|||
|
|
<div className="items">
|
|||
|
|
{matchedItems.map((item) => (
|
|||
|
|
<article key={`${item.type}:${item.slug}`} className="item-card">
|
|||
|
|
<header>
|
|||
|
|
<span className={`badge ${item.type}`}>{item.type}</span>
|
|||
|
|
<strong>{item.title}</strong>
|
|||
|
|
<span className="muted">
|
|||
|
|
{item.company ? `${item.company} · ` : ""}
|
|||
|
|
{item.dates} · score {item.score}
|
|||
|
|
</span>
|
|||
|
|
</header>
|
|||
|
|
<div className="mini-chips">
|
|||
|
|
{item.matchedSkills.map((s) => (
|
|||
|
|
<span key={s} className="chip mini">
|
|||
|
|
{s}
|
|||
|
|
</span>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
<ul>
|
|||
|
|
{item.bullets.slice(0, 3).map((b) => (
|
|||
|
|
<li key={b.slice(0, 40)}>{b}</li>
|
|||
|
|
))}
|
|||
|
|
</ul>
|
|||
|
|
</article>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</section>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<section className="chat panel">
|
|||
|
|
<h2>Refine with the agent</h2>
|
|||
|
|
<div className="chat-log">
|
|||
|
|
{messages.length === 0 && (
|
|||
|
|
<div className="muted chat-empty">
|
|||
|
|
Ask the agent to emphasize certain skills, pick different projects, or adjust the
|
|||
|
|
headline — the conversation is folded into the plan when you hit Generate.
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
{messages.map((m, i) => (
|
|||
|
|
<div key={`${i}-${m.content.slice(0, 20)}`} className={`msg ${m.role}`}>
|
|||
|
|
{m.content}
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
{chatBusy && <div className="msg assistant pending">…</div>}
|
|||
|
|
<div ref={chatEndRef} />
|
|||
|
|
</div>
|
|||
|
|
<form
|
|||
|
|
className="chat-form"
|
|||
|
|
onSubmit={(e) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
void sendChat();
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<input
|
|||
|
|
value={draft}
|
|||
|
|
onChange={(e) => setDraft(e.target.value)}
|
|||
|
|
placeholder={match ? "e.g. Lead with the cryptography work, skip consumer apps" : "Waiting for match…"}
|
|||
|
|
disabled={!match}
|
|||
|
|
/>
|
|||
|
|
<button type="submit" className="btn" disabled={!match || chatBusy || draft.trim().length === 0}>
|
|||
|
|
Send
|
|||
|
|
</button>
|
|||
|
|
</form>
|
|||
|
|
</section>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|