From b90fe7f96bea42699365b69927a768fb0bd5cc15 Mon Sep 17 00:00:00 2001 From: Luis Tanafranca <80248146+ltanafranca1004@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:00:27 -0700 Subject: [PATCH 1/5] =?UTF-8?q?feat(resume):=20AI=20resume=20builder=20?= =?UTF-8?q?=E2=80=94=20split-screen=20chat=20+=20live=20Jake's=20Resume?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conversational, DeepSeek-powered resume builder (web-only). The left pane asks open-ended questions about work history and turns plain, everyday answers into resume-quality bullets; the right pane live-renders a "Jake's Resume"-style document from structured JSON. Suggested answer chips FILL the input (they do not auto-send) to cut typing for users with limited English. - Route + two-pane layout cloned from Companion; sidebar entry is desktop-only (NavItem.desktopOnly) so the full mobile bottom nav is untouched. - Structured JSON-per-turn contract: the model returns the complete updated resume each turn; reply + suggestions in the user's language, resume content in English for the Canadian job market. - Prompt iterated against real DeepSeek across retail/trades/office/student paths (fixes: single-field nagging loop, non-English resume values, prose fallback when json-mode slips). - Prototype execution runs in-process at /api/resume (Node -> OpenRouter, pinned deepseek/deepseek-v4-flash). The full Deno edge function ships at supabase/functions/resume-chat but is NOT deployed (awaits Savar sign-off on shared infra + a real quota RPC). - Local-only persistence: localStorage drafts + a daily message cap (separate from the 6/day chatbot quota), via services/resume.ts + hooks/useResume.ts, shaped to swap to a Supabase table later. - Onboarding prefill (name / city / persona / language) personalizes the opener. - Client-side PDF export via @media print isolation (selectable, ATS-friendly). - i18n: en/es/hi/vi translated; ar/fr-CA fall back per the existing pattern. Co-Authored-By: Claude Opus 4.8 --- app/(main)/resume/page.tsx | 148 ++++++++ app/api/resume/route.ts | 132 +++++++ app/globals.css | 33 ++ components/layout/BottomNav.tsx | 4 +- components/layout/navItems.ts | 12 +- components/resume/ResumeChatColumn.tsx | 279 ++++++++++++++ components/resume/ResumePanel.tsx | 82 ++++ components/resume/ResumePaper.tsx | 212 +++++++++++ components/resume/ResumeSuggestionChips.tsx | 42 +++ hooks/useResume.ts | 229 ++++++++++++ lib/i18n/locales/en/translation.json | 41 +- lib/i18n/locales/es/translation.json | 41 +- lib/i18n/locales/hi/translation.json | 41 +- lib/i18n/locales/vi/translation.json | 41 +- lib/resume/generateTurn.ts | 129 +++++++ lib/resume/prompt.ts | 209 +++++++++++ lib/resume/schema.ts | 166 +++++++++ services/resume.ts | 213 +++++++++++ supabase/functions/resume-chat/index.ts | 394 ++++++++++++++++++++ types/resume.ts | 154 ++++++++ 20 files changed, 2596 insertions(+), 6 deletions(-) create mode 100644 app/(main)/resume/page.tsx create mode 100644 app/api/resume/route.ts create mode 100644 components/resume/ResumeChatColumn.tsx create mode 100644 components/resume/ResumePanel.tsx create mode 100644 components/resume/ResumePaper.tsx create mode 100644 components/resume/ResumeSuggestionChips.tsx create mode 100644 hooks/useResume.ts create mode 100644 lib/resume/generateTurn.ts create mode 100644 lib/resume/prompt.ts create mode 100644 lib/resume/schema.ts create mode 100644 services/resume.ts create mode 100644 supabase/functions/resume-chat/index.ts create mode 100644 types/resume.ts diff --git a/app/(main)/resume/page.tsx b/app/(main)/resume/page.tsx new file mode 100644 index 0000000..ce21292 --- /dev/null +++ b/app/(main)/resume/page.tsx @@ -0,0 +1,148 @@ +"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(null); + const [sendError, setSendError] = useState(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; + 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 ( +
+ setMobileShowResume(true)} + /> + setMobileShowResume(false)} + /> +
+ ); +} diff --git a/app/api/resume/route.ts b/app/api/resume/route.ts new file mode 100644 index 0000000..08d163c --- /dev/null +++ b/app/api/resume/route.ts @@ -0,0 +1,132 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { generateResumeTurn, ResumeUpstreamError } from "@/lib/resume/generateTurn"; +import { normalizeResumeData, MAX_RESUME_MESSAGE_LEN } 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 ?? {}) as Record; + const persona = + typeof p.persona === "string" && VALID_PERSONAS.includes(p.persona as Persona) + ? (p.persona as Persona) + : null; + const stageNum = Number(p.stage); + const stage: Stage | null = + Number.isInteger(stageNum) && stageNum >= 0 && stageNum <= 4 + ? (stageNum 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 []; + const out: { role: ResumeChatRole; content: string }[] = []; + for (const item of raw) { + const r = (item ?? {}) as Record; + const role = r.role === "assistant" ? "assistant" : "user"; + const content = + typeof r.content === "string" ? r.content.slice(0, 4000) : ""; + if (content) out.push({ role, content }); + if (out.length >= 30) break; + } + return out; +} + +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; + try { + body = (await req.json()) as Record; + } 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 }, + ); + } + + const turn: ResumeTurnRequest = { + message, + history: clampHistory(body.history), + currentResume: normalizeResumeData(body.currentResume), + profile: clampProfile(body.profile), + }; + + try { + const result = await generateResumeTurn(turn); + return NextResponse.json(result); + } catch (error) { + if (error instanceof ResumeUpstreamError) { + // 503 for retryable upstream trouble (429 / 5xx / timeout), else 502. + const status = error.retryable ? 503 : 502; + return NextResponse.json( + { error: "The resume assistant is busy. Please try again.", retryable: error.retryable }, + { status }, + ); + } + console.error("Resume: turn generation failed", error); + return NextResponse.json( + { error: "Failed to generate a reply." }, + { status: 500 }, + ); + } +} diff --git a/app/globals.css b/app/globals.css index b33519a..60b9dc6 100644 --- a/app/globals.css +++ b/app/globals.css @@ -309,3 +309,36 @@ input:-webkit-autofill:active { scroll-behavior: auto !important; } } + +/* + * Resume Builder — "Download PDF" prints ONLY the rendered resume, not the app + * shell or the chat. Classic single-element isolation: hide everything, re-show + * the .resume-paper subtree, and float it to the page origin. Text stays real + + * selectable (ATS-friendly) — no rasterization. The user picks "Save as PDF". + */ +@media print { + body * { + visibility: hidden !important; + } + .resume-paper, + .resume-paper * { + visibility: visible !important; + } + .resume-paper { + position: absolute !important; + inset-inline-start: 0 !important; + top: 0 !important; + margin: 0 !important; + width: 100% !important; + max-width: 100% !important; + padding: 0.5in 0.55in !important; + box-shadow: none !important; + --tw-ring-shadow: 0 0 #0000 !important; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + @page { + size: letter; + margin: 0; + } +} diff --git a/components/layout/BottomNav.tsx b/components/layout/BottomNav.tsx index 27adea1..3707ff5 100644 --- a/components/layout/BottomNav.tsx +++ b/components/layout/BottomNav.tsx @@ -10,7 +10,9 @@ import { MAIN_NAV, SETTINGS_ITEM, isNavItemActive } from "./navItems"; // The 5 primary tabs + Settings (6 total) — 7 was cramped on 375px. Profile is // reached from within Settings (the "View your profile" row), and sign-out also // lives in Settings, so the profile / settings / sign-out chain stays reachable. -const TABS = [...MAIN_NAV, SETTINGS_ITEM]; +// `desktopOnly` items (the width-hungry Resume Builder) are excluded here so the +// bottom bar stays within its item ceiling. +const TABS = [...MAIN_NAV.filter((item) => !item.desktopOnly), SETTINGS_ITEM]; /** * Fixed bottom navigation shown only below the `md` breakpoint. Pads itself with diff --git a/components/layout/navItems.ts b/components/layout/navItems.ts index 9c6218f..9b87cf1 100644 --- a/components/layout/navItems.ts +++ b/components/layout/navItems.ts @@ -1,5 +1,5 @@ import React from "react"; -import { User, Settings, Handshake } from "lucide-react"; +import { User, Settings, Handshake, FileText } from "lucide-react"; import { LearnIcon } from "@/components/icons/LearnIcon"; import { ChecklistIcon } from "@/components/icons/ChecklistIcon"; import { CompanionIcon } from "@/components/icons/CompanionIcon"; @@ -12,6 +12,10 @@ export interface NavItem { labelKey: string; href: string; icon: React.ComponentType<{ className?: string }>; + /** Shown in the desktop sidebar only, hidden from the mobile bottom nav + * (which is already at its 375px item ceiling). Used for web-first, + * width-hungry features like the split-screen Resume Builder. */ + desktopOnly?: boolean; } // Shared by the desktop left sidebar (Sidebar.tsx) and the mobile bottom nav @@ -25,6 +29,12 @@ export const MAIN_NAV: NavItem[] = [ { labelKey: "tabs.learn", href: "/learn", icon: LearnIcon }, { labelKey: "tabs.checklist", href: "/checklist", icon: ChecklistIcon }, { labelKey: "tabs.companion", href: "/companion", icon: CompanionIcon }, + { + labelKey: "tabs.resume", + href: "/resume", + icon: FileText, + desktopOnly: true, + }, { labelKey: "tabs.community", href: "/community", icon: CommunityIcon }, { labelKey: "tabs.resources", href: "/resources", icon: Handshake }, { labelKey: "tabs.social", href: "/home", icon: SocialIcon }, diff --git a/components/resume/ResumeChatColumn.tsx b/components/resume/ResumeChatColumn.tsx new file mode 100644 index 0000000..ffdcf8e --- /dev/null +++ b/components/resume/ResumeChatColumn.tsx @@ -0,0 +1,279 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { + ChevronDown, + FileText, + Plus, + Trash2, + PanelRight, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { cn, RTL_FLIP } from "@/lib/utils"; +import { ChatInput } from "@/components/companion/ChatInput"; +import { ResumeSuggestionChips } from "./ResumeSuggestionChips"; +import type { ResumeChatMessage, ResumeDraft, ResumeDraftSummary } from "@/types/resume"; + +function TypingIndicator() { + return ( +
+
+ {[0, 1, 2].map((i) => ( + + ))} +
+
+ ); +} + +function Bubble({ message }: { message: ResumeChatMessage }) { + if (message.role === "user") { + return ( +
+
+

{message.content}

+
+
+ ); + } + return ( +
+
+

+ {message.content} +

+
+
+ ); +} + +/** Compact drafts switcher in the chat header (avoids a third column). */ +function DraftsMenu({ + drafts, + activeId, + onSelect, + onNew, + onDelete, +}: { + drafts: ResumeDraftSummary[]; + activeId: string | null; + onSelect: (id: string) => void; + onNew: () => void; + onDelete: (id: string) => void; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const menuRef = useRef(null); + const active = drafts.find((d) => d.id === activeId); + + useEffect(() => { + if (!open) return; + function onDocClick(e: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", onDocClick); + return () => document.removeEventListener("mousedown", onDocClick); + }, [open]); + + return ( +
+ + + {open && ( +
+ +
    + {drafts.length === 0 && ( +
  • + {t("resume.noDrafts")} +
  • + )} + {drafts.map((d) => ( +
  • + + +
  • + ))} +
+
+ )} +
+ ); +} + +interface ResumeChatColumnProps { + draft: ResumeDraft | null; + drafts: ResumeDraftSummary[]; + activeId: string | null; + isTyping: boolean; + errorMessage: string | null; + remaining: number; + limitReached: boolean; + onSend: (text: string) => void; + onSelectDraft: (id: string) => void; + onNewDraft: () => void; + onDeleteDraft: (id: string) => void; + /** Mobile master/detail: is the chat the visible pane (vs the resume)? */ + mobileActive: boolean; + /** Mobile master/detail: reveal the resume pane. */ + onShowResume: () => void; +} + +export function ResumeChatColumn({ + draft, + drafts, + activeId, + isTyping, + errorMessage, + remaining, + limitReached, + onSend, + onSelectDraft, + onNewDraft, + onDeleteDraft, + mobileActive, + onShowResume, +}: ResumeChatColumnProps) { + const { t } = useTranslation(); + const [input, setInput] = useState(""); + const endRef = useRef(null); + const inputRef = useRef(null); + const messages = draft?.messages ?? []; + + useEffect(() => { + endRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages.length, isTyping]); + + function handleSend(text: string) { + onSend(text); + setInput(""); + } + + // Chips come from the latest assistant turn — shown only when we're waiting + // for the user (not mid-generation). Tapping fills the input for editing. + const lastMessage = messages[messages.length - 1]; + const activeSuggestions = + !isTyping && lastMessage?.role === "assistant" + ? (lastMessage.suggestions ?? []) + : []; + + function pickSuggestion(text: string) { + setInput(text); + inputRef.current?.focus(); + } + + return ( +
+
+ + +
+ +
+
+ {messages.map((m) => ( + + ))} + {isTyping && } +
+
+
+ +
+ {activeSuggestions.length > 0 && ( + + )} + {errorMessage && ( +

+ {errorMessage} +

+ )} + {limitReached ? ( +
+ {t("resume.limitReached")} +
+ ) : ( + + )} +

+ {t("resume.messagesRemaining", { count: remaining })} +

+
+
+ ); +} diff --git a/components/resume/ResumePanel.tsx b/components/resume/ResumePanel.tsx new file mode 100644 index 0000000..1fb8554 --- /dev/null +++ b/components/resume/ResumePanel.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { ArrowLeft, CheckCircle2, Download } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { cn, RTL_FLIP } from "@/lib/utils"; +import { ResumePaper } from "./ResumePaper"; +import type { ResumeData } from "@/types/resume"; + +interface ResumePanelProps { + data: ResumeData; + isEmpty: boolean; + complete: boolean; + /** Mobile master/detail: is the resume the visible pane (vs the chat)? */ + mobileActive: boolean; + onBackToChat: () => void; +} + +export function ResumePanel({ + data, + isEmpty, + complete, + mobileActive, + onBackToChat, +}: ResumePanelProps) { + const { t } = useTranslation(); + + // Isolated by the print stylesheet (globals.css @media print) to the + // .resume-paper node, so this exports selectable, ATS-friendly text. + function handleDownload() { + window.print(); + } + + return ( +
+ {/* Toolbar */} +
+
+ + + {t("resume.templateName")} + + {complete && ( + + + {t("resume.ready")} + + )} +
+ +
+ + {/* Scrollable paper */} +
+ {isEmpty && ( +

+ {t("resume.buildingHint")} +

+ )} + +
+
+ ); +} diff --git a/components/resume/ResumePaper.tsx b/components/resume/ResumePaper.tsx new file mode 100644 index 0000000..1099570 --- /dev/null +++ b/components/resume/ResumePaper.tsx @@ -0,0 +1,212 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import type { + ResumeData, + ResumeEducation, + ResumeExperience, + ResumeProject, +} from "@/types/resume"; + +/** + * Live-rendering resume in the "Jake's Resume" style: single column, serif type + * (Latin-Modern-like), centered contact header, small-caps section headings with + * a full-width rule, and 2-row entry headers (title/dates over subtitle/location). + * + * Renders straight from ResumeData — every section is omitted when empty, and + * entry rows degrade gracefully when a field the user never gave (employer, dates, + * location) is blank, so a partial resume never shows a dangling "@" or empty rule. + * + * The root carries `resume-paper`; a print stylesheet (globals.css) isolates that + * node so "Download PDF" (window.print) exports just the resume with real, + * selectable, ATS-friendly text — no rasterization. + */ + +const SERIF = "Georgia, 'Times New Roman', 'Nimbus Roman', serif"; + +function contactLine(data: ResumeData): string[] { + const { contact } = data; + return [ + contact.phone, + contact.email, + contact.location, + contact.linkedin, + contact.website, + ] + .map((s) => s.trim()) + .filter(Boolean); +} + +function SectionHeading({ label }: { label: string }) { + return ( +

+ {label} +

+ ); +} + +/** 2-row entry header; each cell is skipped when its value is empty. */ +function EntryHeader({ + primaryLeft, + primaryRight, + secondaryLeft, + secondaryRight, +}: { + primaryLeft: string; + primaryRight?: string; + secondaryLeft?: string; + secondaryRight?: string; +}) { + const hasSecondary = Boolean(secondaryLeft?.trim() || secondaryRight?.trim()); + return ( +
+
+ {primaryLeft} + {primaryRight?.trim() && ( + + {primaryRight} + + )} +
+ {hasSecondary && ( +
+ + {secondaryLeft} + + {secondaryRight?.trim() && ( + + {secondaryRight} + + )} +
+ )} +
+ ); +} + +function Bullets({ items }: { items: string[] }) { + const bullets = items.filter((b) => b.trim()); + if (bullets.length === 0) return null; + return ( +
    + {bullets.map((b, i) => ( +
  • + {b} +
  • + ))} +
+ ); +} + +function ExperienceEntry({ item }: { item: ResumeExperience }) { + return ( +
+ + +
+ ); +} + +function EducationEntry({ item }: { item: ResumeEducation }) { + return ( + + ); +} + +function ProjectEntry({ item }: { item: ResumeProject }) { + const heading = [item.name, item.tech].filter((s) => s.trim()).join(" | "); + return ( +
+ + +
+ ); +} + +export function ResumePaper({ data }: { data: ResumeData }) { + const { t } = useTranslation(); + const contacts = contactLine(data); + const name = data.contact.name.trim(); + + return ( +
+ {/* Header */} +
+

+ {name || t("resume.paper.yourName")} +

+ {contacts.length > 0 && ( +

+ {contacts.join(" | ")} +

+ )} +
+ + {data.summary.trim() && ( +
+ +

+ {data.summary} +

+
+ )} + + {data.education.length > 0 && ( +
+ + {data.education.map((e) => ( + + ))} +
+ )} + + {data.experience.length > 0 && ( +
+ + {data.experience.map((e) => ( + + ))} +
+ )} + + {data.projects.length > 0 && ( +
+ + {data.projects.map((p) => ( + + ))} +
+ )} + + {data.skills.length > 0 && ( +
+ +
+ {data.skills.map((s) => ( +

+ {s.category}:{" "} + {s.items.join(", ")} +

+ ))} +
+
+ )} +
+ ); +} diff --git a/components/resume/ResumeSuggestionChips.tsx b/components/resume/ResumeSuggestionChips.tsx new file mode 100644 index 0000000..0a4fba2 --- /dev/null +++ b/components/resume/ResumeSuggestionChips.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { Sparkles } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +/** + * Tappable example-answer chips. Unlike Companion's follow-up chips (which + * auto-send), these FILL the input with an editable suggestion so users with + * limited English can pick a starting point and tweak it before sending. + */ +export function ResumeSuggestionChips({ + suggestions, + onPick, +}: { + suggestions: string[]; + onPick: (text: string) => void; +}) { + const { t } = useTranslation(); + const items = suggestions.filter((s) => s.trim()); + if (items.length === 0) return null; + + return ( +
+

+ + {t("resume.suggestionsHint")} +

+
+ {items.map((s) => ( + + ))} +
+
+ ); +} diff --git a/hooks/useResume.ts b/hooks/useResume.ts new file mode 100644 index 0000000..a497962 --- /dev/null +++ b/hooks/useResume.ts @@ -0,0 +1,229 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import * as resume from "@/services/resume"; +import { CURRENT_USER_KEY } from "@/hooks/useProfile"; +import { + DEFAULT_LANGUAGE, + isSupportedLanguage, + type SupportedLanguage, +} from "@/lib/i18n/config"; +import type { UserProfile } from "@/types"; +import type { + ResumeChatMessage, + ResumeData, + ResumeDraft, + ResumeProfileContext, +} from "@/types/resume"; + +/** React Query hooks for the AI Resume Builder (local persistence). Mirrors the + * Companion hook shape: stable keys, optimistic send, onSuccess invalidation. */ + +const DRAFTS_KEY = ["resume-drafts"] as const; +const USAGE_KEY = ["resume-usage"] as const; + +export function draftKey(id: string) { + return ["resume-draft", id] as const; +} + +function resolveLanguage(lang: string): SupportedLanguage { + return isSupportedLanguage(lang) ? lang : DEFAULT_LANGUAGE; +} + +/** Build the per-turn personalization context from the cached current user. */ +function buildProfile( + user: UserProfile | undefined, + language: string, +): ResumeProfileContext { + const onb = user?.onboarding ?? null; + return { + firstName: onb?.firstName ?? null, + persona: onb?.persona ?? null, + stage: onb?.stage ?? null, + city: onb?.city ?? null, + province: onb?.province ?? null, + email: null, + responseLanguage: resolveLanguage(language), + }; +} + +function nowIso() { + return new Date().toISOString(); +} + +export function useResumeDrafts() { + return useQuery({ queryKey: DRAFTS_KEY, queryFn: resume.listDrafts }); +} + +export function useResumeDraft(id: string | null) { + return useQuery({ + queryKey: draftKey(id ?? ""), + queryFn: () => resume.getDraft(id as string), + enabled: !!id, + // Guard the optimistic user bubble from an immediate refetch (mirrors + // Companion's useConversationMessages staleTime). + staleTime: 30_000, + }); +} + +export function useResumeUsage() { + return useQuery({ queryKey: USAGE_KEY, queryFn: resume.getResumeUsage }); +} + +/** + * Create a new draft: prefill contact from the onboarding profile (name + + * city/province) and seed a localized opening message + example-answer chips, + * so the empty state is warm and instant with no model call. + */ +export function useCreateResumeDraft() { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + return useMutation({ + mutationFn: async () => { + const user = queryClient.getQueryData(CURRENT_USER_KEY); + const onb = user?.onboarding ?? null; + const name = onb?.firstName?.trim() ?? ""; + const location = [onb?.city, onb?.province].filter(Boolean).join(", "); + const opener: ResumeChatMessage = { + id: crypto.randomUUID(), + role: "assistant", + content: name + ? t("resume.opener.greetingNamed", { name }) + : t("resume.opener.greeting"), + suggestions: [ + t("resume.opener.suggestion1"), + t("resume.opener.suggestion2"), + t("resume.opener.suggestion3"), + ], + createdAt: nowIso(), + }; + const title = name + ? t("resume.draftTitleNamed", { name }) + : t("resume.untitled"); + const draft = resume.newDraft({ + title, + contact: { name, location }, + openerMessage: opener, + }); + return resume.saveDraft(draft); + }, + onSuccess: (draft) => { + queryClient.setQueryData(draftKey(draft.id), draft); + queryClient.invalidateQueries({ queryKey: DRAFTS_KEY }); + }, + }); +} + +/** A short human title derived from the resume so the drafts list stays scannable. */ +function deriveTitle( + data: ResumeData, + user: UserProfile | undefined, + fallback: string, +): string { + const job = data.experience[0]?.title?.trim(); + const name = user?.onboarding?.firstName?.trim(); + if (job && name) return `${name} — ${job}`; + if (job) return job; + return fallback; +} + +interface SendInput { + draftId: string; + text: string; +} + +/** + * Persist the user turn, ask DeepSeek (via /api/resume) for the structured next + * turn, then persist the assistant reply + the updated resume snapshot. + * Optimistically appends the user bubble; the user turn is saved BEFORE the + * model call, so a failed generation keeps their message (they can continue). + */ +export function useSendResumeMessage() { + const queryClient = useQueryClient(); + const { i18n } = useTranslation(); + return useMutation }>( + { + mutationFn: async ({ draftId, text }) => { + // Read from the persisted store, NOT the query cache: onMutate has + // already appended an optimistic user bubble to the cache, so reading + // the cache here would double-count it into the saved draft. + const draft = await resume.getDraft(draftId); + if (!draft) throw new Error("Draft not found"); + + const user = queryClient.getQueryData(CURRENT_USER_KEY); + const profile = buildProfile(user, i18n.language); + + const userMessage: ResumeChatMessage = { + id: crypto.randomUUID(), + role: "user", + content: text, + createdAt: nowIso(), + }; + const withUser: ResumeDraft = { + ...draft, + messages: [...draft.messages, userMessage], + }; + // Persist the user turn first so it survives a failed generation. + await resume.saveDraft(withUser); + queryClient.setQueryData(draftKey(draftId), withUser); + + const response = await resume.generateResumeTurn({ + history: draft.messages, + message: text, + currentResume: draft.resume, + profile, + }); + + const assistantMessage: ResumeChatMessage = { + id: crypto.randomUUID(), + role: "assistant", + content: response.reply, + suggestions: response.suggestions, + createdAt: nowIso(), + }; + const finalDraft: ResumeDraft = { + ...withUser, + messages: [...withUser.messages, assistantMessage], + resume: response.resume, + complete: response.complete, + title: deriveTitle(response.resume, user, draft.title), + }; + return resume.saveDraft(finalDraft); + }, + onMutate: async ({ draftId, text }) => { + const key = draftKey(draftId); + await queryClient.cancelQueries({ queryKey: key }); + const optimistic: ResumeChatMessage = { + id: `optimistic-${Date.now()}`, + role: "user", + content: text, + createdAt: nowIso(), + }; + queryClient.setQueryData(key, (prev) => + prev ? { ...prev, messages: [...prev.messages, optimistic] } : prev, + ); + return { key }; + }, + onError: (_err, _vars, context) => { + // The user turn was persisted; reconverge the cache to the stored state + // (keeps their message, drops the failed assistant turn). + if (context) queryClient.invalidateQueries({ queryKey: context.key }); + }, + onSuccess: (finalDraft) => { + queryClient.setQueryData(draftKey(finalDraft.id), finalDraft); + queryClient.invalidateQueries({ queryKey: DRAFTS_KEY }); + queryClient.invalidateQueries({ queryKey: USAGE_KEY }); + }, + }, + ); +} + +export function useDeleteResumeDraft() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id) => resume.deleteDraft(id), + onSuccess: (_data, id) => { + queryClient.removeQueries({ queryKey: draftKey(id) }); + queryClient.invalidateQueries({ queryKey: DRAFTS_KEY }); + }, + }); +} diff --git a/lib/i18n/locales/en/translation.json b/lib/i18n/locales/en/translation.json index 58c0706..1d57099 100644 --- a/lib/i18n/locales/en/translation.json +++ b/lib/i18n/locales/en/translation.json @@ -71,7 +71,8 @@ "companion": "Companion", "checklist": "Checklist", "learn": "Learn", - "resources": "Resources" + "resources": "Resources", + "resume": "Resume" }, "auth": { "signIn": "Sign in", @@ -1875,5 +1876,43 @@ "visitWelcomeCentre": "Visit the Welcome Centre" }, "referralDisclosure": "Referral partner — Unify may earn a fee when you use this link." + }, + "resume": { + "title": "Resume Builder", + "templateName": "Jake's Resume template", + "newResume": "New resume", + "noDrafts": "No resumes yet", + "deleteDraft": "Delete resume", + "viewResume": "View resume", + "backToChat": "Back to chat", + "downloadPdf": "Download PDF", + "ready": "Ready", + "untitled": "Untitled resume", + "draftTitleNamed": "{{name}}'s Resume", + "inputPlaceholder": "Type your answer...", + "suggestionsHint": "Tap to use, then edit if you like", + "messagesRemaining": "{{count}} messages left today", + "limitReached": "You have reached today's resume-builder limit. Please come back tomorrow to keep going.", + "limitReachedToast": "You have reached today's limit. Come back tomorrow to continue.", + "busy": "The resume assistant is busy. Please try again.", + "sendFailed": "Something went wrong. Please try again.", + "buildingHint": "Your resume builds here as you answer. The assistant fills it in for you.", + "paper": { + "yourName": "Your Name" + }, + "sections": { + "summary": "Summary", + "education": "Education", + "experience": "Experience", + "projects": "Projects", + "skills": "Skills" + }, + "opener": { + "greetingNamed": "Hi {{name}}! I'm your Unify resume coach. I'll ask a few simple questions and turn your answers into a clean, professional resume. To start: what kind of work have you done? Any job, part-time role, or volunteer work counts, even from your home country.", + "greeting": "Hi! I'm your Unify resume coach. I'll ask a few simple questions and turn your answers into a clean, professional resume. To start: what kind of work have you done? Any job, part-time role, or volunteer work counts, even from your home country.", + "suggestion1": "I worked as a cashier at a store", + "suggestion2": "I have not worked in Canada yet, I came to study", + "suggestion3": "I did volunteer work in my home country" + } } } diff --git a/lib/i18n/locales/es/translation.json b/lib/i18n/locales/es/translation.json index 4f95a73..37098e7 100644 --- a/lib/i18n/locales/es/translation.json +++ b/lib/i18n/locales/es/translation.json @@ -71,7 +71,8 @@ "companion": "Asistente", "checklist": "Lista", "learn": "Aprender", - "resources": "Recursos" + "resources": "Recursos", + "resume": "Currículum" }, "auth": { "signIn": "Iniciar sesión", @@ -1875,5 +1876,43 @@ "visitWelcomeCentre": "Visita el Centro de Bienvenida" }, "referralDisclosure": "Socio de referidos: Unify puede recibir una comisión cuando usas este enlace." + }, + "resume": { + "title": "Creador de currículum", + "templateName": "Plantilla Jake's Resume", + "newResume": "Nuevo currículum", + "noDrafts": "Aún no hay currículums", + "deleteDraft": "Eliminar currículum", + "viewResume": "Ver currículum", + "backToChat": "Volver al chat", + "downloadPdf": "Descargar PDF", + "ready": "Listo", + "untitled": "Currículum sin título", + "draftTitleNamed": "Currículum de {{name}}", + "inputPlaceholder": "Escribe tu respuesta...", + "suggestionsHint": "Toca para usar y luego edítalo si quieres", + "messagesRemaining": "{{count}} mensajes restantes hoy", + "limitReached": "Has alcanzado el límite del creador de currículum de hoy. Vuelve mañana para continuar.", + "limitReachedToast": "Has alcanzado el límite de hoy. Vuelve mañana para continuar.", + "busy": "El asistente de currículum está ocupado. Inténtalo de nuevo.", + "sendFailed": "Algo salió mal. Inténtalo de nuevo.", + "buildingHint": "Tu currículum se construye aquí mientras respondes. El asistente lo completa por ti.", + "paper": { + "yourName": "Tu nombre" + }, + "sections": { + "summary": "Resumen", + "education": "Educación", + "experience": "Experiencia", + "projects": "Proyectos", + "skills": "Habilidades" + }, + "opener": { + "greetingNamed": "¡Hola {{name}}! Soy tu asesor de currículum de Unify. Te haré algunas preguntas sencillas y convertiré tus respuestas en un currículum profesional y claro. Para empezar: ¿qué tipo de trabajo has hecho? Cuenta cualquier empleo, trabajo de medio tiempo o voluntariado, incluso de tu país de origen.", + "greeting": "¡Hola! Soy tu asesor de currículum de Unify. Te haré algunas preguntas sencillas y convertiré tus respuestas en un currículum profesional y claro. Para empezar: ¿qué tipo de trabajo has hecho? Cuenta cualquier empleo, trabajo de medio tiempo o voluntariado, incluso de tu país de origen.", + "suggestion1": "Trabajé como cajero en una tienda", + "suggestion2": "Todavía no he trabajado en Canadá, vine a estudiar", + "suggestion3": "Hice trabajo voluntario en mi país de origen" + } } } diff --git a/lib/i18n/locales/hi/translation.json b/lib/i18n/locales/hi/translation.json index e24d416..b6e002e 100644 --- a/lib/i18n/locales/hi/translation.json +++ b/lib/i18n/locales/hi/translation.json @@ -71,7 +71,8 @@ "companion": "सहायक", "checklist": "चेकलिस्ट", "learn": "सीखें", - "resources": "संसाधन" + "resources": "संसाधन", + "resume": "रिज़्यूमे" }, "auth": { "signIn": "साइन इन करें", @@ -1875,5 +1876,43 @@ "visitWelcomeCentre": "वेलकम सेंटर जाएँ" }, "referralDisclosure": "रेफ़रल पार्टनर — इस लिंक का उपयोग करने पर Unify को शुल्क मिल सकता है।" + }, + "resume": { + "title": "रिज़्यूमे बिल्डर", + "templateName": "Jake's Resume टेम्पलेट", + "newResume": "नया रिज़्यूमे", + "noDrafts": "अभी कोई रिज़्यूमे नहीं", + "deleteDraft": "रिज़्यूमे हटाएँ", + "viewResume": "रिज़्यूमे देखें", + "backToChat": "चैट पर वापस जाएँ", + "downloadPdf": "PDF डाउनलोड करें", + "ready": "तैयार", + "untitled": "बिना शीर्षक वाला रिज़्यूमे", + "draftTitleNamed": "{{name}} का रिज़्यूमे", + "inputPlaceholder": "अपना उत्तर लिखें...", + "suggestionsHint": "उपयोग करने के लिए टैप करें, फिर चाहें तो बदलें", + "messagesRemaining": "आज {{count}} संदेश बाकी", + "limitReached": "आप आज की रिज़्यूमे बिल्डर सीमा तक पहुँच गए हैं। जारी रखने के लिए कल फिर आएँ।", + "limitReachedToast": "आप आज की सीमा तक पहुँच गए हैं। जारी रखने के लिए कल फिर आएँ।", + "busy": "रिज़्यूमे सहायक व्यस्त है। कृपया फिर से प्रयास करें।", + "sendFailed": "कुछ गलत हो गया। कृपया फिर से प्रयास करें।", + "buildingHint": "जैसे-जैसे आप उत्तर देते हैं, आपका रिज़्यूमे यहाँ बनता है। सहायक इसे आपके लिए भरता है।", + "paper": { + "yourName": "आपका नाम" + }, + "sections": { + "summary": "सारांश", + "education": "शिक्षा", + "experience": "अनुभव", + "projects": "प्रोजेक्ट", + "skills": "कौशल" + }, + "opener": { + "greetingNamed": "नमस्ते {{name}}! मैं आपका Unify रिज़्यूमे कोच हूँ। मैं कुछ आसान सवाल पूछूँगा और आपके जवाबों को एक साफ़, पेशेवर रिज़्यूमे में बदल दूँगा। शुरू करते हैं: आपने किस तरह का काम किया है? कोई भी नौकरी, पार्ट-टाइम काम या स्वयंसेवा गिनी जाती है, चाहे वह आपके देश की ही क्यों न हो।", + "greeting": "नमस्ते! मैं आपका Unify रिज़्यूमे कोच हूँ। मैं कुछ आसान सवाल पूछूँगा और आपके जवाबों को एक साफ़, पेशेवर रिज़्यूमे में बदल दूँगा। शुरू करते हैं: आपने किस तरह का काम किया है? कोई भी नौकरी, पार्ट-टाइम काम या स्वयंसेवा गिनी जाती है, चाहे वह आपके देश की ही क्यों न हो।", + "suggestion1": "मैंने एक दुकान में कैशियर के रूप में काम किया", + "suggestion2": "मैंने अभी तक कनाडा में काम नहीं किया, मैं पढ़ने आया हूँ", + "suggestion3": "मैंने अपने देश में स्वयंसेवा का काम किया" + } } } diff --git a/lib/i18n/locales/vi/translation.json b/lib/i18n/locales/vi/translation.json index a59720c..bc26a58 100644 --- a/lib/i18n/locales/vi/translation.json +++ b/lib/i18n/locales/vi/translation.json @@ -71,7 +71,8 @@ "companion": "Trợ lý", "checklist": "Danh sách", "learn": "Học", - "resources": "Tài nguyên" + "resources": "Tài nguyên", + "resume": "Hồ sơ" }, "auth": { "signIn": "Đăng nhập", @@ -1875,5 +1876,43 @@ "visitWelcomeCentre": "Đến Trung tâm Chào đón" }, "referralDisclosure": "Đối tác giới thiệu — Unify có thể nhận phí khi bạn sử dụng liên kết này." + }, + "resume": { + "title": "Trình tạo hồ sơ", + "templateName": "Mẫu Jake's Resume", + "newResume": "Hồ sơ mới", + "noDrafts": "Chưa có hồ sơ nào", + "deleteDraft": "Xóa hồ sơ", + "viewResume": "Xem hồ sơ", + "backToChat": "Quay lại trò chuyện", + "downloadPdf": "Tải PDF", + "ready": "Sẵn sàng", + "untitled": "Hồ sơ chưa đặt tên", + "draftTitleNamed": "Hồ sơ của {{name}}", + "inputPlaceholder": "Nhập câu trả lời của bạn...", + "suggestionsHint": "Chạm để dùng, sau đó chỉnh sửa nếu muốn", + "messagesRemaining": "Còn {{count}} tin nhắn hôm nay", + "limitReached": "Bạn đã đạt giới hạn tạo hồ sơ hôm nay. Vui lòng quay lại vào ngày mai để tiếp tục.", + "limitReachedToast": "Bạn đã đạt giới hạn hôm nay. Quay lại vào ngày mai để tiếp tục.", + "busy": "Trợ lý hồ sơ đang bận. Vui lòng thử lại.", + "sendFailed": "Đã xảy ra lỗi. Vui lòng thử lại.", + "buildingHint": "Hồ sơ của bạn được tạo ở đây khi bạn trả lời. Trợ lý sẽ điền giúp bạn.", + "paper": { + "yourName": "Tên của bạn" + }, + "sections": { + "summary": "Tóm tắt", + "education": "Học vấn", + "experience": "Kinh nghiệm", + "projects": "Dự án", + "skills": "Kỹ năng" + }, + "opener": { + "greetingNamed": "Chào {{name}}! Tôi là trợ lý hồ sơ Unify của bạn. Tôi sẽ hỏi vài câu đơn giản và biến câu trả lời của bạn thành một hồ sơ chuyên nghiệp, gọn gàng. Để bắt đầu: bạn đã làm những công việc gì? Bất kỳ công việc nào, làm bán thời gian hay tình nguyện đều được, kể cả ở quê nhà.", + "greeting": "Xin chào! Tôi là trợ lý hồ sơ Unify của bạn. Tôi sẽ hỏi vài câu đơn giản và biến câu trả lời của bạn thành một hồ sơ chuyên nghiệp, gọn gàng. Để bắt đầu: bạn đã làm những công việc gì? Bất kỳ công việc nào, làm bán thời gian hay tình nguyện đều được, kể cả ở quê nhà.", + "suggestion1": "Tôi từng làm thu ngân ở một cửa hàng", + "suggestion2": "Tôi chưa đi làm ở Canada, tôi đến để học", + "suggestion3": "Tôi từng làm tình nguyện ở quê nhà" + } } } diff --git a/lib/resume/generateTurn.ts b/lib/resume/generateTurn.ts new file mode 100644 index 0000000..7f0619a --- /dev/null +++ b/lib/resume/generateTurn.ts @@ -0,0 +1,129 @@ +/** + * Server-side (Node) turn generator for the resume builder. Calls OpenRouter + * directly — pinned to deepseek/deepseek-v4-flash — using the shared prompt in + * ./prompt.ts, then parses + normalizes the structured JSON reply. + * + * This is the LOCAL PROTOTYPE execution path used by app/api/resume/route.ts. + * The production form is the resume-chat Supabase edge function (Deno), which + * uses _shared/openrouter.ts and the same prompt text. This module deliberately + * mirrors that logic in Node so the prototype runs with no Docker / + * `functions serve` (the OPENROUTER_API_KEY lives in .env.local for local dev). + */ + +import { buildTurnMessages, parseTurnResponse } from "./prompt"; +import { normalizeResumeData } from "./schema"; +import type { ResumeTurnRequest, ResumeTurnResponse } from "@/types/resume"; + +const ENDPOINT = "https://openrouter.ai/api/v1/chat/completions"; +/** Savar's explicit model choice for the resume builder. */ +const MODEL = "deepseek/deepseek-v4-flash"; +const TIMEOUT_MS = 45_000; + +export class ResumeUpstreamError extends Error { + status: number; + /** True for 429 / 5xx — the client can offer a retry rather than a hard fail. */ + retryable: boolean; + constructor(message: string, status: number, retryable: boolean) { + super(message); + this.name = "ResumeUpstreamError"; + this.status = status; + this.retryable = retryable; + } +} + +export async function generateResumeTurn( + req: ResumeTurnRequest, +): Promise { + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) { + throw new ResumeUpstreamError( + "OPENROUTER_API_KEY not set (add it to .env.local for the local prototype)", + 500, + false, + ); + } + + const messages = buildTurnMessages({ + profile: req.profile, + history: req.history, + currentResume: req.currentResume, + message: req.message, + }); + + let response: Response; + try { + response = await fetch(ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + "X-Title": "Unify Resume Builder", + }, + body: JSON.stringify({ + model: MODEL, + models: [MODEL], + messages, + response_format: { type: "json_object" }, + temperature: 0.5, + max_tokens: 2400, + usage: { include: true }, + }), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + } catch (error) { + const aborted = + error instanceof DOMException && error.name === "TimeoutError"; + throw new ResumeUpstreamError( + aborted ? "OpenRouter request timed out" : "OpenRouter request failed", + aborted ? 504 : 502, + true, + ); + } + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new ResumeUpstreamError( + `OpenRouter ${response.status}: ${body.slice(0, 300)}`, + response.status, + response.status === 429 || response.status >= 500, + ); + } + + const data = (await response.json().catch(() => null)) as { + choices?: { message?: { content?: unknown } }[]; + } | null; + const content = + typeof data?.choices?.[0]?.message?.content === "string" + ? (data.choices[0].message.content as string) + : ""; + + const parsed = parseTurnResponse(content); + if (!parsed || !parsed.reply) { + // Prose fallback: despite json-mode, a turn occasionally comes back as a + // plain conversational sentence with no JSON. Rather than fail the turn, + // show it as the assistant's reply and leave the resume unchanged this turn + // (the user's next answer re-anchors the model on the JSON contract). + const prose = content.trim(); + if (prose && prose.length <= 1500 && !prose.includes("{")) { + return { + reply: prose, + suggestions: [], + resume: normalizeResumeData(req.currentResume), + complete: false, + }; + } + // Empty, or a malformed JSON blob — treat as retryable ("try again"). + throw new ResumeUpstreamError( + "The model returned an unexpected response", + 502, + true, + ); + } + + return { + reply: parsed.reply, + suggestions: parsed.suggestions, + resume: normalizeResumeData(parsed.resume), + complete: parsed.complete, + }; +} diff --git a/lib/resume/prompt.ts b/lib/resume/prompt.ts new file mode 100644 index 0000000..a71ff3c --- /dev/null +++ b/lib/resume/prompt.ts @@ -0,0 +1,209 @@ +/** + * The resume-builder conversation brain: the DeepSeek system prompt + the + * per-turn message assembly + the response parser. + * + * This is the canonical, iterated version used by the live local path + * (app/api/resume/route.ts). The resume-chat edge function carries a Deno port + * of the SAME prompt text — keep the two in sync (see supabase/functions/ + * resume-chat/index.ts). + * + * Design: + * - The model returns STRICT JSON each turn: { reply, suggestions, resume, + * complete }. It returns the COMPLETE resume every turn (not a patch), + * preserving prior fields, so the panel always renders from a full snapshot. + * - `reply` + `suggestions` are in the user's UI language; resume field VALUES + * stay in English (the target Canadian job market). This lets a low-English + * user be coached in their language while producing an English resume. + * - The model must never invent employers, dates, numbers, or skills — only + * shape what the user actually said into resume-quality text. + */ + +import type { ResumeData, ResumeProfileContext } from "@/types/resume"; + +export interface PromptMessage { + role: "system" | "user" | "assistant"; + content: string; +} + +const LANGUAGE_NAMES: Record = { + en: "English", + es: "Spanish", + hi: "Hindi", + vi: "Vietnamese", + ar: "Arabic", + "fr-CA": "Canadian French", +}; + +const PERSONA_HINT: Record = { + international_student: + "an international student — likely light on Canadian work history; lean on studies, campus involvement, internships, part-time jobs, and projects.", + skilled_worker: + "a skilled worker with professional experience abroad — focus on translating that experience for Canadian employers.", + refugee: + "a refugee or protected person — be especially patient and encouraging; prior work may be informal, interrupted, or hard to document. Value transferable and informal experience.", + other: + "a newcomer to Canada — keep questions general and adapt to whatever background they share.", +}; + +function stageHint(stage: number | null): string { + if (stage === 0) return "They have not arrived in Canada yet."; + if (stage === 1) return "They arrived very recently (under 3 months ago)."; + if (stage === 2) return "They have been in Canada 3–12 months."; + if (stage === 3) return "They have been in Canada 1–3 years."; + if (stage === 4) return "They have been in Canada 3+ years."; + return ""; +} + +/** The JSON contract, embedded verbatim so the model mirrors the exact shape. */ +const SCHEMA_BLOCK = `Return ONLY a single JSON object (no markdown, no code fences, no text before or after) with EXACTLY these keys: + +{ + "reply": "string — your short conversational message to the user (1–4 sentences). Ask ONE question, or acknowledge + confirm.", + "suggestions": ["string", "string", "string"], + "complete": false, + "resume": { + "contact": { "name": "", "email": "", "phone": "", "location": "", "linkedin": "", "website": "" }, + "summary": "", + "education": [ { "institution": "", "location": "", "degree": "", "dates": "" } ], + "experience": [ { "title": "", "organization": "", "location": "", "dates": "", "bullets": ["", ""] } ], + "projects": [ { "name": "", "tech": "", "dates": "", "bullets": ["", ""] } ], + "skills": [ { "category": "", "items": ["", ""] } ] + } +}`; + +export function buildSystemPrompt(profile: ResumeProfileContext): string { + const langName = LANGUAGE_NAMES[profile.responseLanguage] ?? "English"; + const persona = profile.persona + ? PERSONA_HINT[profile.persona] ?? PERSONA_HINT.other + : PERSONA_HINT.other; + const name = profile.firstName?.trim(); + const place = [profile.city, profile.province].filter(Boolean).join(", "); + + const contextLines = [ + name ? `The user's first name is ${name}.` : "", + `They are ${persona}`, + stageHint(profile.stage), + place ? `They are settling in ${place}, Canada.` : "", + ] + .filter(Boolean) + .map((l) => `- ${l}`) + .join("\n"); + + return `You are Unify's Resume Coach — a warm, patient career helper who builds a clean, professional resume WITH a newcomer to Canada through natural conversation. You are not a form. You talk like a supportive human, one question at a time. + +# Who you're helping +${contextLines} + +# Your goal +Interview the user about their background and turn their answers into a polished, ATS-friendly resume in the "Jake's Resume" style (single column: Contact, optional Summary, Education, Experience, Projects, Skills). You do the hard part — the user gives you plain, everyday answers and YOU rewrite them into strong resume language. + +# How to converse +- Ask ONE clear question at a time. Keep it short and use plain, simple words (many users are still learning English). +- Be warm and encouraging. Never lecture or overwhelm. React briefly to what they said before asking the next thing. +- Go roughly in this order, but follow the user: (1) most recent / most important job, (2) earlier jobs, (3) education, (4) skills / languages / certifications, (5) projects or volunteer work if relevant, (6) a short summary line, (7) any missing contact info (phone, LinkedIn). Skip what clearly doesn't apply. +- If an answer is vague about WHAT THEY DID, gently probe for the actual tasks with a concrete example. Example: they say "I worked at a store" → "Nice! What did you do there day to day — like helping customers, using the cash register, or stocking shelves?" +- Accept answers in any language and in any grammar. Understand the meaning; never criticize their English. + +# Don't get stuck (VERY IMPORTANT — read carefully) +- Ask for any single missing FACT (employer name, exact dates, city, school name) AT MOST ONCE. If the user doesn't give it, or answers something else, ACCEPT the entry as-is (leave that field ""), and move on. A partial entry with a blank employer is completely fine — a blank field is far better than a frustrated user. +- NEVER ask for the same piece of information twice. If you already asked for the company name and didn't get it, do not ask again — move forward. +- ALWAYS follow the user's lead. If they volunteer new information (a different job, their studies, a skill) while you were waiting on a detail, capture that new information and continue from there. Do not drag them back to an unfinished detail. +- When the user signals they are finished ("that's all", "no more", "I'm done", "eso es todo"), do NOT ask another question. Finalize immediately with "complete": true. + +# Writing the resume (the value you add) +- Convert casual answers into strong, concise bullet points: start with a past-tense action verb, be specific, and quantify ONLY when the user gave a number. Example: "i helped customers and used the till" → "Delivered friendly customer service and processed cash and card payments accurately". +- NEVER invent facts. Do not fabricate employers, job titles, dates, numbers, achievements, schools, or skills. Only include what the user actually told you or clearly implied. If you don't know a field yet, leave it "" (or []). Empty is always better than made-up. +- Write ALL resume content in English, because this resume is for Canadian employers — even when you are talking to the user in ${langName}. Translate job titles ("asistente administrativa" → "Administrative Assistant"), degrees ("licenciatura en administración" → "Bachelor of Business Administration"), skill names, categories, and every bullet point into natural English. Keep real proper names (a specific company or school) as the user gave them. ONLY the "reply" and "suggestions" fields stay in ${langName}. +- Preserve everything captured so far. Each turn, return the COMPLETE resume with all prior fields intact plus any updates from the latest answer. +- Prefill: some contact fields may already be filled in the current resume — keep them; don't re-ask for what's already there. It's fine to ask once, near the end, for a phone number (and optionally LinkedIn) if still blank — but don't push if the user skips it. + +# Suggestions (tappable example answers) +- Provide 2–3 short example answers to the QUESTION YOU JUST ASKED — realistic things THIS user might tap and lightly edit, so users who struggle to type still make progress. +- Make them specific to the user's situation and the current question — never generic filler like "Yes"/"No"/"I don't know". If you asked about a cashier's duties, good suggestions are "Handled cash and card payments", "Helped customers find products", "Kept shelves stocked and tidy". +- Write suggestions in ${langName}. +- If suggestions don't make sense for the current question (e.g. asking for their name or phone), return an empty array. + +# Finishing +- When you have at least one work OR education entry plus some skills, and the user has nothing more to add, set "complete": true, briefly congratulate them, and invite them to refine any section or export the PDF. + +# Output format +${SCHEMA_BLOCK} + +Reply to the user in ${langName}. Output ONLY the JSON object.`; +} + +/** + * Build the full message array for one turn: system prompt, prior turns, a + * system snapshot of the current resume JSON (so the model always sees the + * latest structured state), then the new user message. + */ +export function buildTurnMessages(args: { + profile: ResumeProfileContext; + history: { role: "user" | "assistant"; content: string }[]; + currentResume: ResumeData; + message: string; +}): PromptMessage[] { + const { profile, history, currentResume, message } = args; + const messages: PromptMessage[] = [ + { role: "system", content: buildSystemPrompt(profile) }, + ]; + for (const turn of history) { + messages.push({ role: turn.role, content: turn.content }); + } + messages.push({ + role: "system", + content: `CURRENT_RESUME_JSON (the resume so far — preserve every non-empty field, then apply the user's next answer):\n${JSON.stringify( + currentResume, + )}`, + }); + messages.push({ role: "user", content: message }); + return messages; +} + +export interface ParsedTurn { + reply: string; + suggestions: string[]; + resume: unknown; + complete: boolean; +} + +/** + * Extract the JSON object from the model's raw completion. Tolerant of stray + * code fences or leading/trailing prose: falls back to the first `{`…last `}` + * slice. Returns null when no JSON object can be recovered. + */ +export function parseTurnResponse(raw: string): ParsedTurn | null { + const parsed = extractJsonObject(raw); + if (!parsed) return null; + const obj = parsed as Record; + const reply = typeof obj.reply === "string" ? obj.reply.trim() : ""; + const suggestions = Array.isArray(obj.suggestions) + ? obj.suggestions + .filter((s): s is string => typeof s === "string") + .map((s) => s.trim()) + .filter(Boolean) + .slice(0, 3) + : []; + const complete = obj.complete === true; + return { reply, suggestions, resume: obj.resume ?? {}, complete }; +} + +function extractJsonObject(raw: string): unknown { + const trimmed = raw.trim(); + // Strip a ```json … ``` fence if present. + const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i); + const candidate = fenced ? fenced[1].trim() : trimmed; + try { + return JSON.parse(candidate); + } catch { + // Fall back to the widest {...} span. + const first = candidate.indexOf("{"); + const last = candidate.lastIndexOf("}"); + if (first === -1 || last <= first) return null; + try { + return JSON.parse(candidate.slice(first, last + 1)); + } catch { + return null; + } + } +} diff --git a/lib/resume/schema.ts b/lib/resume/schema.ts new file mode 100644 index 0000000..8a78796 --- /dev/null +++ b/lib/resume/schema.ts @@ -0,0 +1,166 @@ +/** + * Resume data shaping + validation, shared by the browser (services/resume.ts), + * the Next API route (app/api/resume/route.ts), and — in ported form — the + * resume-chat edge function. + * + * The model is asked for strict JSON, but LLM output is never fully trusted: + * `normalizeResumeData` coerces whatever comes back into a well-formed + * `ResumeData`, assigns stable ids, and bounds array/bullet sizes so a + * hallucinated 50-bullet entry can't blow up the UI or the next prompt. + * + * No runtime-specific APIs beyond `crypto.randomUUID` (present in Node 20+, + * Deno, and browsers) so this file stays portable. + */ + +import type { + ResumeData, + ResumeEducation, + ResumeExperience, + ResumeProject, + ResumeSkillCategory, +} from "@/types/resume"; + +/** Per-day cap on resume-builder messages (localStorage-tracked). Deliberately + * far higher than Companion's 6/day chatbot quota — a full resume is 20+ turns, + * so that cap is unusable here. Sized to allow a couple of full resumes/day. */ +export const RESUME_DAILY_MESSAGE_LIMIT = 60; + +/** Max characters accepted for a single user message (bounds prompt cost). */ +export const MAX_RESUME_MESSAGE_LEN = 2000; + +/** How many prior turns to send back to the model (bounds prompt size). */ +export const RESUME_HISTORY_TURNS = 12; + +// Size caps — generous but bounded, applied during normalization. +const MAX_ENTRIES = 12; +const MAX_BULLETS = 10; +const MAX_SKILL_CATEGORIES = 8; +const MAX_SKILL_ITEMS = 20; +const MAX_FIELD_LEN = 400; +const MAX_BULLET_LEN = 400; + +function str(value: unknown, max = MAX_FIELD_LEN): string { + if (typeof value !== "string") return ""; + return value.trim().slice(0, max); +} + +function strArray(value: unknown, maxItems: number, maxLen: number): string[] { + if (!Array.isArray(value)) return []; + const out: string[] = []; + for (const item of value) { + const s = str(item, maxLen); + if (s) out.push(s); + if (out.length >= maxItems) break; + } + return out; +} + +function newId(): string { + return crypto.randomUUID(); +} + +/** A blank resume, optionally seeded with prefilled contact fields. */ +export function emptyResume(contact?: Partial): ResumeData { + return { + contact: { + name: contact?.name ?? "", + email: contact?.email ?? "", + phone: contact?.phone ?? "", + location: contact?.location ?? "", + linkedin: contact?.linkedin ?? "", + website: contact?.website ?? "", + }, + summary: "", + education: [], + experience: [], + projects: [], + skills: [], + }; +} + +function normalizeEducation(value: unknown): ResumeEducation[] { + if (!Array.isArray(value)) return []; + return value.slice(0, MAX_ENTRIES).map((raw) => { + const r = (raw ?? {}) as Record; + return { + id: str(r.id) || newId(), + institution: str(r.institution), + location: str(r.location), + degree: str(r.degree), + dates: str(r.dates), + }; + }); +} + +function normalizeExperience(value: unknown): ResumeExperience[] { + if (!Array.isArray(value)) return []; + return value.slice(0, MAX_ENTRIES).map((raw) => { + const r = (raw ?? {}) as Record; + return { + id: str(r.id) || newId(), + title: str(r.title), + organization: str(r.organization), + location: str(r.location), + dates: str(r.dates), + bullets: strArray(r.bullets, MAX_BULLETS, MAX_BULLET_LEN), + }; + }); +} + +function normalizeProjects(value: unknown): ResumeProject[] { + if (!Array.isArray(value)) return []; + return value.slice(0, MAX_ENTRIES).map((raw) => { + const r = (raw ?? {}) as Record; + return { + id: str(r.id) || newId(), + name: str(r.name), + tech: str(r.tech), + dates: str(r.dates), + bullets: strArray(r.bullets, MAX_BULLETS, MAX_BULLET_LEN), + }; + }); +} + +function normalizeSkills(value: unknown): ResumeSkillCategory[] { + if (!Array.isArray(value)) return []; + return value.slice(0, MAX_SKILL_CATEGORIES).map((raw) => { + const r = (raw ?? {}) as Record; + return { + id: str(r.id) || newId(), + category: str(r.category, 120), + items: strArray(r.items, MAX_SKILL_ITEMS, 120), + }; + }); +} + +/** Coerce arbitrary (model-produced) input into a well-formed ResumeData. */ +export function normalizeResumeData(value: unknown): ResumeData { + const v = (value ?? {}) as Record; + const contact = (v.contact ?? {}) as Record; + return { + contact: { + name: str(contact.name, 120), + email: str(contact.email, 160), + phone: str(contact.phone, 60), + location: str(contact.location, 120), + linkedin: str(contact.linkedin, 200), + website: str(contact.website, 200), + }, + summary: str(v.summary, 600), + education: normalizeEducation(v.education), + experience: normalizeExperience(v.experience), + projects: normalizeProjects(v.projects), + skills: normalizeSkills(v.skills), + }; +} + +/** True when the resume has no substantive content yet (only maybe contact). */ +export function isResumeEmpty(resume: ResumeData): boolean { + return ( + resume.experience.length === 0 && + resume.education.length === 0 && + resume.projects.length === 0 && + resume.skills.length === 0 && + !resume.summary.trim() + ); +} diff --git a/services/resume.ts b/services/resume.ts new file mode 100644 index 0000000..ae926c8 --- /dev/null +++ b/services/resume.ts @@ -0,0 +1,213 @@ +/** + * AI Resume Builder data layer — LOCAL ONLY for the prototype. + * + * Drafts, chat transcripts, and the daily rate-limit counter all live in + * localStorage (no Supabase table — a shared-DB schema change needs Savar's + * sign-off). The shape here mirrors what a future `resume_drafts` table + + * `resume_usage` quota would hold, so the migration is a swap of these read/ + * write bodies for Supabase calls — the hooks/components above don't change. + * + * The one network call is generateResumeTurn → POST /api/resume (the DeepSeek + * turn). Everything else is synchronous localStorage access, wrapped in async + * signatures so the React Query hooks read identically to the Companion ones. + */ + +import { + RESUME_DAILY_MESSAGE_LIMIT, + RESUME_HISTORY_TURNS, + emptyResume, +} from "@/lib/resume/schema"; +import type { + ResumeChatMessage, + ResumeData, + ResumeDraft, + ResumeDraftSummary, + ResumeProfileContext, + ResumeTurnResponse, +} from "@/types/resume"; + +const DRAFTS_KEY = "unify_resume_drafts_v1"; +const USAGE_KEY = "unify_resume_usage_v1"; + +/** Raised when the daily resume-message cap is hit (client-enforced). */ +export class ResumeLimitError extends Error { + constructor() { + super("Daily resume-builder limit reached"); + this.name = "ResumeLimitError"; + } +} + +/** Raised when the assistant is temporarily unavailable (upstream 5xx/timeout). */ +export class ResumeBusyError extends Error { + constructor() { + super("The resume assistant is busy"); + this.name = "ResumeBusyError"; + } +} + +function hasStorage(): boolean { + return typeof window !== "undefined" && !!window.localStorage; +} + +function readDrafts(): ResumeDraft[] { + if (!hasStorage()) return []; + try { + const raw = window.localStorage.getItem(DRAFTS_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as ResumeDraft[]) : []; + } catch { + return []; + } +} + +function writeDrafts(drafts: ResumeDraft[]): void { + if (!hasStorage()) return; + try { + window.localStorage.setItem(DRAFTS_KEY, JSON.stringify(drafts)); + } catch { + // Quota / private-mode failures are non-fatal for a prototype. + } +} + +/** Newest-first list of lightweight draft rows for the sidebar. */ +export async function listDrafts(): Promise { + return readDrafts() + .slice() + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + .map((d) => ({ + id: d.id, + title: d.title, + updatedAt: d.updatedAt, + complete: d.complete, + })); +} + +export async function getDraft(id: string): Promise { + return readDrafts().find((d) => d.id === id) ?? null; +} + +/** Persist a full draft (create or replace), stamping updatedAt. */ +export async function saveDraft(draft: ResumeDraft): Promise { + const stamped = { ...draft, updatedAt: new Date().toISOString() }; + const drafts = readDrafts(); + const idx = drafts.findIndex((d) => d.id === stamped.id); + if (idx === -1) drafts.push(stamped); + else drafts[idx] = stamped; + writeDrafts(drafts); + return stamped; +} + +export async function deleteDraft(id: string): Promise { + writeDrafts(readDrafts().filter((d) => d.id !== id)); +} + +/** Build a brand-new draft. The opener message + prefilled contact are composed + * by the caller (the hook) so localization stays in the component layer. */ +export function newDraft(args: { + title: string; + contact: Partial; + openerMessage: ResumeChatMessage; +}): ResumeDraft { + const now = new Date().toISOString(); + return { + id: crypto.randomUUID(), + title: args.title, + createdAt: now, + updatedAt: now, + resume: emptyResume(args.contact), + messages: [args.openerMessage], + complete: false, + }; +} + +/* ----- Rate limit (per calendar day, local) ------------------------------- */ + +interface UsageRecord { + date: string; // YYYY-MM-DD + count: number; +} + +function today(): string { + return new Date().toISOString().slice(0, 10); +} + +function readUsage(): UsageRecord { + if (!hasStorage()) return { date: today(), count: 0 }; + try { + const raw = window.localStorage.getItem(USAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw) as UsageRecord; + if (parsed?.date === today()) return parsed; + } + } catch { + // fall through to a fresh record + } + return { date: today(), count: 0 }; +} + +export async function getResumeUsage(): Promise<{ + count: number; + remaining: number; +}> { + const { count } = readUsage(); + return { count, remaining: Math.max(0, RESUME_DAILY_MESSAGE_LIMIT - count) }; +} + +function incrementUsage(): void { + if (!hasStorage()) return; + const usage = readUsage(); + const next: UsageRecord = { date: usage.date, count: usage.count + 1 }; + try { + window.localStorage.setItem(USAGE_KEY, JSON.stringify(next)); + } catch { + // non-fatal + } +} + +/* ----- The one network call: a DeepSeek turn ------------------------------ */ + +export async function generateResumeTurn(args: { + history: ResumeChatMessage[]; + message: string; + currentResume: ResumeData; + profile: ResumeProfileContext; +}): Promise { + // Client-side daily cap — checked before spending an OpenRouter call. + const { remaining } = await getResumeUsage(); + if (remaining <= 0) throw new ResumeLimitError(); + + const history = args.history + .slice(-RESUME_HISTORY_TURNS) + .map((m) => ({ role: m.role, content: m.content })); + + const res = await fetch("/api/resume", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: args.message, + history, + currentResume: args.currentResume, + profile: args.profile, + }), + }); + + if (!res.ok) { + // 503 = upstream busy/timeout (retryable); surface a distinct busy error so + // the UI can say "try again" rather than a generic failure. + if (res.status === 503) throw new ResumeBusyError(); + let message = "Failed to generate a reply."; + try { + const body = (await res.json()) as { error?: string }; + if (body?.error) message = body.error; + } catch { + // keep the generic message + } + throw new Error(message); + } + + const data = (await res.json()) as ResumeTurnResponse; + // Count a successful turn against the daily cap. + incrementUsage(); + return data; +} diff --git a/supabase/functions/resume-chat/index.ts b/supabase/functions/resume-chat/index.ts new file mode 100644 index 0000000..334a16c --- /dev/null +++ b/supabase/functions/resume-chat/index.ts @@ -0,0 +1,394 @@ +// @ts-nocheck Deno runtime — Supabase Edge Functions +/** + * resume-chat — the AI Resume Builder turn generator (web-only feature). + * + * One conversational turn: given the transcript so far + the resume built so + * far, returns strict JSON { reply, suggestions, resume, complete } from + * DeepSeek (pinned deepseek/deepseek-v4-flash) via the shared OpenRouter helper. + * + * DEPLOY STATUS: NOT deployed to the shared project yet. The web prototype runs + * the identical logic in-process at app/api/resume/route.ts (Node) so it works + * with no Docker / functions-serve. This function is the production form, ready + * to deploy once Savar signs off on adding a web-only function to shared infra + * (and a real per-user quota RPC replaces the prototype's local rate limit). + * + * The prompt text + JSON contract + normalization here MIRROR + * lib/resume/prompt.ts and lib/resume/schema.ts — keep the two in sync. + */ +import 'jsr:@supabase/functions-js/edge-runtime.d.ts'; +import { createClient } from 'jsr:@supabase/supabase-js@2'; +import { callOpenRouter } from '../_shared/openrouter.ts'; +import { captureAiGeneration } from '../_shared/posthogCapture.ts'; + +const SUPABASE_URL = Deno.env.get('SUPABASE_URL'); +const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY'); + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': + 'authorization, x-client-info, apikey, content-type', + 'Content-Type': 'application/json', +}; + +function jsonResponse(body: Record, status = 200) { + return new Response(JSON.stringify(body), { status, headers: corsHeaders }); +} + +// --------------------------------------------------------------------------- +// Prompt (mirrors lib/resume/prompt.ts) +// --------------------------------------------------------------------------- + +const LANGUAGE_NAMES: Record = { + en: 'English', + es: 'Spanish', + hi: 'Hindi', + vi: 'Vietnamese', + ar: 'Arabic', + 'fr-CA': 'Canadian French', +}; + +const PERSONA_HINT: Record = { + international_student: + 'an international student — likely light on Canadian work history; lean on studies, campus involvement, internships, part-time jobs, and projects.', + skilled_worker: + 'a skilled worker with professional experience abroad — focus on translating that experience for Canadian employers.', + refugee: + 'a refugee or protected person — be especially patient and encouraging; prior work may be informal, interrupted, or hard to document. Value transferable and informal experience.', + other: + 'a newcomer to Canada — keep questions general and adapt to whatever background they share.', +}; + +function stageHint(stage: number | null): string { + if (stage === 0) return 'They have not arrived in Canada yet.'; + if (stage === 1) return 'They arrived very recently (under 3 months ago).'; + if (stage === 2) return 'They have been in Canada 3–12 months.'; + if (stage === 3) return 'They have been in Canada 1–3 years.'; + if (stage === 4) return 'They have been in Canada 3+ years.'; + return ''; +} + +const SCHEMA_BLOCK = `Return ONLY a single JSON object (no markdown, no code fences, no text before or after) with EXACTLY these keys: + +{ + "reply": "string — your short conversational message to the user (1–4 sentences). Ask ONE question, or acknowledge + confirm.", + "suggestions": ["string", "string", "string"], + "complete": false, + "resume": { + "contact": { "name": "", "email": "", "phone": "", "location": "", "linkedin": "", "website": "" }, + "summary": "", + "education": [ { "institution": "", "location": "", "degree": "", "dates": "" } ], + "experience": [ { "title": "", "organization": "", "location": "", "dates": "", "bullets": ["", ""] } ], + "projects": [ { "name": "", "tech": "", "dates": "", "bullets": ["", ""] } ], + "skills": [ { "category": "", "items": ["", ""] } ] + } +}`; + +function buildSystemPrompt(profile): string { + const langName = LANGUAGE_NAMES[profile.responseLanguage] ?? 'English'; + const persona = profile.persona + ? PERSONA_HINT[profile.persona] ?? PERSONA_HINT.other + : PERSONA_HINT.other; + const name = profile.firstName ? String(profile.firstName).trim() : ''; + const place = [profile.city, profile.province].filter(Boolean).join(', '); + + const contextLines = [ + name ? `The user's first name is ${name}.` : '', + `They are ${persona}`, + stageHint(profile.stage ?? null), + place ? `They are settling in ${place}, Canada.` : '', + ] + .filter(Boolean) + .map(l => `- ${l}`) + .join('\n'); + + return `You are Unify's Resume Coach — a warm, patient career helper who builds a clean, professional resume WITH a newcomer to Canada through natural conversation. You are not a form. You talk like a supportive human, one question at a time. + +# Who you're helping +${contextLines} + +# Your goal +Interview the user about their background and turn their answers into a polished, ATS-friendly resume in the "Jake's Resume" style (single column: Contact, optional Summary, Education, Experience, Projects, Skills). You do the hard part — the user gives you plain, everyday answers and YOU rewrite them into strong resume language. + +# How to converse +- Ask ONE clear question at a time. Keep it short and use plain, simple words (many users are still learning English). +- Be warm and encouraging. Never lecture or overwhelm. React briefly to what they said before asking the next thing. +- Go roughly in this order, but follow the user: (1) most recent / most important job, (2) earlier jobs, (3) education, (4) skills / languages / certifications, (5) projects or volunteer work if relevant, (6) a short summary line, (7) any missing contact info (phone, LinkedIn). Skip what clearly doesn't apply. +- If an answer is vague about WHAT THEY DID, gently probe for the actual tasks with a concrete example. Example: they say "I worked at a store" → "Nice! What did you do there day to day — like helping customers, using the cash register, or stocking shelves?" +- Accept answers in any language and in any grammar. Understand the meaning; never criticize their English. + +# Don't get stuck (VERY IMPORTANT — read carefully) +- Ask for any single missing FACT (employer name, exact dates, city, school name) AT MOST ONCE. If the user doesn't give it, or answers something else, ACCEPT the entry as-is (leave that field ""), and move on. A partial entry with a blank employer is completely fine — a blank field is far better than a frustrated user. +- NEVER ask for the same piece of information twice. If you already asked for the company name and didn't get it, do not ask again — move forward. +- ALWAYS follow the user's lead. If they volunteer new information (a different job, their studies, a skill) while you were waiting on a detail, capture that new information and continue from there. Do not drag them back to an unfinished detail. +- When the user signals they are finished ("that's all", "no more", "I'm done", "eso es todo"), do NOT ask another question. Finalize immediately with "complete": true. + +# Writing the resume (the value you add) +- Convert casual answers into strong, concise bullet points: start with a past-tense action verb, be specific, and quantify ONLY when the user gave a number. Example: "i helped customers and used the till" → "Delivered friendly customer service and processed cash and card payments accurately". +- NEVER invent facts. Do not fabricate employers, job titles, dates, numbers, achievements, schools, or skills. Only include what the user actually told you or clearly implied. If you don't know a field yet, leave it "" (or []). Empty is always better than made-up. +- Write ALL resume content in English, because this resume is for Canadian employers — even when you are talking to the user in ${langName}. Translate job titles ("asistente administrativa" → "Administrative Assistant"), degrees ("licenciatura en administración" → "Bachelor of Business Administration"), skill names, categories, and every bullet point into natural English. Keep real proper names (a specific company or school) as the user gave them. ONLY the "reply" and "suggestions" fields stay in ${langName}. +- Preserve everything captured so far. Each turn, return the COMPLETE resume with all prior fields intact plus any updates from the latest answer. +- Prefill: some contact fields may already be filled in the current resume — keep them; don't re-ask for what's already there. It's fine to ask once, near the end, for a phone number (and optionally LinkedIn) if still blank — but don't push if the user skips it. + +# Suggestions (tappable example answers) +- Provide 2–3 short example answers to the QUESTION YOU JUST ASKED — realistic things THIS user might tap and lightly edit, so users who struggle to type still make progress. +- Make them specific to the user's situation and the current question — never generic filler like "Yes"/"No"/"I don't know". If you asked about a cashier's duties, good suggestions are "Handled cash and card payments", "Helped customers find products", "Kept shelves stocked and tidy". +- Write suggestions in ${langName}. +- If suggestions don't make sense for the current question (e.g. asking for their name or phone), return an empty array. + +# Finishing +- When you have at least one work OR education entry plus some skills, and the user has nothing more to add, set "complete": true, briefly congratulate them, and invite them to refine any section or export the PDF. + +# Output format +${SCHEMA_BLOCK} + +Reply to the user in ${langName}. Output ONLY the JSON object.`; +} + +function buildTurnMessages(profile, history, currentResume, message) { + const messages = [{ role: 'system', content: buildSystemPrompt(profile) }]; + for (const turn of history) { + messages.push({ role: turn.role, content: turn.content }); + } + messages.push({ + role: 'system', + content: `CURRENT_RESUME_JSON (the resume so far — preserve every non-empty field, then apply the user's next answer):\n${JSON.stringify(currentResume)}`, + }); + messages.push({ role: 'user', content: message }); + return messages; +} + +function extractJsonObject(raw: string) { + const trimmed = raw.trim(); + const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i); + const candidate = fenced ? fenced[1].trim() : trimmed; + try { + return JSON.parse(candidate); + } catch { + const first = candidate.indexOf('{'); + const last = candidate.lastIndexOf('}'); + if (first === -1 || last <= first) return null; + try { + return JSON.parse(candidate.slice(first, last + 1)); + } catch { + return null; + } + } +} + +function parseTurnResponse(raw: string) { + const parsed = extractJsonObject(raw); + if (!parsed) return null; + const reply = typeof parsed.reply === 'string' ? parsed.reply.trim() : ''; + const suggestions = Array.isArray(parsed.suggestions) + ? parsed.suggestions + .filter(s => typeof s === 'string') + .map(s => s.trim()) + .filter(Boolean) + .slice(0, 3) + : []; + return { + reply, + suggestions, + resume: parsed.resume ?? {}, + complete: parsed.complete === true, + }; +} + +// --------------------------------------------------------------------------- +// Normalization (mirrors lib/resume/schema.ts) +// --------------------------------------------------------------------------- + +const MAX_ENTRIES = 12; +const MAX_BULLETS = 10; + +function s(v: unknown, max = 400): string { + return typeof v === 'string' ? v.trim().slice(0, max) : ''; +} +function sArr(v: unknown, maxItems: number, maxLen: number): string[] { + if (!Array.isArray(v)) return []; + const out: string[] = []; + for (const item of v) { + const val = s(item, maxLen); + if (val) out.push(val); + if (out.length >= maxItems) break; + } + return out; +} +function nid(): string { + return crypto.randomUUID(); +} + +function normalizeResume(v) { + const r = v ?? {}; + const c = r.contact ?? {}; + return { + contact: { + name: s(c.name, 120), + email: s(c.email, 160), + phone: s(c.phone, 60), + location: s(c.location, 120), + linkedin: s(c.linkedin, 200), + website: s(c.website, 200), + }, + summary: s(r.summary, 600), + education: Array.isArray(r.education) + ? r.education.slice(0, MAX_ENTRIES).map(e => ({ + id: s(e?.id) || nid(), + institution: s(e?.institution), + location: s(e?.location), + degree: s(e?.degree), + dates: s(e?.dates), + })) + : [], + experience: Array.isArray(r.experience) + ? r.experience.slice(0, MAX_ENTRIES).map(e => ({ + id: s(e?.id) || nid(), + title: s(e?.title), + organization: s(e?.organization), + location: s(e?.location), + dates: s(e?.dates), + bullets: sArr(e?.bullets, MAX_BULLETS, 400), + })) + : [], + projects: Array.isArray(r.projects) + ? r.projects.slice(0, MAX_ENTRIES).map(p => ({ + id: s(p?.id) || nid(), + name: s(p?.name), + tech: s(p?.tech), + dates: s(p?.dates), + bullets: sArr(p?.bullets, MAX_BULLETS, 400), + })) + : [], + skills: Array.isArray(r.skills) + ? r.skills.slice(0, 8).map(sk => ({ + id: s(sk?.id) || nid(), + category: s(sk?.category, 120), + items: sArr(sk?.items, 20, 120), + })) + : [], + }; +} + +// --------------------------------------------------------------------------- + +Deno.serve(async req => { + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }); + } + if (req.method !== 'POST') { + return jsonResponse({ error: 'Method not allowed' }, 405); + } + if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) { + return jsonResponse({ error: 'Missing Supabase env vars' }, 500); + } + if (!Deno.env.get('OPENROUTER_API_KEY')) { + return jsonResponse({ error: 'Missing OPENROUTER_API_KEY' }, 500); + } + + const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); + + try { + const token = req.headers.get('Authorization')?.replace('Bearer ', ''); + if (!token) return jsonResponse({ error: 'Unauthorized' }, 401); + + const { data: authData, error: userError } = + await supabase.auth.getUser(token); + if (userError || !authData?.user) { + return jsonResponse({ error: 'Invalid user' }, 401); + } + + let body; + try { + body = await req.json(); + } catch { + return jsonResponse({ error: 'Invalid JSON body' }, 400); + } + + const message = typeof body.message === 'string' ? body.message.trim() : ''; + if (!message) return jsonResponse({ error: 'Message is required' }, 400); + if (message.length > 2000) { + return jsonResponse({ error: 'Message is too long' }, 413); + } + + const history = Array.isArray(body.history) + ? body.history + .filter(m => m && typeof m.content === 'string' && m.content.trim()) + .slice(-12) + .map(m => ({ + role: m.role === 'assistant' ? 'assistant' : 'user', + content: String(m.content).slice(0, 4000), + })) + : []; + const currentResume = normalizeResume(body.currentResume); + const profile = body.profile ?? {}; + + const messages = buildTurnMessages( + profile, + history, + currentResume, + message + ); + + const llmResult = await callOpenRouter({ + model: 'deepseek/deepseek-v4-flash', + messages, + jsonMode: true, + maxTokens: 2400, + temperature: 0.5, + timeoutMs: 45000, + retries: 1, + retryDelayMs: 500, + appName: 'Unify — resume-chat', + }); + + if (!llmResult.ok) { + console.error('resume-chat OpenRouter call failed:', llmResult.message); + let status = 502; + if (llmResult.status === 504) status = 504; + else if (llmResult.retryable) status = 503; + return jsonResponse({ error: 'AI service unavailable' }, status); + } + + captureAiGeneration(authData.user.id, { + $ai_model: llmResult.model, + $ai_provider: llmResult.provider, + $ai_input_tokens: llmResult.usage.promptTokens, + $ai_output_tokens: llmResult.usage.completionTokens, + $ai_total_tokens: llmResult.usage.totalTokens, + $ai_total_cost_usd: llmResult.usage.costUsd, + feature: 'resume_builder', + source: typeof body.source === 'string' ? body.source : 'web', + message_length: message.length, + }); + + const parsed = parseTurnResponse(llmResult.content); + if (!parsed || !parsed.reply) { + // Prose fallback: a turn occasionally returns a plain sentence despite + // json mode. Show it and leave the resume unchanged this turn. + const prose = llmResult.content.trim(); + if (prose && prose.length <= 1500 && !prose.includes('{')) { + return jsonResponse({ + reply: prose, + suggestions: [], + resume: currentResume, + complete: false, + }); + } + return jsonResponse({ error: 'Unexpected model response' }, 502); + } + + return jsonResponse({ + reply: parsed.reply, + suggestions: parsed.suggestions, + resume: normalizeResume(parsed.resume), + complete: parsed.complete, + }); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return jsonResponse({ error: 'Request timed out' }, 504); + } + console.error('resume-chat error:', error); + return jsonResponse({ error: 'Internal server error' }, 500); + } +}); diff --git a/types/resume.ts b/types/resume.ts new file mode 100644 index 0000000..3dc599f --- /dev/null +++ b/types/resume.ts @@ -0,0 +1,154 @@ +/* ------------------------------------------------------------------ * + * AI Resume Builder — shared types. + * + * The resume data model maps to the "Jake's Resume" template (a single-column, + * ATS-friendly layout): Contact header → Summary (optional) → Education → + * Experience → Projects (optional) → Skills. `skills` is a list of freeform + * categories rather than the SWE-fixed "Languages / Frameworks / Tools" so the + * same template serves trades, healthcare, and service-industry newcomers, not + * only software engineers. + * + * Persistence is LOCAL ONLY for the prototype (localStorage, see + * services/resume.ts). These types describe both the persisted draft shape and + * the structured turn the model returns. + * ------------------------------------------------------------------ */ + +import type { Persona, Stage } from "@/types"; +import type { SupportedLanguage } from "@/lib/i18n/config"; + +export interface ResumeContact { + name: string; + email: string; + phone: string; + /** "City, Province" — e.g. "Toronto, ON". */ + location: string; + /** LinkedIn URL or handle; empty string when not provided. */ + linkedin: string; + /** Portfolio / GitHub / personal site; empty string when not provided. */ + website: string; +} + +export interface ResumeEducation { + /** Client-assigned stable id (React key + reorder). */ + id: string; + institution: string; + /** "City, Country" or "City, Province". */ + location: string; + /** Degree / program / credential, e.g. "B.A. Economics" or "High School Diploma". */ + degree: string; + /** Free-form date range, e.g. "Sep 2018 – May 2022" or "2022". */ + dates: string; +} + +export interface ResumeExperience { + id: string; + /** Job title, e.g. "Retail Sales Associate". */ + title: string; + organization: string; + location: string; + dates: string; + /** Achievement-oriented bullet points (action verb first). */ + bullets: string[]; +} + +export interface ResumeProject { + id: string; + name: string; + /** Optional tech / tools summary, shown next to the name. */ + tech: string; + dates: string; + bullets: string[]; +} + +export interface ResumeSkillCategory { + id: string; + /** e.g. "Languages", "Certifications", "Technical Skills", "Software". */ + category: string; + items: string[]; +} + +export interface ResumeData { + contact: ResumeContact; + /** Optional 1–2 sentence professional summary shown under the header. */ + summary: string; + education: ResumeEducation[]; + experience: ResumeExperience[]; + projects: ResumeProject[]; + skills: ResumeSkillCategory[]; +} + +/** Chat roles for the resume conversation. */ +export type ResumeChatRole = "user" | "assistant"; + +export interface ResumeChatMessage { + /** Local uuid — stable React key (no server bigint here; persistence is local). */ + id: string; + role: ResumeChatRole; + content: string; + /** + * 2–3 suggested example answers the user can TAP TO FILL the input (they do + * NOT auto-send, unlike Companion's follow-up chips). Present on assistant + * turns; absent/empty otherwise. + */ + suggestions?: string[]; + createdAt: string; +} + +/** One saved resume draft (the unit the drafts list selects). */ +export interface ResumeDraft { + id: string; + title: string; + createdAt: string; + updatedAt: string; + resume: ResumeData; + messages: ResumeChatMessage[]; + /** True once the model signals the resume is reasonably complete. */ + complete: boolean; +} + +/** Lightweight row for the drafts list (no messages/resume payload). */ +export interface ResumeDraftSummary { + id: string; + title: string; + updatedAt: string; + complete: boolean; +} + +/** + * The structured JSON the model returns each turn. The model is instructed to + * return the COMPLETE updated resume (not a patch), preserving all previously + * captured fields, so the right-hand panel always renders from a full snapshot. + */ +export interface ResumeTurnResponse { + /** Conversational message shown in the chat (user's language). */ + reply: string; + /** 0–3 tappable example answers (user's language). */ + suggestions: string[]; + /** Full updated resume (English content). */ + resume: ResumeData; + /** True when the model judges the resume reasonably complete. */ + complete: boolean; +} + +/** Context passed to the turn generator to personalize the conversation. */ +export interface ResumeProfileContext { + firstName: string | null; + persona: Persona | null; + stage: Stage | null; + city: string | null; + province: string | null; + /** UI language — the assistant replies + suggestions use it (resume stays English). */ + responseLanguage: SupportedLanguage; + email: string | null; +} + +/** Request body sent to /api/resume (and, later, the resume-chat edge function). */ +export interface ResumeTurnRequest { + /** Prior turns (trimmed), oldest first. */ + history: { role: ResumeChatRole; content: string }[]; + /** The latest user message. */ + message: string; + /** The resume built so far — the model returns an updated copy. */ + currentResume: ResumeData; + profile: ResumeProfileContext; +} From 5301153e105b1e19318e08b893686d3c50264d45 Mon Sep 17 00:00:00 2001 From: Luis Tanafranca <80248146+ltanafranca1004@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:59:22 -0700 Subject: [PATCH 2/5] fix(resume): address CodeRabbit review (PR #107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Server-side daily quota backstop (lib/resume/serverRateLimit) + 429 handling — the localStorage cap was bypassable, leaving the OpenRouter budget unbounded. - Clamp/validate client-supplied profile in the resume-chat edge fn before it enters the system prompt (prompt-injection / unbounded-inflation gap). - Serialize resume sends: disable the composer while a turn is pending + guard handleSend, so concurrent turns can't read the same draft and clobber it. - Keep entry ids stable across turns: add id to SCHEMA_BLOCK + instruct the model to echo existing ids (was reassigning UUIDs every turn -> list remount flicker). - clampHistory keeps the NEWEST turns (was keeping the oldest 30) and uses the shared RESUME_HISTORY_TURNS window, matching the edge path. - Propagate localStorage write failures from writeDrafts so a failed save doesn't masquerade as success. - Local calendar date for the daily usage key (was UTC — reset early for CA zones). - Log ResumeUpstreamError + preserve a non-retryable 500 instead of masking as 502. - a11y: reveal the draft delete control on keyboard focus. - Print CSS: drop the redundant --tw-ring-shadow reset (box-shadow:none clears the ring) + stylelint spacing. Co-Authored-By: Claude Opus 4.8 --- app/(main)/resume/page.tsx | 3 ++ app/api/resume/route.ts | 31 ++++++++++++--- app/globals.css | 6 ++- components/companion/ChatInput.tsx | 6 ++- components/resume/ResumeChatColumn.tsx | 6 ++- lib/resume/prompt.ts | 12 +++--- lib/resume/serverRateLimit.ts | 51 ++++++++++++++++++++++++ services/resume.ts | 20 +++++++--- supabase/functions/resume-chat/index.ts | 53 ++++++++++++++++++++++--- 9 files changed, 162 insertions(+), 26 deletions(-) create mode 100644 lib/resume/serverRateLimit.ts diff --git a/app/(main)/resume/page.tsx b/app/(main)/resume/page.tsx index ce21292..763d4ec 100644 --- a/app/(main)/resume/page.tsx +++ b/app/(main)/resume/page.tsx @@ -69,6 +69,9 @@ export default function ResumePage() { 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 }); diff --git a/app/api/resume/route.ts b/app/api/resume/route.ts index 08d163c..e176513 100644 --- a/app/api/resume/route.ts +++ b/app/api/resume/route.ts @@ -1,7 +1,12 @@ import { NextResponse, type NextRequest } from "next/server"; import { createClient } from "@/lib/supabase/server"; import { generateResumeTurn, ResumeUpstreamError } from "@/lib/resume/generateTurn"; -import { normalizeResumeData, MAX_RESUME_MESSAGE_LEN } from "@/lib/resume/schema"; +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, @@ -72,9 +77,10 @@ function clampHistory( const content = typeof r.content === "string" ? r.content.slice(0, 4000) : ""; if (content) out.push({ role, content }); - if (out.length >= 30) break; } - return out; + // Keep the NEWEST turns (drop the oldest) and bound to the shared history + // window, matching services/resume.ts and the resume-chat edge function. + return out.slice(-RESUME_HISTORY_TURNS); } export async function POST(req: NextRequest) { @@ -104,6 +110,15 @@ export async function POST(req: NextRequest) { ); } + // 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), @@ -116,8 +131,14 @@ export async function POST(req: NextRequest) { return NextResponse.json(result); } catch (error) { if (error instanceof ResumeUpstreamError) { - // 503 for retryable upstream trouble (429 / 5xx / timeout), else 502. - const status = error.retryable ? 503 : 502; + 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 }, diff --git a/app/globals.css b/app/globals.css index 60b9dc6..2c2325b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -331,9 +331,11 @@ input:-webkit-autofill:active { margin: 0 !important; width: 100% !important; max-width: 100% !important; + /* Physical page margins for print — absolute units (inches) are paper + geometry, intentionally not part of the screen brand-spacing scale. */ padding: 0.5in 0.55in !important; - box-shadow: none !important; - --tw-ring-shadow: 0 0 #0000 !important; + box-shadow: none !important; /* also clears the Tailwind ring (ring == box-shadow) */ + -webkit-print-color-adjust: exact; print-color-adjust: exact; } diff --git a/components/companion/ChatInput.tsx b/components/companion/ChatInput.tsx index 8293dda..86430d8 100644 --- a/components/companion/ChatInput.tsx +++ b/components/companion/ChatInput.tsx @@ -10,6 +10,8 @@ interface ChatInputProps { onSend: (text: string) => void; placeholder?: string; inputRef?: React.Ref; + /** Blocks input + send while a turn is in flight (prevents concurrent sends). */ + disabled?: boolean; } /** Companion message input — controlled; Enter sends, Shift+Enter inserts a newline. */ @@ -19,9 +21,10 @@ export function ChatInput({ onSend, placeholder, inputRef, + disabled = false, }: ChatInputProps) { const { t } = useTranslation(); - const canSend = value.trim().length > 0; + const canSend = !disabled && value.trim().length > 0; function send() { if (!canSend) return; @@ -33,6 +36,7 @@ export function ChatInput({