init(app): Setup client package for web api specific logic

This commit is contained in:
Prad Nukala
2026-07-02 17:33:29 -04:00
parent f56116a26b
commit bcfeea93c9
27 changed files with 3104 additions and 0 deletions
+198
View File
@@ -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>
);
}