mirror of
https://github.com/prdlk/cv.git
synced 2026-08-02 17:31:41 +00:00
init(app): Setup client package for web api specific logic
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user