-
Notifications
You must be signed in to change notification settings - Fork 0
feat(resume): AI resume builder — split-screen chat + live Jake's Resume #107
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
b90fe7f
feat(resume): AI resume builder — split-screen chat + live Jake's Resume
ltanafranca1004 5301153
fix(resume): address CodeRabbit review (PR #107)
ltanafranca1004 bcef890
fix(resume): stricter profile validation (CodeRabbit round 2)
ltanafranca1004 0f440ac
fix(resume): reject non-object request bodies + bound history allocation
ltanafranca1004 af4bfc2
fix(resume): trim history content before the empty-check (CodeRabbit)
ltanafranca1004 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,151 @@ | ||
| "use client"; | ||
|
|
||
| import { useEffect, useMemo, useRef, useState } from "react"; | ||
| import { useTranslation } from "react-i18next"; | ||
| import { ResumeChatColumn } from "@/components/resume/ResumeChatColumn"; | ||
| import { ResumePanel } from "@/components/resume/ResumePanel"; | ||
| import { | ||
| useCreateResumeDraft, | ||
| useDeleteResumeDraft, | ||
| useResumeDraft, | ||
| useResumeDrafts, | ||
| useResumeUsage, | ||
| useSendResumeMessage, | ||
| } from "@/hooks/useResume"; | ||
| import { useCurrentUser } from "@/hooks/useProfile"; | ||
| import { | ||
| ResumeBusyError, | ||
| ResumeLimitError, | ||
| } from "@/services/resume"; | ||
| import { | ||
| RESUME_DAILY_MESSAGE_LIMIT, | ||
| emptyResume, | ||
| isResumeEmpty, | ||
| } from "@/lib/resume/schema"; | ||
|
|
||
| /** | ||
| * AI Resume Builder — split screen: conversation (left) + live-rendering resume | ||
| * (right). Mirrors the Companion two-pane flex; on mobile it's master/detail | ||
| * (toggle between chat and resume). Drafts + the daily cap are local-only. | ||
| */ | ||
| export default function ResumePage() { | ||
| const { t } = useTranslation(); | ||
| const [activeId, setActiveId] = useState<string | null>(null); | ||
| const [sendError, setSendError] = useState<string | null>(null); | ||
| // Mobile master/detail: false = chat visible, true = resume visible. | ||
| const [mobileShowResume, setMobileShowResume] = useState(false); | ||
|
|
||
| const draftsQuery = useResumeDrafts(); | ||
| const drafts = useMemo(() => draftsQuery.data ?? [], [draftsQuery.data]); | ||
| const currentUserQuery = useCurrentUser(); | ||
| // The shown draft: the explicitly-selected one, else the newest. Deriving it | ||
| // (rather than syncing via setState in an effect) auto-follows list changes — | ||
| // a freshly created draft becomes drafts[0] and is picked up here. | ||
| const effectiveActiveId = activeId ?? drafts[0]?.id ?? null; | ||
| const draftQuery = useResumeDraft(effectiveActiveId); | ||
| const draft = draftQuery.data ?? null; | ||
| const usageQuery = useResumeUsage(); | ||
|
|
||
| const createDraft = useCreateResumeDraft(); | ||
| const sendMessage = useSendResumeMessage(); | ||
| const deleteDraft = useDeleteResumeDraft(); | ||
|
|
||
| const remaining = usageQuery.data?.remaining ?? RESUME_DAILY_MESSAGE_LIMIT; | ||
| const limitReached = remaining <= 0; | ||
|
|
||
| // Bootstrap: when the user has no drafts at all, create a first one (once the | ||
| // current user has loaded so contact prefill is populated). No setState here — | ||
| // the new draft surfaces via `effectiveActiveId`'s drafts[0] fallback. | ||
| const bootstrapping = useRef(false); | ||
| useEffect(() => { | ||
| if (bootstrapping.current) return; | ||
| if (!draftsQuery.isSuccess || drafts.length > 0) return; | ||
| if (!currentUserQuery.isFetched) return; | ||
| bootstrapping.current = true; | ||
| createDraft.mutateAsync().catch(() => { | ||
| bootstrapping.current = false; | ||
| }); | ||
| }, [draftsQuery.isSuccess, drafts.length, currentUserQuery.isFetched, createDraft]); | ||
|
|
||
| async function handleSend(text: string) { | ||
| if (!effectiveActiveId) return; | ||
| // Serialize turns: ignore a new send while one is still in flight so two | ||
| // mutations can't read the same draft and overwrite each other. | ||
| if (sendMessage.isPending) return; | ||
| setSendError(null); | ||
| try { | ||
| await sendMessage.mutateAsync({ draftId: effectiveActiveId, text }); | ||
| } catch (err) { | ||
| if (err instanceof ResumeLimitError) { | ||
| setSendError(t("resume.limitReachedToast")); | ||
| } else if (err instanceof ResumeBusyError) { | ||
| setSendError(t("resume.busy")); | ||
| } else { | ||
| console.error("Resume: failed to send message", err); | ||
| setSendError(t("resume.sendFailed")); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| async function handleNewDraft() { | ||
| setSendError(null); | ||
| try { | ||
| const created = await createDraft.mutateAsync(); | ||
| setActiveId(created.id); | ||
| setMobileShowResume(false); | ||
| } catch (err) { | ||
| console.error("Resume: failed to create draft", err); | ||
| } | ||
| } | ||
|
|
||
| function handleSelectDraft(id: string) { | ||
| setSendError(null); | ||
| setActiveId(id); | ||
| setMobileShowResume(false); | ||
| } | ||
|
|
||
| async function handleDeleteDraft(id: string) { | ||
| try { | ||
| await deleteDraft.mutateAsync(id); | ||
| if (id === effectiveActiveId) { | ||
| // Drop the explicit selection; effectiveActiveId falls back to the next | ||
| // newest draft. If none remain, re-arm the bootstrap to create a fresh one. | ||
| setActiveId(null); | ||
| if (drafts.filter((d) => d.id !== id).length === 0) { | ||
| bootstrapping.current = false; | ||
| } | ||
| } | ||
| } catch (err) { | ||
| console.error("Resume: failed to delete draft", err); | ||
| } | ||
| } | ||
|
|
||
| const resumeData = draft?.resume ?? emptyResume(); | ||
|
|
||
| return ( | ||
| <div className="flex h-[calc(100dvh_-_3.5rem_-_env(safe-area-inset-bottom))] animate-fade-in md:h-dvh"> | ||
| <ResumeChatColumn | ||
| draft={draft} | ||
| drafts={drafts} | ||
| activeId={effectiveActiveId} | ||
| isTyping={sendMessage.isPending} | ||
| errorMessage={sendError} | ||
| remaining={remaining} | ||
| limitReached={limitReached} | ||
| onSend={handleSend} | ||
| onSelectDraft={handleSelectDraft} | ||
| onNewDraft={handleNewDraft} | ||
| onDeleteDraft={handleDeleteDraft} | ||
| mobileActive={!mobileShowResume} | ||
| onShowResume={() => setMobileShowResume(true)} | ||
| /> | ||
| <ResumePanel | ||
| data={resumeData} | ||
| isEmpty={isResumeEmpty(resumeData)} | ||
| complete={draft?.complete ?? false} | ||
| mobileActive={mobileShowResume} | ||
| onBackToChat={() => setMobileShowResume(false)} | ||
| /> | ||
| </div> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,172 @@ | ||
| import { NextResponse, type NextRequest } from "next/server"; | ||
| import { createClient } from "@/lib/supabase/server"; | ||
| import { generateResumeTurn, ResumeUpstreamError } from "@/lib/resume/generateTurn"; | ||
| import { checkAndIncrementResumeUsage } from "@/lib/resume/serverRateLimit"; | ||
| import { | ||
| normalizeResumeData, | ||
| MAX_RESUME_MESSAGE_LEN, | ||
| RESUME_HISTORY_TURNS, | ||
| } from "@/lib/resume/schema"; | ||
| import { isSupportedLanguage, DEFAULT_LANGUAGE } from "@/lib/i18n/config"; | ||
| import type { | ||
| ResumeChatRole, | ||
| ResumeProfileContext, | ||
| ResumeTurnRequest, | ||
| } from "@/types/resume"; | ||
| import type { Persona, Stage } from "@/types"; | ||
|
|
||
| /** | ||
| * Server-side turn generator for the AI Resume Builder (local prototype path). | ||
| * | ||
| * The browser POSTs the conversation so far + the resume built so far; this | ||
| * route asks DeepSeek (via OpenRouter, pinned to deepseek/deepseek-v4-flash) for | ||
| * the next structured turn and returns { reply, suggestions, resume, complete }. | ||
| * | ||
| * PROTOTYPE NOTE: production would move this to the resume-chat Supabase edge | ||
| * function (see supabase/functions/resume-chat/) and invoke it server→server — | ||
| * the same shape as /api/companion → rag-query. It runs here directly against | ||
| * OpenRouter so the prototype works with no Docker / functions-serve. Auth is | ||
| * still required (the endpoint spends the shared OpenRouter budget). Node | ||
| * runtime; maxDuration mirrors /api/companion (LLM completion with a timeout). | ||
| */ | ||
| export const runtime = "nodejs"; | ||
| export const maxDuration = 60; | ||
|
|
||
| const VALID_PERSONAS: Persona[] = [ | ||
| "international_student", | ||
| "skilled_worker", | ||
| "refugee", | ||
| "other", | ||
| ]; | ||
|
|
||
| function clampProfile(raw: unknown): ResumeProfileContext { | ||
| const p = ( | ||
| raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {} | ||
| ) as Record<string, unknown>; | ||
| const persona = | ||
| typeof p.persona === "string" && VALID_PERSONAS.includes(p.persona as Persona) | ||
| ? (p.persona as Persona) | ||
| : null; | ||
| // Require an actual integer — Number("0")/Number(false)/Number("") all coerce | ||
| // to 0 and would otherwise sneak through as a valid stage. | ||
| const stage: Stage | null = | ||
| typeof p.stage === "number" && | ||
| Number.isInteger(p.stage) && | ||
| p.stage >= 0 && | ||
| p.stage <= 4 | ||
| ? (p.stage as Stage) | ||
| : null; | ||
| const responseLanguage = isSupportedLanguage(p.responseLanguage) | ||
| ? p.responseLanguage | ||
| : DEFAULT_LANGUAGE; | ||
| const asString = (v: unknown, max: number): string | null => | ||
| typeof v === "string" && v.trim() ? v.trim().slice(0, max) : null; | ||
| return { | ||
| firstName: asString(p.firstName, 80), | ||
| persona, | ||
| stage, | ||
| city: asString(p.city, 80), | ||
| province: asString(p.province, 40), | ||
| email: asString(p.email, 160), | ||
| responseLanguage, | ||
| }; | ||
| } | ||
|
|
||
| function clampHistory( | ||
| raw: unknown, | ||
| ): { role: ResumeChatRole; content: string }[] { | ||
| if (!Array.isArray(raw)) return []; | ||
| // Walk newest-first and keep only the last RESUME_HISTORY_TURNS valid entries | ||
| // (in chronological order), so a huge client history isn't fully allocated | ||
| // just to be sliced away. Matches services/resume.ts + the edge function. | ||
| const out: { role: ResumeChatRole; content: string }[] = []; | ||
| for ( | ||
| let i = raw.length - 1; | ||
| i >= 0 && out.length < RESUME_HISTORY_TURNS; | ||
| i -= 1 | ||
| ) { | ||
| const r = (raw[i] ?? {}) as Record<string, unknown>; | ||
| const role: ResumeChatRole = r.role === "assistant" ? "assistant" : "user"; | ||
| // Trim before the truthiness check so whitespace-only entries don't count | ||
| // toward RESUME_HISTORY_TURNS and displace real history. | ||
| const content = | ||
| typeof r.content === "string" ? r.content.trim().slice(0, 4000) : ""; | ||
| if (content) out.unshift({ role, content }); | ||
| } | ||
| return out; | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| export async function POST(req: NextRequest) { | ||
| const supabase = await createClient(); | ||
| const { | ||
| data: { user }, | ||
| } = await supabase.auth.getUser(); | ||
| if (!user) { | ||
| return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); | ||
| } | ||
|
|
||
| let body: Record<string, unknown>; | ||
| try { | ||
| const parsed = await req.json(); | ||
| // `null`, arrays, and primitives parse as valid JSON but aren't request | ||
| // objects — reject them with a 400 instead of throwing a 500 on field access. | ||
| if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { | ||
| return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); | ||
| } | ||
| body = parsed as Record<string, unknown>; | ||
| } catch { | ||
| return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); | ||
| } | ||
|
|
||
| const message = typeof body.message === "string" ? body.message.trim() : ""; | ||
| if (!message) { | ||
| return NextResponse.json({ error: "Message is required" }, { status: 400 }); | ||
| } | ||
| if (message.length > MAX_RESUME_MESSAGE_LEN) { | ||
| return NextResponse.json( | ||
| { error: "Message is too long" }, | ||
| { status: 413 }, | ||
| ); | ||
| } | ||
|
|
||
| // Server-side daily cap keyed by user (the client localStorage cap is | ||
| // bypassable). Checked before spending an OpenRouter call. | ||
| if (!checkAndIncrementResumeUsage(user.id)) { | ||
| return NextResponse.json( | ||
| { error: "Daily resume-builder limit reached.", code: "daily_limit_reached" }, | ||
| { status: 429 }, | ||
| ); | ||
| } | ||
|
|
||
| const turn: ResumeTurnRequest = { | ||
| message, | ||
| history: clampHistory(body.history), | ||
| currentResume: normalizeResumeData(body.currentResume), | ||
| profile: clampProfile(body.profile), | ||
| }; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| try { | ||
| const result = await generateResumeTurn(turn); | ||
| return NextResponse.json(result); | ||
| } catch (error) { | ||
| if (error instanceof ResumeUpstreamError) { | ||
| console.error("Resume: upstream failure", { | ||
| status: error.status, | ||
| retryable: error.retryable, | ||
| message: error.message, | ||
| }); | ||
| // 503 for retryable upstream trouble (429 / 5xx / timeout); preserve a | ||
| // non-retryable 500 (e.g. missing key) instead of masking it as 502. | ||
| const status = error.retryable ? 503 : error.status === 500 ? 500 : 502; | ||
| return NextResponse.json( | ||
| { error: "The resume assistant is busy. Please try again.", retryable: error.retryable }, | ||
| { status }, | ||
| ); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| console.error("Resume: turn generation failed", error); | ||
| return NextResponse.json( | ||
| { error: "Failed to generate a reply." }, | ||
| { status: 500 }, | ||
| ); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.