This commit is contained in:
Amruth Pillai
2026-02-09 01:50:31 +01:00
committed by GitHub
parent 2b8fa9c7e8
commit 90c34ca572
95 changed files with 3615 additions and 450 deletions
+408
View File
@@ -0,0 +1,408 @@
import { useChat } from "@ai-sdk/react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { eventIteratorToUnproxiedDataStream } from "@orpc/client";
import {
CheckCircleIcon,
CircleNotchIcon,
PaperPlaneRightIcon,
SparkleIcon,
StopIcon,
TrashSimpleIcon,
} from "@phosphor-icons/react";
import type { UIMessage } from "ai";
import type { Operation } from "fast-json-patch";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useAIStore } from "@/integrations/ai/store";
import { client } from "@/integrations/orpc/client";
import { applyResumePatches } from "@/utils/resume/patch";
import { cn } from "@/utils/style";
import { useResumeStore } from "../resume/store/resume";
/**
* Extract patch operations from the latest assistant messages' tool call parts.
* Returns operations that haven't been applied yet (tracked by processedToolCallIds).
*/
function extractNewPatchOperations(
messages: UIMessage[],
processedIds: Set<string>,
): { operations: Operation[]; newIds: string[] } {
const operations: Operation[] = [];
const newIds: string[] = [];
for (const message of messages) {
if (message.role !== "assistant") continue;
for (const part of message.parts) {
if (part.type !== "tool-patch_resume") continue;
// biome-ignore lint/suspicious/noExplicitAny: AI SDK tool parts have dynamic shapes
const toolPart = part as any;
if (toolPart.state !== "output-available") continue;
const callId = toolPart.toolCallId as string;
if (processedIds.has(callId)) continue;
const result = toolPart.output as { success: boolean; appliedOperations?: Operation[] } | undefined;
if (result?.success && result.appliedOperations) {
operations.push(...result.appliedOperations);
newIds.push(callId);
}
}
}
return { operations, newIds };
}
/**
* LocalStorage helpers for persisting chat messages per resume.
*/
const STORAGE_KEY_PREFIX = "ai-chat-messages";
function getStorageKey(resumeId: string): string {
return `${STORAGE_KEY_PREFIX}:${resumeId}`;
}
function loadStoredMessages(resumeId: string): UIMessage[] {
try {
const stored = localStorage.getItem(getStorageKey(resumeId));
if (!stored) return [];
return JSON.parse(stored) as UIMessage[];
} catch {
return [];
}
}
function saveStoredMessages(resumeId: string, messages: UIMessage[]): void {
try {
localStorage.setItem(getStorageKey(resumeId), JSON.stringify(messages));
} catch {
// Silently fail if localStorage is full or unavailable
}
}
function clearStoredMessages(resumeId: string): void {
localStorage.removeItem(getStorageKey(resumeId));
}
function formatPath(path: string): string {
return path.replace(/^\//, "").replace(/\//g, " ");
}
function formatOperationLabel(op: string): string {
switch (op) {
case "replace":
return t`Updated`;
case "add":
return t`Added`;
case "remove":
return t`Removed`;
default:
return op;
}
}
function ToolBadge({ state, operations }: { state: string; operations?: { op: string; path: string }[] }) {
const isDone = state === "output-available";
return (
<div className="my-0.5 flex flex-col gap-1.5">
<span
data-state={state}
className={cn(
"inline-flex w-fit items-center gap-1.5 rounded-full border px-2.5 py-1 font-medium text-[11px]",
"data-[state=output-available]:border-emerald-500/30 data-[state=output-available]:bg-emerald-500/10 data-[state=output-available]:text-emerald-600 dark:data-[state=output-available]:text-emerald-400",
)}
>
{isDone ? (
<CheckCircleIcon weight="fill" className="size-3" />
) : (
<CircleNotchIcon className="size-3 animate-spin" />
)}
{isDone ? "Changes applied" : "Applying changes..."}
</span>
{isDone && operations && operations.length > 0 && (
<div className="flex flex-col gap-0.5 pl-1 text-[11px] text-muted-foreground">
{operations.map((op, i) => (
<span key={i} className="flex items-center gap-1">
<span className="opacity-40"></span>
{formatOperationLabel(op.op)} {formatPath(op.path)}
</span>
))}
</div>
)}
</div>
);
}
function MessageParts({ message }: { message: UIMessage }) {
const parts = message.parts;
if (parts.length === 0) return null;
return (
<div className="flex flex-col gap-2">
{parts.map((part, i) => {
if (part.type === "step-start") {
if (i === 0) return null;
return <hr key={i} className="my-0.5 border-border/40" />;
}
if (part.type === "text" && part.text.trim()) {
return (
<p key={i} className="whitespace-pre-wrap text-[13px] leading-relaxed">
{part.text}
</p>
);
}
if (part.type === "reasoning" && part.text.trim()) {
return (
<p key={i} className="whitespace-pre-wrap text-[11px] text-muted-foreground italic leading-relaxed">
{part.text}
</p>
);
}
if (part.type === "tool-patch_resume") {
// biome-ignore lint/suspicious/noExplicitAny: AI SDK tool parts have dynamic shapes
const toolPart = part as any;
const state = toolPart.state as string;
const operations = toolPart.input?.operations as { op: string; path: string }[] | undefined;
return <ToolBadge key={i} state={state} operations={operations} />;
}
return null;
})}
</div>
);
}
export function AIChat() {
const enabled = useAIStore((s) => s.enabled);
const provider = useAIStore((s) => s.provider);
const model = useAIStore((s) => s.model);
const apiKey = useAIStore((s) => s.apiKey);
const baseURL = useAIStore((s) => s.baseURL);
const resumeId = useResumeStore((s) => s.resume.id);
const resumeData = useResumeStore((s) => s.resume.data);
const updateResumeData = useResumeStore((s) => s.updateResumeData);
const [input, setInput] = useState("");
const [open, setOpen] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const processedToolCallIds = useRef(new Set<string>());
// Load stored messages once on mount and pre-populate processedToolCallIds
// so that already-applied patches are not re-applied.
const [initialMessages] = useState(() => {
const stored = loadStoredMessages(resumeId);
for (const msg of stored) {
if (msg.role !== "assistant") continue;
for (const part of msg.parts) {
if (part.type !== "tool-patch_resume") continue;
// biome-ignore lint/suspicious/noExplicitAny: AI SDK tool parts have dynamic shapes
const toolPart = part as any;
if (toolPart.state === "output-available") {
processedToolCallIds.current.add(toolPart.toolCallId as string);
}
}
}
return stored;
});
const { messages, sendMessage, status, stop, setMessages } = useChat({
messages: initialMessages,
transport: {
async sendMessages(options) {
return eventIteratorToUnproxiedDataStream(
await client.ai.chat(
{
provider,
model,
apiKey,
baseURL,
messages: options.messages,
resumeData,
},
{ signal: options.abortSignal },
),
);
},
reconnectToStream() {
throw new Error("Unsupported");
},
},
onError(error) {
toast.error("AI chat error", { description: error.message });
},
});
// Apply patches when new tool results arrive
useEffect(() => {
const { operations, newIds } = extractNewPatchOperations(messages, processedToolCallIds.current);
if (operations.length === 0) return;
try {
const patched = applyResumePatches(resumeData, operations);
updateResumeData((draft) => {
Object.assign(draft, patched);
});
for (const id of newIds) processedToolCallIds.current.add(id);
} catch (error) {
toast.error("Failed to apply resume changes", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
}, [messages, resumeData, updateResumeData]);
// Persist messages to localStorage whenever they change
useEffect(() => {
saveStoredMessages(resumeId, messages);
}, [resumeId, messages]);
// Auto-scroll to bottom (sentinel so it works with ScrollArea viewport)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// Refocus the input when the AI finishes responding
useEffect(() => {
if (status === "ready") inputRef.current?.focus();
}, [status]);
const handleOpenChange = useCallback((nextOpen: boolean) => {
setOpen(nextOpen);
}, []);
const handleClearMessages = useCallback(() => {
setMessages([]);
clearStoredMessages(resumeId);
processedToolCallIds.current.clear();
}, [resumeId, setMessages]);
const handleSubmit = useCallback(
(e: React.SubmitEvent) => {
e.preventDefault();
if (!input.trim() || status !== "ready") return;
sendMessage({ text: input });
setInput("");
},
[input, status, sendMessage],
);
if (!enabled) return null;
const isLoading = status === "submitted" || status === "streaming";
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button size="icon" variant="ghost">
<SparkleIcon />
</Button>
</PopoverTrigger>
<PopoverContent className="flex h-128 w-md flex-col gap-y-0 overflow-hidden p-0" side="top" align="center">
{/* Header with clear button */}
<div className="flex shrink-0 items-center justify-between border-b px-3 py-1.5">
<p className="font-medium text-muted-foreground text-xs">
<Trans>AI Chat</Trans>
</p>
<Button
size="icon-sm"
variant="ghost"
className="size-7"
title={t`Clear chat history`}
onClick={handleClearMessages}
disabled={messages.length === 0 || isLoading}
>
<TrashSimpleIcon className="size-3" />
</Button>
</div>
{/* Message list — min-h-0 so flex constrains height and viewport scrolls */}
<ScrollArea className="min-h-0 flex-1 px-4">
<div className="flex flex-col gap-y-4 pt-4">
{messages.length === 0 && (
<div className="flex h-full items-center justify-center py-6">
<p className="text-center text-muted-foreground text-xs">
<Trans>Ask me to update your resume...</Trans>
</p>
</div>
)}
<div className="flex flex-col gap-4">
{messages.map((message) => (
<div key={message.id} className={cn("flex", message.role === "user" ? "justify-end" : "justify-start")}>
<div
data-role={message.role}
className={cn(
"max-w-[85%] rounded-xl px-3.5 py-2.5",
"data-[role=user]:rounded-br-sm data-[role=user]:bg-primary data-[role=user]:text-primary-foreground",
"data-[role=assistant]:rounded-bl-sm data-[role=assistant]:bg-muted data-[role=assistant]:text-foreground",
)}
>
{message.role === "user" ? (
<p className="text-[13px] leading-relaxed">
{message.parts.map((part, i) =>
part.type === "text" ? <span key={i}>{part.text}</span> : null,
)}
</p>
) : (
<MessageParts message={message} />
)}
</div>
</div>
))}
{status === "submitted" && (
<div className="flex justify-start">
<div className="rounded-xl rounded-bl-sm bg-muted px-3.5 py-2.5">
<div className="flex items-center gap-2 text-[13px] text-muted-foreground">
<CircleNotchIcon className="size-3 animate-spin" />
<span>
<Trans>Thinking...</Trans>
</span>
</div>
</div>
</div>
)}
<div ref={bottomRef} aria-hidden />
</div>
</div>
</ScrollArea>
{/* Input form — shrink-0 so it stays visible when content scrolls */}
<form onSubmit={handleSubmit} className="flex shrink-0 items-center gap-1.5 border-t px-3 py-2">
<input
ref={inputRef}
value={input}
disabled={!enabled || isLoading}
onChange={(e) => setInput(e.target.value)}
placeholder={t`e.g. Change my name to...`}
className="flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground"
/>
{isLoading ? (
<Button type="button" size="icon" variant="ghost" onClick={stop} className="size-7 shrink-0">
<StopIcon className="size-3.5" />
</Button>
) : (
<Button type="submit" size="icon" variant="ghost" disabled={!input.trim()} className="size-7 shrink-0">
<PaperPlaneRightIcon className="size-3.5" />
</Button>
)}
</form>
</PopoverContent>
</Popover>
);
}
@@ -12,17 +12,13 @@ export function CoverLetterItem({ className, ...item }: CoverLetterItemProps) {
return (
<div className={cn("cover-letter-item", className)}>
{stripHtml(item.recipient) && (
<div className="cover-letter-item-recipient mb-4">
<TiptapContent content={item.recipient} />
</div>
)}
<div className={cn("cover-letter-item-recipient mb-4", !stripHtml(item.recipient) && "hidden")}>
<TiptapContent content={item.recipient} />
</div>
{stripHtml(item.content) && (
<div className="cover-letter-item-content">
<TiptapContent content={item.content} />
</div>
)}
<div className={cn("cover-letter-item-content", !stripHtml(item.content) && "hidden")}>
<TiptapContent content={item.content} />
</div>
</div>
);
}
@@ -17,15 +17,11 @@ export function SkillsItem({ className, ...item }: SkillsItemProps) {
</div>
{/* Proficiency */}
{item.proficiency && (
<span className="section-item-metadata skills-item-proficiency inline-block">{item.proficiency}</span>
)}
{item.proficiency && <div className="section-item-metadata skills-item-proficiency">{item.proficiency}</div>}
{/* Keywords */}
{item.keywords.length > 0 && (
<span className="section-item-keywords skills-item-keywords inline-block opacity-80">
{item.keywords.join(", ")}
</span>
<div className="section-item-keywords skills-item-keywords opacity-80">{item.keywords.join(", ")}</div>
)}
{/* Level */}
+1 -1
View File
@@ -65,7 +65,7 @@ function Header() {
return (
<div
className={cn(
"page-header flex items-center gap-x-(--page-gap-x)",
"page-header flex items-center gap-x-(--page-margin-x)",
"rounded-(--picture-border-radius) border border-(--page-text-color)/10 bg-(--page-background-color) p-4",
)}
>
+1 -1
View File
@@ -43,7 +43,7 @@ function Header() {
const basics = useResumeStore((state) => state.resume.data.basics);
return (
<div className="page-header flex items-center gap-x-(--page-gap-x) border-(--page-primary-color) border-b pb-(--page-margin-y)">
<div className="page-header flex items-center gap-x-(--page-margin-x) border-(--page-primary-color) border-b pb-(--page-margin-y)">
<PagePicture />
<div className="page-basics space-y-(--page-gap-y)">
+1 -1
View File
@@ -129,7 +129,7 @@ function Combobox<TValue extends string | number = string>({
<CommandGroup>
{options.map((option) => {
const isSelected = selectedValue === option.value;
const isDisabled = option.disabled ?? (false || disabled);
const isDisabled = option.disabled ?? disabled;
return (
<CommandItem
+1 -1
View File
@@ -7,7 +7,7 @@ function ScrollArea({ className, children, ...props }: React.ComponentProps<type
<ScrollAreaPrimitive.Root data-slot="scroll-area" className={cn("relative", className)} {...props}>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] outline-none transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:ring-ring/50"
className="[&>div]:block! size-full rounded-[inherit] outline-none transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
{children}
</ScrollAreaPrimitive.Viewport>
@@ -0,0 +1,65 @@
You are an expert resume writer and a specialist in JSON Patch (RFC 6902) operations. Your role is to help the user improve and modify their resume through natural conversation.
## Your Capabilities
- You can read and understand the user's current resume data (provided below).
- You can modify the resume by calling the `patch_resume` tool with JSON Patch operations.
- You can advise on resume best practices, wording, and structure.
## Rules
1. **Always use the `patch_resume` tool** to make changes. Never output raw JSON or patch operations in your text response.
2. **Generate the minimal set of operations** needed for each change. Do not replace entire objects when only a single field needs updating.
3. **Preserve existing data** unless the user explicitly asks to remove or replace it.
4. **Confirm before destructive actions** like removing sections or clearing large amounts of content.
5. **Stay on topic.** Only discuss resume-related content. Politely decline off-topic requests.
6. **Do not fabricate content.** Only add information the user provides or explicitly asks you to generate. If generating content (e.g. a summary), make it clear you are drafting and ask for approval.
7. **HTML content fields** (description, summary content, cover letter content) must use valid HTML. Use `<p>` for paragraphs, `<ul>`/`<li>` for lists, `<strong>` for bold, `<em>` for italic.
8. **IDs for new items** must be valid UUIDs (use the format `xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx`).
## Resume Data Structure
The resume data is a JSON object with these top-level keys:
- `basics` — name, headline, email, phone, location, website, customFields
- `summary` — title, content (HTML), columns, hidden
- `picture` — url, size, rotation, aspectRatio, border/shadow settings
- `sections` — built-in sections, each with title, columns, hidden, items[]
- `profiles` — items: { network, username, icon, website }
- `experience` — items: { company, position, location, period, website, description }
- `education` — items: { school, degree, area, grade, location, period, website, description }
- `projects` — items: { name, period, website, description }
- `skills` — items: { name, proficiency, level, keywords[], icon }
- `languages` — items: { language, fluency, level }
- `interests` — items: { name, keywords[], icon }
- `awards` — items: { title, awarder, date, website, description }
- `certifications` — items: { title, issuer, date, website, description }
- `publications` — items: { title, publisher, date, website, description }
- `volunteer` — items: { organization, location, period, website, description }
- `references` — items: { name, position, website, phone, description }
- `customSections` — array of user-created sections with id, type, title, items[]
- `metadata` — template, layout, css, page, design, typography, notes
Every item in a section has: `id` (UUID), `hidden` (boolean), and optionally `options`.
Every `website` field is an object: `{ url: string, label: string }`.
## JSON Patch Path Examples
| Action | Operation |
|--------|-----------|
| Change name | `{ "op": "replace", "path": "/basics/name", "value": "Jane Doe" }` |
| Update headline | `{ "op": "replace", "path": "/basics/headline", "value": "Senior Engineer" }` |
| Replace summary content | `{ "op": "replace", "path": "/summary/content", "value": "<p>Experienced engineer...</p>" }` |
| Add experience item | `{ "op": "add", "path": "/sections/experience/items/-", "value": { ...full item object } }` |
| Remove skill at index 2 | `{ "op": "remove", "path": "/sections/skills/items/2" }` |
| Update a specific item field | `{ "op": "replace", "path": "/sections/experience/items/0/company", "value": "New Corp" }` |
| Change template | `{ "op": "replace", "path": "/metadata/template", "value": "bronzor" }` |
| Change primary color | `{ "op": "replace", "path": "/metadata/design/colors/primary", "value": "rgba(37, 99, 235, 1)" }` |
| Hide a section | `{ "op": "replace", "path": "/sections/interests/hidden", "value": true }` |
| Rename a section title | `{ "op": "replace", "path": "/sections/experience/title", "value": "Work History" }` |
## Current Resume Data
```json
{{RESUME_DATA}}
```
+22
View File
@@ -0,0 +1,22 @@
import type { Operation } from "fast-json-patch";
import z from "zod";
import type { ResumeData } from "@/schema/resume/data";
import { applyResumePatches, jsonPatchOperationSchema } from "@/utils/resume/patch";
export const patchResumeInputSchema = z.object({
operations: z
.array(jsonPatchOperationSchema)
.min(1)
.describe("Array of JSON Patch (RFC 6902) operations to apply to the resume"),
});
export const patchResumeDescription = `Apply JSON Patch (RFC 6902) operations to modify the user's resume data.
Use this tool whenever the user asks to change, add, or remove content from their resume.
Always generate the minimal set of operations needed. Prefer "replace" for updates, "add" for new content, "remove" for deletions.
Use the special "-" index to append to arrays (e.g. "/sections/experience/items/-").`;
export function executePatchResume(resumeData: ResumeData, operations: Operation[]) {
// Validates operations structurally and against the schema; throws on invalid
applyResumePatches(resumeData, operations);
return { success: true as const, appliedOperations: operations };
}
+2 -1
View File
@@ -2,7 +2,7 @@ import { BetterAuthError } from "@better-auth/core/error";
import { passkey } from "@better-auth/passkey";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { betterAuth } from "better-auth/minimal";
import { apiKey, type GenericOAuthConfig, genericOAuth, twoFactor } from "better-auth/plugins";
import { apiKey, type GenericOAuthConfig, genericOAuth, openAPI, twoFactor } from "better-auth/plugins";
import { username } from "better-auth/plugins/username";
import { tanstackStartCookies } from "better-auth/tanstack-start";
import { and, eq, or } from "drizzle-orm";
@@ -211,6 +211,7 @@ const getAuthConfig = () => {
},
plugins: [
openAPI(),
apiKey({
enableSessionForAPIKeys: true,
rateLimit: {
+21 -6
View File
@@ -1,6 +1,6 @@
import { createORPCClient } from "@orpc/client";
import { createORPCClient, onError } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import { BatchLinkPlugin } from "@orpc/client/plugins";
import { BatchLinkPlugin, SimpleCsrfProtectionLinkPlugin } from "@orpc/client/plugins";
import { createRouterClient, type InferRouterInputs, type InferRouterOutputs, type RouterClient } from "@orpc/server";
import { createTanstackQueryUtils } from "@orpc/tanstack-query";
import { createIsomorphicFn } from "@tanstack/react-start";
@@ -11,6 +11,11 @@ import { getLocale } from "@/utils/locale";
export const getORPCClient = createIsomorphicFn()
.server((): RouterClient<typeof router> => {
return createRouterClient(router, {
interceptors: [
onError((error) => {
console.error(`ERROR [oRPC]: ${error}`);
}),
],
context: async () => {
const locale = await getLocale();
const reqHeaders = getRequestHeaders();
@@ -25,10 +30,20 @@ export const getORPCClient = createIsomorphicFn()
.client((): RouterClient<typeof router> => {
const link = new RPCLink({
url: `${window.location.origin}/api/rpc`,
plugins: [new BatchLinkPlugin({ groups: [{ condition: () => true, context: {} }] })],
fetch: (request, init) => {
return fetch(request, { ...init, credentials: "include" });
},
fetch: (request, init) => fetch(request, { ...init, credentials: "include" }),
plugins: [
new SimpleCsrfProtectionLinkPlugin(),
new BatchLinkPlugin({
mode: typeof window === "undefined" ? "buffered" : "streaming",
groups: [{ condition: () => true, context: {} }],
}),
],
interceptors: [
onError((error) => {
if (error instanceof DOMException && error.name === "AbortError") return;
console.error(`ERROR [oRPC]: ${error}`);
}),
],
});
return createORPCClient(link);
+88 -3
View File
@@ -1,11 +1,26 @@
import { ORPCError } from "@orpc/client";
import { AISDKError } from "ai";
import { type } from "@orpc/server";
import { AISDKError, type UIMessage } from "ai";
import { OllamaError } from "ai-sdk-ollama";
import z, { ZodError } from "zod";
import type { ResumeData } from "@/schema/resume/data";
import { protectedProcedure } from "../context";
import { aiCredentialsSchema, aiProviderSchema, aiService, fileInputSchema, formatZodError } from "../services/ai";
type AIProvider = z.infer<typeof aiProviderSchema>;
export const aiRouter = {
testConnection: protectedProcedure
.route({
method: "POST",
path: "/ai/test-connection",
tags: ["AI"],
operationId: "testAiConnection",
summary: "Test AI provider connection",
description:
"Validates the connection to an AI provider by sending a simple test prompt. Requires the provider type, model name, API key, and an optional base URL. Supported providers: OpenAI, Anthropic, Google Gemini, Ollama, and Vercel AI Gateway. Requires authentication.",
successDescription: "The AI provider connection was successful.",
})
.input(
z.object({
provider: aiProviderSchema,
@@ -14,11 +29,17 @@ export const aiRouter = {
baseURL: z.string(),
}),
)
.errors({
BAD_GATEWAY: {
message: "The AI provider returned an error or is unreachable.",
status: 502,
},
})
.handler(async ({ input }) => {
try {
return await aiService.testConnection(input);
} catch (error) {
if (error instanceof AISDKError) {
if (error instanceof AISDKError || error instanceof OllamaError) {
throw new ORPCError("BAD_GATEWAY", { message: error.message });
}
@@ -27,13 +48,29 @@ export const aiRouter = {
}),
parsePdf: protectedProcedure
.route({
method: "POST",
path: "/ai/parse-pdf",
tags: ["AI"],
operationId: "parseResumePdf",
summary: "Parse a PDF file into resume data",
description:
"Extracts structured resume data from a PDF file using the specified AI provider. The file should be sent as a base64-encoded string along with AI provider credentials. Returns a complete ResumeData object. Requires authentication.",
successDescription: "The PDF was successfully parsed into structured resume data.",
})
.input(
z.object({
...aiCredentialsSchema.shape,
file: fileInputSchema,
}),
)
.handler(async ({ input }) => {
.errors({
BAD_GATEWAY: {
message: "The AI provider returned an error or is unreachable.",
status: 502,
},
})
.handler(async ({ input }): Promise<ResumeData> => {
try {
return await aiService.parsePdf(input);
} catch (error) {
@@ -49,6 +86,16 @@ export const aiRouter = {
}),
parseDocx: protectedProcedure
.route({
method: "POST",
path: "/ai/parse-docx",
tags: ["AI"],
operationId: "parseResumeDocx",
summary: "Parse a DOCX file into resume data",
description:
"Extracts structured resume data from a DOCX or DOC file using the specified AI provider. The file should be sent as a base64-encoded string along with AI provider credentials and the document's media type. Returns a complete ResumeData object. Requires authentication.",
successDescription: "The DOCX was successfully parsed into structured resume data.",
})
.input(
z.object({
...aiCredentialsSchema.shape,
@@ -59,6 +106,12 @@ export const aiRouter = {
]),
}),
)
.errors({
BAD_GATEWAY: {
message: "The AI provider returned an error or is unreachable.",
status: 502,
},
})
.handler(async ({ input }) => {
try {
return await aiService.parseDocx(input);
@@ -71,6 +124,38 @@ export const aiRouter = {
throw new Error(formatZodError(error));
}
throw error;
}
}),
chat: protectedProcedure
.route({
method: "POST",
path: "/ai/chat",
tags: ["AI"],
operationId: "aiChat",
summary: "Chat with AI to modify resume",
description:
"Streams a chat response from the configured AI provider. The LLM can call the patch_resume tool to generate JSON Patch operations that modify the resume. Requires authentication and AI provider credentials.",
})
.input(
type<{
provider: AIProvider;
model: string;
apiKey: string;
baseURL: string;
messages: UIMessage[];
resumeData: ResumeData;
}>(),
)
.handler(async ({ input }) => {
try {
return await aiService.chat(input);
} catch (error) {
if (error instanceof AISDKError || error instanceof OllamaError) {
throw new ORPCError("BAD_GATEWAY", { message: error.message });
}
throw error;
}
}),
+10 -30
View File
@@ -1,4 +1,3 @@
import z from "zod";
import { protectedProcedure, publicProcedure } from "../context";
import { authService, type ProviderList } from "../services/auth";
@@ -7,48 +6,29 @@ export const authRouter = {
list: publicProcedure
.route({
method: "GET",
path: "/auth/providers/list",
path: "/auth/providers",
tags: ["Authentication"],
summary: "List all auth providers",
operationId: "listAuthProviders",
summary: "List authentication providers",
description:
"A list of all authentication providers, and their display names, supported by the instance of Reactive Resume.",
"Returns a list of all authentication providers enabled on this Reactive Resume instance, along with their display names. Possible providers include password-based credentials, Google, GitHub, and custom OAuth. No authentication required.",
successDescription: "A map of enabled authentication provider identifiers to their display names.",
})
.handler((): ProviderList => {
return authService.providers.list();
}),
},
verifyResumePassword: publicProcedure
.route({
method: "POST",
path: "/auth/verify-resume-password",
tags: ["Authentication", "Resume"],
summary: "Verify resume password",
description: "Verify a resume password, to grant access to the locked resume.",
})
.input(
z.object({
slug: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
}),
)
.output(z.boolean())
.handler(async ({ input }): Promise<boolean> => {
return await authService.verifyResumePassword({
slug: input.slug,
username: input.username,
password: input.password,
});
}),
deleteAccount: protectedProcedure
.route({
method: "DELETE",
path: "/auth/delete-account",
path: "/auth/account",
tags: ["Authentication"],
operationId: "deleteAccount",
summary: "Delete user account",
description: "Delete the authenticated user's account and all associated data.",
description:
"Permanently deletes the authenticated user's account, including all resumes, uploaded files (profile pictures, screenshots, PDFs), and associated data. This action is irreversible. Requires authentication.",
successDescription: "The user account and all associated data have been successfully deleted.",
})
.handler(async ({ context }): Promise<void> => {
return await authService.deleteAccount({ userId: context.user.id });
+11 -1
View File
@@ -1,3 +1,4 @@
import z from "zod";
import { publicProcedure } from "../context";
import { type FeatureFlags, flagsService } from "../services/flags";
@@ -7,8 +8,17 @@ export const flagsRouter = {
method: "GET",
path: "/flags",
tags: ["Feature Flags"],
operationId: "getFeatureFlags",
summary: "Get feature flags",
description: "Returns the current feature flags for this instance.",
description:
"Returns the current feature flags for this Reactive Resume instance. Feature flags control instance-wide settings such as whether new user signups or email-based authentication are disabled. No authentication required.",
successDescription: "The current feature flags for this instance.",
})
.output(
z.object({
disableSignups: z.boolean().describe("Whether new user signups are disabled on this instance."),
disableEmailAuth: z.boolean().describe("Whether email-based authentication is disabled on this instance."),
}),
)
.handler((): FeatureFlags => flagsService.getFlags()),
};
+16 -10
View File
@@ -7,13 +7,16 @@ export const printerRouter = {
printResumeAsPDF: publicProcedure
.route({
method: "GET",
path: "/printer/resume/{id}/pdf",
tags: ["Resume", "Printer"],
path: "/resumes/{id}/pdf",
tags: ["Resume Export"],
operationId: "exportResumePdf",
summary: "Export resume as PDF",
description: "Export a resume as a PDF. Returns a URL to download the PDF.",
description:
"Generates a PDF from the specified resume and uploads it to storage. Returns a URL to download the generated PDF file. If the request is made by an unauthenticated user (e.g. via a public share link), the resume's download count is incremented. Authentication is optional.",
successDescription: "The PDF was generated successfully. Returns a URL to download the file.",
})
.input(z.object({ id: z.string() }))
.output(z.object({ url: z.string() }))
.input(z.object({ id: z.string().describe("The unique identifier of the resume to export.") }))
.output(z.object({ url: z.string().describe("The URL to download the generated PDF file.") }))
.handler(async ({ input, context }) => {
const { id, data, userId } = await resumeService.getByIdForPrinter({ id: input.id });
const url = await printerService.printResumeAsPDF({ id, data, userId });
@@ -28,13 +31,16 @@ export const printerRouter = {
getResumeScreenshot: protectedProcedure
.route({
method: "GET",
path: "/printer/resume/{id}/screenshot",
tags: ["Resume", "Printer"],
path: "/resumes/{id}/screenshot",
tags: ["Resume Export"],
operationId: "getResumeScreenshot",
summary: "Get resume screenshot",
description: "Get a screenshot of a resume. Returns a URL to the screenshot image.",
description:
"Returns a URL to a screenshot image of the first page of the specified resume. Screenshots are cached for up to 6 hours and regenerated automatically when the resume is updated. Returns null if the screenshot cannot be generated. Requires authentication.",
successDescription: "The screenshot URL, or null if the screenshot could not be generated.",
})
.input(z.object({ id: z.string() }))
.output(z.object({ url: z.string().nullable() }))
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
.output(z.object({ url: z.string().nullable().describe("The URL to the screenshot image, or null.") }))
.handler(async ({ input }) => {
try {
const { id, data, userId, updatedAt } = await resumeService.getByIdForPrinter({ id: input.id });
+125 -57
View File
@@ -9,10 +9,13 @@ const tagsRouter = {
list: protectedProcedure
.route({
method: "GET",
path: "/resume/tags/list",
tags: ["Resume"],
path: "/resumes/tags",
tags: ["Resumes"],
operationId: "listResumeTags",
summary: "List all resume tags",
description: "List all tags for the authenticated user's resumes. Used to populate the filter in the dashboard.",
description:
"Returns a sorted list of all unique tags across the authenticated user's resumes. Useful for populating tag filters in the dashboard. Requires authentication.",
successDescription: "A sorted array of unique tag strings.",
})
.output(z.array(z.string()))
.handler(async ({ context }) => {
@@ -24,19 +27,22 @@ const statisticsRouter = {
getById: protectedProcedure
.route({
method: "GET",
path: "/resume/statistics/{id}",
tags: ["Resume"],
path: "/resumes/{id}/statistics",
tags: ["Resume Statistics"],
operationId: "getResumeStatistics",
summary: "Get resume statistics",
description: "Get the statistics for a resume, such as number of views and downloads.",
description:
"Returns view and download statistics for the specified resume, including total counts and the timestamps of the last view and download. Requires authentication.",
successDescription: "The resume's view and download statistics.",
})
.input(z.object({ id: z.string() }))
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
.output(
z.object({
isPublic: z.boolean(),
views: z.number(),
downloads: z.number(),
lastViewedAt: z.date().nullable(),
lastDownloadedAt: z.date().nullable(),
isPublic: z.boolean().describe("Whether the resume is currently public."),
views: z.number().describe("Total number of times the resume has been viewed."),
downloads: z.number().describe("Total number of times the resume has been downloaded."),
lastViewedAt: z.date().nullable().describe("Timestamp of the last view, or null if never viewed."),
lastDownloadedAt: z.date().nullable().describe("Timestamp of the last download, or null if never downloaded."),
}),
)
.handler(async ({ context, input }) => {
@@ -44,7 +50,7 @@ const statisticsRouter = {
}),
increment: publicProcedure
.route({ tags: ["Internal"], summary: "Increment resume statistics" })
.route({ tags: ["Internal"], operationId: "incrementResumeStatistics", summary: "Increment resume statistics" })
.input(z.object({ id: z.string(), views: z.boolean().default(false), downloads: z.boolean().default(false) }))
.handler(async ({ input }) => {
return await resumeService.statistics.increment(input);
@@ -58,10 +64,13 @@ export const resumeRouter = {
list: protectedProcedure
.route({
method: "GET",
path: "/resume/list",
tags: ["Resume"],
path: "/resumes",
tags: ["Resumes"],
operationId: "listResumes",
summary: "List all resumes",
description: "List of all the resumes for the authenticated user.",
description:
"Returns a list of all resumes belonging to the authenticated user. Results can be filtered by tags and sorted by last updated date, creation date, or name. Resume data is not included in the response for performance; use the get endpoint to fetch full resume data. Requires authentication.",
successDescription: "A list of resumes with their metadata (without full resume data).",
})
.input(resumeDto.list.input.optional().default({ tags: [], sort: "lastUpdatedAt" }))
.output(resumeDto.list.output)
@@ -76,10 +85,13 @@ export const resumeRouter = {
getById: protectedProcedure
.route({
method: "GET",
path: "/resume/{id}",
tags: ["Resume"],
path: "/resumes/{id}",
tags: ["Resumes"],
operationId: "getResume",
summary: "Get resume by ID",
description: "Get a resume, along with its data, by its ID.",
description:
"Returns a single resume with its full data, identified by its unique ID. Only resumes belonging to the authenticated user can be retrieved. Requires authentication.",
successDescription: "The resume with its full data.",
})
.input(resumeDto.getById.input)
.output(resumeDto.getById.output)
@@ -88,7 +100,7 @@ export const resumeRouter = {
}),
getByIdForPrinter: serverOnlyProcedure
.route({ tags: ["Internal"], summary: "Get resume by ID for printer" })
.route({ tags: ["Internal"], operationId: "getResumeForPrinter", summary: "Get resume by ID for printer" })
.input(resumeDto.getById.input)
.handler(async ({ input }) => {
return await resumeService.getByIdForPrinter({ id: input.id });
@@ -97,10 +109,13 @@ export const resumeRouter = {
getBySlug: publicProcedure
.route({
method: "GET",
path: "/resume/{username}/{slug}",
tags: ["Resume"],
summary: "Get resume by username and slug",
description: "Get a resume, along with its data, by its username and slug.",
path: "/resumes/{username}/{slug}",
tags: ["Resume Sharing"],
operationId: "getResumeBySlug",
summary: "Get public resume by username and slug",
description:
"Returns a publicly shared resume identified by the owner's username and the resume's slug. If the resume is password-protected and the viewer has not yet verified the password, a 401 error with code NEED_PASSWORD is returned. No authentication required for public resumes; if authenticated as the owner, private resumes are also accessible.",
successDescription: "The public resume with its full data.",
})
.input(resumeDto.getBySlug.input)
.output(resumeDto.getBySlug.output)
@@ -111,10 +126,13 @@ export const resumeRouter = {
create: protectedProcedure
.route({
method: "POST",
path: "/resume/create",
tags: ["Resume"],
path: "/resumes",
tags: ["Resumes"],
operationId: "createResume",
summary: "Create a new resume",
description: "Create a new resume, with the ability to initialize it with sample data.",
description:
"Creates a new resume with the given name, slug, and tags. Optionally initializes the resume with sample data by setting withSampleData to true. The slug must be unique across the user's resumes. Returns the ID of the newly created resume. Requires authentication.",
successDescription: "The ID of the newly created resume.",
})
.input(resumeDto.create.input)
.output(resumeDto.create.output)
@@ -138,10 +156,13 @@ export const resumeRouter = {
import: protectedProcedure
.route({
method: "POST",
path: "/resume/import",
tags: ["Resume"],
path: "/resumes/import",
tags: ["Resumes"],
operationId: "importResume",
summary: "Import a resume",
description: "Import a resume from a file.",
description:
"Creates a new resume from an existing ResumeData object (e.g. from a previously exported JSON file). A random name and slug are generated automatically. Returns the ID of the imported resume. Requires authentication.",
successDescription: "The ID of the imported resume.",
})
.input(resumeDto.import.input)
.output(resumeDto.import.output)
@@ -168,10 +189,13 @@ export const resumeRouter = {
update: protectedProcedure
.route({
method: "PUT",
path: "/resume/{id}",
tags: ["Resume"],
path: "/resumes/{id}",
tags: ["Resumes"],
operationId: "updateResume",
summary: "Update a resume",
description: "Update a resume, along with its data, by its ID.",
description:
"Updates one or more fields of a resume identified by its ID. All fields are optional; only provided fields will be updated. Locked resumes cannot be updated. Requires authentication.",
successDescription: "The updated resume with its full data.",
})
.input(resumeDto.update.input)
.output(resumeDto.update.output)
@@ -196,11 +220,13 @@ export const resumeRouter = {
patch: protectedProcedure
.route({
method: "PATCH",
path: "/resume/{id}",
tags: ["Resume"],
summary: "Patch a resume",
path: "/resumes/{id}",
tags: ["Resumes"],
operationId: "patchResume",
summary: "Patch resume data",
description:
"Apply JSON Patch (RFC 6902) operations to partially update a resume's data. This allows you to make small, targeted changes without sending the entire resume object.",
"Applies JSON Patch (RFC 6902) operations to partially update a resume's data. This allows small, targeted changes (e.g. updating a single field) without sending the entire resume object. Locked resumes cannot be patched. Requires authentication.",
successDescription: "The patched resume with its full data.",
})
.input(resumeDto.patch.input)
.output(resumeDto.patch.output)
@@ -221,10 +247,13 @@ export const resumeRouter = {
setLocked: protectedProcedure
.route({
method: "POST",
path: "/resume/{id}/set-locked",
tags: ["Resume"],
summary: "Set resume locked status",
description: "Toggle the locked status of a resume, by its ID.",
path: "/resumes/{id}/lock",
tags: ["Resumes"],
operationId: "setResumeLocked",
summary: "Set resume lock status",
description:
"Toggles the locked status of a resume. When locked, a resume cannot be updated, patched, or deleted. Useful for protecting finalized resumes from accidental edits. Requires authentication.",
successDescription: "The resume lock status was updated successfully.",
})
.input(resumeDto.setLocked.input)
.output(resumeDto.setLocked.output)
@@ -238,11 +267,14 @@ export const resumeRouter = {
setPassword: protectedProcedure
.route({
method: "POST",
path: "/resume/{id}/set-password",
tags: ["Resume"],
summary: "Set password on a resume",
description: "Set a password on a resume to protect it from unauthorized access when shared publicly.",
method: "PUT",
path: "/resumes/{id}/password",
tags: ["Resume Sharing"],
operationId: "setResumePassword",
summary: "Set resume password",
description:
"Sets or updates a password on a resume. When a password is set, viewers of the public resume must enter the password before the resume data is revealed. The password must be between 6 and 64 characters. Requires authentication.",
successDescription: "The resume password was set successfully.",
})
.input(resumeDto.setPassword.input)
.output(resumeDto.setPassword.output)
@@ -254,13 +286,43 @@ export const resumeRouter = {
});
}),
removePassword: protectedProcedure
verifyPassword: publicProcedure
.route({
method: "POST",
path: "/resume/{id}/remove-password",
tags: ["Resume"],
summary: "Remove password from a resume",
description: "Remove password protection from a resume.",
path: "/resumes/{username}/{slug}/password/verify",
tags: ["Resume Sharing"],
operationId: "verifyResumePassword",
summary: "Verify resume password",
description:
"Verifies a password for a password-protected public resume. On success, the viewer is granted access to view the resume data for the duration of their session. No authentication required.",
successDescription: "The password was verified successfully and access has been granted.",
})
.input(
z.object({
username: z.string().min(1).describe("The username of the resume owner."),
slug: z.string().min(1).describe("The slug of the resume."),
password: z.string().min(1).describe("The password to verify."),
}),
)
.output(z.boolean())
.handler(async ({ input }): Promise<boolean> => {
return await resumeService.verifyPassword({
username: input.username,
slug: input.slug,
password: input.password,
});
}),
removePassword: protectedProcedure
.route({
method: "DELETE",
path: "/resumes/{id}/password",
tags: ["Resume Sharing"],
operationId: "removeResumePassword",
summary: "Remove resume password",
description:
"Removes password protection from a resume. After removal, the resume (if public) can be viewed without entering a password. Requires authentication.",
successDescription: "The resume password was removed successfully.",
})
.input(resumeDto.removePassword.input)
.output(resumeDto.removePassword.output)
@@ -274,10 +336,13 @@ export const resumeRouter = {
duplicate: protectedProcedure
.route({
method: "POST",
path: "/resume/{id}/duplicate",
tags: ["Resume"],
path: "/resumes/{id}/duplicate",
tags: ["Resumes"],
operationId: "duplicateResume",
summary: "Duplicate a resume",
description: "Duplicate a resume, by its ID.",
description:
"Creates a copy of an existing resume with the same data. Optionally override the name, slug, and tags for the duplicate. If not provided, the original resume's name, slug, and tags are used. Returns the ID of the duplicated resume. Requires authentication.",
successDescription: "The ID of the duplicated resume.",
})
.input(resumeDto.duplicate.input)
.output(resumeDto.duplicate.output)
@@ -297,10 +362,13 @@ export const resumeRouter = {
delete: protectedProcedure
.route({
method: "DELETE",
path: "/resume/{id}",
tags: ["Resume"],
path: "/resumes/{id}",
tags: ["Resumes"],
operationId: "deleteResume",
summary: "Delete a resume",
description: "Delete a resume, by its ID.",
description:
"Permanently deletes a resume and its associated files (screenshots, PDFs) from storage. Locked resumes cannot be deleted; unlock the resume first. Requires authentication.",
successDescription: "The resume and its associated files were deleted successfully.",
})
.input(resumeDto.delete.input)
.output(resumeDto.delete.output)
+21 -12
View File
@@ -6,12 +6,15 @@ const userRouter = {
getCount: publicProcedure
.route({
method: "GET",
path: "/statistics/user/count",
tags: ["Statistics"],
path: "/statistics/users",
tags: ["Platform Statistics"],
operationId: "getUserCount",
summary: "Get total number of users",
description: "Get the total number of users for the Reactive Resume.",
description:
"Returns the total number of registered users on this Reactive Resume instance. The count is cached for up to 6 hours for performance. No authentication required.",
successDescription: "The total number of registered users.",
})
.output(z.number())
.output(z.number().describe("The total number of registered users."))
.handler(async (): Promise<number> => {
return await statisticsService.user.getCount();
}),
@@ -21,12 +24,15 @@ const resumeRouter = {
getCount: publicProcedure
.route({
method: "GET",
path: "/statistics/resume/count",
tags: ["Statistics"],
path: "/statistics/resumes",
tags: ["Platform Statistics"],
operationId: "getResumeCount",
summary: "Get total number of resumes",
description: "Get the total number of resumes for the Reactive Resume.",
description:
"Returns the total number of resumes created on this Reactive Resume instance. The count is cached for up to 6 hours for performance. No authentication required.",
successDescription: "The total number of resumes created.",
})
.output(z.number())
.output(z.number().describe("The total number of resumes created."))
.handler(async (): Promise<number> => {
return await statisticsService.resume.getCount();
}),
@@ -37,11 +43,14 @@ const githubRouter = {
.route({
method: "GET",
path: "/statistics/github/stars",
tags: ["Statistics"],
summary: "Get GitHub Repository stargazers count",
description: "Get the stargazers count for the Reactive Resume GitHub repository, at the time of writing.",
tags: ["Platform Statistics"],
operationId: "getGitHubStarCount",
summary: "Get GitHub star count",
description:
"Returns the number of GitHub stars for the Reactive Resume repository. The count is cached for up to 6 hours and falls back to a last-known value if the GitHub API is unavailable. No authentication required.",
successDescription: "The number of GitHub stars for the Reactive Resume repository.",
})
.output(z.number())
.output(z.number().describe("The number of GitHub stars."))
.handler(async (): Promise<number> => {
return await statisticsService.github.getStarCount();
}),
+28 -6
View File
@@ -7,17 +7,26 @@ const storageService = getStorageService();
const fileSchema = z.file().max(10 * 1024 * 1024, "File size must be less than 10MB");
const filenameSchema = z.object({ filename: z.string().min(1) });
const filenameSchema = z.object({
filename: z.string().min(1).describe("The path or filename of the file to delete."),
});
export const storageRouter = {
uploadFile: protectedProcedure
.route({ tags: ["Internal"], summary: "Upload a file" })
.route({
tags: ["Internal"],
operationId: "uploadFile",
summary: "Upload a file",
description:
"Uploads a file to storage. Images are automatically resized and converted to WebP format. Maximum file size is 10MB. Requires authentication.",
successDescription: "The file was uploaded successfully.",
})
.input(fileSchema)
.output(
z.object({
url: z.string(),
path: z.string(),
contentType: z.string(),
url: z.string().describe("The public URL to access the uploaded file."),
path: z.string().describe("The storage path of the uploaded file."),
contentType: z.string().describe("The MIME type of the uploaded file."),
}),
)
.handler(async ({ context, input: file }) => {
@@ -52,9 +61,22 @@ export const storageRouter = {
}),
deleteFile: protectedProcedure
.route({ tags: ["Internal"], summary: "Delete a file" })
.route({
tags: ["Internal"],
operationId: "deleteFile",
summary: "Delete a file",
description:
"Deletes a file from storage by its filename or path. If the filename does not start with 'uploads/', the user's picture directory is assumed. Requires authentication.",
successDescription: "The file was deleted successfully.",
})
.input(filenameSchema)
.output(z.void())
.errors({
NOT_FOUND: {
message: "The specified file was not found in storage.",
status: 404,
},
})
.handler(async ({ context, input }): Promise<void> => {
// The filename is now the full path from the URL (e.g., "uploads/userId/pictures/timestamp.webp")
// We need to extract just the path portion that matches the storage key
+48 -1
View File
@@ -1,15 +1,31 @@
import { createAnthropic } from "@ai-sdk/anthropic";
import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { createOpenAI } from "@ai-sdk/openai";
import { createGateway, generateText, Output } from "ai";
import { streamToEventIterator } from "@orpc/server";
import {
convertToModelMessages,
createGateway,
generateText,
Output,
stepCountIs,
streamText,
tool,
type UIMessage,
} from "ai";
import { createOllama } from "ai-sdk-ollama";
import { match } from "ts-pattern";
import type { ZodError } from "zod";
import z, { flattenError } from "zod";
import chatSystemPromptTemplate from "@/integrations/ai/prompts/chat-system.md?raw";
import docxParserSystemPrompt from "@/integrations/ai/prompts/docx-parser-system.md?raw";
import docxParserUserPrompt from "@/integrations/ai/prompts/docx-parser-user.md?raw";
import pdfParserSystemPrompt from "@/integrations/ai/prompts/pdf-parser-system.md?raw";
import pdfParserUserPrompt from "@/integrations/ai/prompts/pdf-parser-user.md?raw";
import {
executePatchResume,
patchResumeDescription,
patchResumeInputSchema,
} from "@/integrations/ai/tools/patch-resume";
import type { ResumeData } from "@/schema/resume/data";
import { defaultResumeData, resumeDataSchema } from "@/schema/resume/data";
@@ -141,8 +157,39 @@ export function formatZodError(error: ZodError): string {
return JSON.stringify(flattenError(error));
}
function buildChatSystemPrompt(resumeData: ResumeData): string {
return chatSystemPromptTemplate.replace("{{RESUME_DATA}}", JSON.stringify(resumeData, null, 2));
}
type ChatInput = z.infer<typeof aiCredentialsSchema> & {
messages: UIMessage[];
resumeData: ResumeData;
};
async function chat(input: ChatInput) {
const model = getModel(input);
const systemPrompt = buildChatSystemPrompt(input.resumeData);
const result = streamText({
model,
system: systemPrompt,
messages: await convertToModelMessages(input.messages),
tools: {
patch_resume: tool({
description: patchResumeDescription,
inputSchema: patchResumeInputSchema,
execute: async ({ operations }) => executePatchResume(input.resumeData, operations),
}),
},
stopWhen: stepCountIs(3),
});
return streamToEventIterator(result.toUIMessageStream());
}
export const aiService = {
testConnection,
parsePdf,
parseDocx,
chat,
};
+1 -28
View File
@@ -1,11 +1,9 @@
import { ORPCError } from "@orpc/client";
import { and, eq, isNotNull } from "drizzle-orm";
import { eq } from "drizzle-orm";
import type { AuthProvider } from "@/integrations/auth/types";
import { schema } from "@/integrations/drizzle";
import { db } from "@/integrations/drizzle/client";
import { env } from "@/utils/env";
import { verifyPassword } from "@/utils/password";
import { grantResumeAccess } from "../helpers/resume-access";
import { getStorageService } from "./storage";
export type ProviderList = Partial<Record<AuthProvider, string>>;
@@ -25,31 +23,6 @@ const providers = {
export const authService = {
providers,
verifyResumePassword: async (input: { slug: string; username: string; password: string }): Promise<boolean> => {
const [resume] = await db
.select({ id: schema.resume.id, password: schema.resume.password })
.from(schema.resume)
.innerJoin(schema.user, eq(schema.resume.userId, schema.user.id))
.where(
and(
isNotNull(schema.resume.password),
eq(schema.resume.slug, input.slug),
eq(schema.user.username, input.username),
),
);
if (!resume) throw new ORPCError("NOT_FOUND");
const passwordHash = resume.password as string;
const isValid = await verifyPassword(input.password, passwordHash);
if (!isValid) throw new ORPCError("INVALID_PASSWORD");
grantResumeAccess(resume.id, passwordHash);
return true;
},
deleteAccount: async (input: { userId: string }): Promise<void> => {
if (!input.userId || input.userId.length === 0) return;
+28 -3
View File
@@ -1,5 +1,5 @@
import { ORPCError } from "@orpc/client";
import { and, arrayContains, asc, desc, eq, sql } from "drizzle-orm";
import { and, arrayContains, asc, desc, eq, isNotNull, sql } from "drizzle-orm";
import { get } from "es-toolkit/compat";
import type { Operation } from "fast-json-patch";
import { match } from "ts-pattern";
@@ -9,10 +9,10 @@ import type { ResumeData } from "@/schema/resume/data";
import { defaultResumeData } from "@/schema/resume/data";
import { env } from "@/utils/env";
import type { Locale } from "@/utils/locale";
import { hashPassword } from "@/utils/password";
import { hashPassword, verifyPassword } from "@/utils/password";
import { applyResumePatches, ResumePatchError } from "@/utils/resume/patch";
import { generateId } from "@/utils/string";
import { hasResumeAccess } from "../helpers/resume-access";
import { grantResumeAccess, hasResumeAccess } from "../helpers/resume-access";
import { getStorageService } from "./storage";
const tags = {
@@ -383,6 +383,31 @@ export const resumeService = {
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
},
verifyPassword: async (input: { slug: string; username: string; password: string }) => {
const [resume] = await db
.select({ id: schema.resume.id, password: schema.resume.password })
.from(schema.resume)
.innerJoin(schema.user, eq(schema.resume.userId, schema.user.id))
.where(
and(
isNotNull(schema.resume.password),
eq(schema.resume.slug, input.slug),
eq(schema.user.username, input.username),
),
);
if (!resume) throw new ORPCError("NOT_FOUND");
const passwordHash = resume.password as string;
const isValid = await verifyPassword(input.password, passwordHash);
if (!isValid) throw new ORPCError("INVALID_PASSWORD");
grantResumeAccess(resume.id, passwordHash);
return true;
},
removePassword: async (input: { id: string; userId: string }) => {
await db
.update(schema.resume)
+7 -1
View File
@@ -1,7 +1,13 @@
import { createFileRoute } from "@tanstack/react-router";
import { auth } from "@/integrations/auth/config";
function handler({ request }: { request: Request }) {
async function handler({ request }: { request: Request }) {
if (request.method === "GET" && request.url.endsWith("/spec.json")) {
const spec = await auth.api.generateOpenAPISchema();
return Response.json(spec);
}
return auth.handler(request);
}
+14 -8
View File
@@ -2,25 +2,28 @@ import { SmartCoercionPlugin } from "@orpc/json-schema";
import { OpenAPIGenerator } from "@orpc/openapi";
import { OpenAPIHandler } from "@orpc/openapi/fetch";
import { onError } from "@orpc/server";
import { RequestHeadersPlugin } from "@orpc/server/plugins";
import { BatchHandlerPlugin, RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
import { createFileRoute } from "@tanstack/react-router";
import router from "@/integrations/orpc/router";
import { resumeDataSchema } from "@/schema/resume/data";
import { env } from "@/utils/env";
import { getLocale } from "@/utils/locale";
const openAPIHandler = new OpenAPIHandler(router, {
plugins: [
new BatchHandlerPlugin(),
new RequestHeadersPlugin(),
new StrictGetMethodPlugin(),
new SmartCoercionPlugin({
schemaConverters: [new ZodToJsonSchemaConverter()],
}),
],
interceptors: [
onError((error) => {
console.error(`ERROR [OpenAPI]: ${error}`);
}),
],
plugins: [
new RequestHeadersPlugin(),
new SmartCoercionPlugin({
schemaConverters: [new ZodToJsonSchemaConverter()],
}),
],
});
const openAPIGenerator = new OpenAPIGenerator({
@@ -34,13 +37,16 @@ async function handler({ request }: { request: Request }) {
const spec = await openAPIGenerator.generate(router, {
info: {
title: "Reactive Resume",
version: "5.0.0",
version: __APP_VERSION__,
description: "Reactive Resume API",
license: { name: "MIT", url: "https://github.com/amruthpillai/reactive-resume/blob/main/LICENSE" },
contact: { name: "Amruth Pillai", email: "hello@amruthpillai.com", url: "https://amruthpillai.com" },
},
servers: [{ url: `${env.APP_URL}/api/openapi` }],
externalDocs: { url: "https://docs.rxresu.me", description: "Reactive Resume Documentation" },
commonSchemas: {
ResumeData: { schema: resumeDataSchema },
},
components: {
securitySchemes: {
apiKey: {
+12 -2
View File
@@ -1,17 +1,27 @@
import { onError } from "@orpc/server";
import { RPCHandler } from "@orpc/server/fetch";
import { BatchHandlerPlugin, RequestHeadersPlugin } from "@orpc/server/plugins";
import {
BatchHandlerPlugin,
RequestHeadersPlugin,
SimpleCsrfProtectionHandlerPlugin,
StrictGetMethodPlugin,
} from "@orpc/server/plugins";
import { createFileRoute } from "@tanstack/react-router";
import router from "@/integrations/orpc/router";
import { getLocale } from "@/utils/locale";
const rpcHandler = new RPCHandler(router, {
plugins: [
new BatchHandlerPlugin(),
new RequestHeadersPlugin(),
new StrictGetMethodPlugin(),
new SimpleCsrfProtectionHandlerPlugin(),
],
interceptors: [
onError((error) => {
console.error(`ERROR [oRPC]: ${error}`);
}),
],
plugins: [new BatchHandlerPlugin(), new RequestHeadersPlugin()],
});
async function handler({ request }: { request: Request }) {
+1 -1
View File
@@ -44,7 +44,7 @@ function RouteComponent() {
const { redirect } = Route.useSearch();
const [showPassword, toggleShowPassword] = useToggle(false);
const { mutate: verifyPassword } = useMutation(orpc.auth.verifyResumePassword.mutationOptions());
const { mutate: verifyPassword } = useMutation(orpc.resume.verifyPassword.mutationOptions());
const [username, slug] = useMemo(() => {
const [username, slug] = redirect.split("/").slice(1) as [string, string];
@@ -19,6 +19,7 @@ import { useHotkeys } from "react-hotkeys-hook";
import { useControls } from "react-zoom-pan-pinch";
import { toast } from "sonner";
import { useCopyToClipboard } from "usehooks-ts";
import { AIChat } from "@/components/ai/chat";
import { useTemporalStore } from "@/components/resume/store/resume";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -121,6 +122,7 @@ export function BuilderDock() {
<DockIcon icon={MagnifyingGlassMinusIcon} title={t`Zoom out`} onClick={() => zoomOut(0.1)} />
<DockIcon icon={CubeFocusIcon} title={t`Center view`} onClick={() => centerView()} />
<div className="mx-1 h-8 w-px bg-border" />
<AIChat />
<DockIcon icon={LinkSimpleIcon} title={t`Copy URL`} onClick={() => onCopyUrl()} />
<DockIcon icon={FileJsIcon} title={t`Download JSON`} onClick={() => onDownloadJSON()} />
<DockIcon
+2 -2
View File
@@ -107,11 +107,11 @@ function BuilderLayout({ initialLayout, ...props }: BuilderLayoutProps) {
>
<BuilderSidebarLeft />
</ResizablePanel>
<ResizableSeparator withHandle className="z-20 border-s" />
<ResizableSeparator withHandle className="z-50 border-s" />
<ResizablePanel id="artboard" defaultSize={artboardSize} className="h-[calc(100svh-3.5rem)]">
<Outlet />
</ResizablePanel>
<ResizableSeparator withHandle className="z-20 border-e" />
<ResizableSeparator withHandle className="z-50 border-e" />
<ResizablePanel
collapsible
id="right"
File diff suppressed because one or more lines are too long
-15
View File
@@ -169,18 +169,3 @@
break-inside: avoid;
}
}
/* Fix for ScrollArea viewport width overflow */
[data-slot="scroll-area-viewport"] > div {
box-sizing: border-box !important;
display: block !important;
width: 100% !important;
min-width: 0 !important;
max-width: 100% !important;
}
[data-slot="scroll-area-viewport"] {
box-sizing: border-box !important;
width: 100% !important;
max-width: 100% !important;
}
+1 -1
View File
@@ -64,7 +64,7 @@ export function generateRandomName() {
* @param html - The HTML string to strip.
* @returns The text content without HTML tags.
*/
export function stripHtml(html: string | undefined) {
export function stripHtml(html: string | undefined): string {
if (!html) return "";
return html.replace(/<[^>]*>/g, "").trim();
}