Compare commits

...
8 Commits
Author SHA1 Message Date
Amruth Pillai 9df2a5287d chore(release): v5.1.4 2026-05-14 15:57:40 +02:00
Amruth Pillai 6d8d8f6e55 feat: add AI agent workspace (#3062)
* chore(ai): remove local AI store now that providers live server-side

The Zustand-based useAIStore has been replaced by the server-side
aiProviders oRPC router (encrypted credentials persisted in DB).
Delete the dead store + tests, drop the ./store export, and remove
zustand/immer deps which are no longer referenced anywhere in
packages/ai/src/.

* feat(agent): archive/delete actions and read-only state for agent threads

- Backend: mark archived threads as read-only in threads.get and reject
  messages.send with CONFLICT when the thread is archived.
- Frontend: render archived threads in the sidebar with muted styling and
  an Archived badge; add a per-thread dropdown menu in the chat header
  with Archive (non-destructive) and Delete (with confirmation); show a
  read-only banner above the message list that disambiguates archived
  vs. missing-resource causes; suppress the Retry and Stop buttons in
  read-only mode.
- Tests: new packages/api/src/services/agent.test.ts covering the
  archived-thread isReadOnly flag and the archived-thread send refusal.

* fix(agent): abort run on archive and verify ownership before deleting thread

- threads.archive: before flipping status, abort any in-flight run controller
  and clear the active-run state on the thread; cleanup failures are logged
  but do not block the status update.
- threads.delete: assert thread ownership via getThread before destructive
  work so an authenticated user cannot wipe another user's attachment rows
  by passing a foreign threadId.

Adds focused tests for both behaviors.

* feat(agent): display patch diffs and surface revert conflicts

Render apply_resume_patch tool messages with a status-aware card (applied/
reverted/conflicted), expandable operation list, and a Revert button that
correctly handles RESUME_VERSION_CONFLICT responses. Adds unit tests for
the inverse-patch builder and the agentService.actions.revert flow.

* chore(agent): remove out-of-scope attachment tests accidentally added in Task 6

The Task 6 commit (73ef1acca) accidentally re-introduced three attachment-
related tests that belong to a separate task:

- `buildAttachmentModelParts > converts text, image, supported binary, and
  unsupported attachments into model parts`
- `agentService.messages.send > persists the user message with file UI parts
  and links selected attachments to it` (was failing — the `ToolLoopAgent`
  mock is not callable as a constructor)
- `agentService.messages.send > rejects attachments that are missing, foreign,
  or already linked before persisting a message`

These were likely re-added during a stash recovery and were not requested
for Task 6, whose scope was limited to the `agentService.actions.revert`
flow. Remove them along with the helpers/fixtures (`buildAttachment`,
`buildActiveThread`, `selectWhereResult`, `selectOrderByResult`) that they
were the only consumers of. `selectLimitResult` is preserved because it is
used by the revert tests.

* chore(agent): configure runtime dependencies

* feat(db): add agent workspace schema

* feat(api): add agent backend services

* feat(web): add agent workspace UI

* chore(agent): remove legacy builder assistant

* test(agent): make agent stream mocks constructible

* chore(web): remove unused resume replacement hook

* feat(api): add unsafe AI base URL flag

* chore(dev): expose local services in compose

* fix(web): normalize resume preview gaps

* feat(api): improve agent tool handling

* feat(web): polish agent workspace UI

* chore: update dependencies

* fix(api,web): address PR review feedback for agent workspace

Security/correctness:
- Restrict AI provider URLs to http/https even in unsafe mode
- Stop exposing Redis on host network by default
- Make .env.local optional and drop app profile in compose.dev.yml
- Store agent attachments with private ACL on S3
- Reset provider test status when provider/model/baseURL changes
- Decouple non-agent AI endpoints from REDIS_URL requirement
- Fix JSON Patch add inverse for existing object members
- Wrap resume patch + agent action insert in db transaction
- Validate partialMessage at runtime and rate-limit attachment uploads
- Add unique index on agent_messages (thread_id, sequence)

UX/bugs:
- Mark agent thread route as ssr: false and guard SSE chunk parsing
- Show config-specific banner only on known configuration error
- Gate AI provider checks behind loading state in resume import
- Fix relative-time formatter blank gap between 45-59 seconds
- Clarify thread delete confirmation message

Polish:
- Raise ENCRYPTION_SECRET minimum to 32 characters
- Bucket AI rate limits by resumeId/threadId/messageId
- Trim form values before submitting AI provider config
- Use single key identifier and nullish-coalesce baseURL display

* fix: address ai agent review feedback

* fix: preserve mobile agent chat state

* docs: add ai agent workspace guides

* feat: introduce design system for Reactive Resume
2026-05-14 15:00:04 +02:00
JamesGoslings 22c60c64b6 fix(fonts): restore legacy local font names via metric-compatible ali… (#3057)
* fix(fonts): restore legacy local font names via metric-compatible aliases

Closes #2989.

In v5.0.x the Puppeteer renderer resolved fonts like 'Times New Roman'
or 'Arial' through the browser's font stack. The v5.1 migration to
@react-pdf/renderer requires every font to be Font.register()-ed; the
legacy local-font names were not carried over, so resumes upgraded
from v5.0.x had their typography silently replaced with IBM Plex Serif,
changing line breaks, page counts and overall layout.

This adds a render-time alias layer mapping the old names to
metric-compatible web fonts already shipped in the webfont list:

  Times New Roman → Tinos
  Cambria         → Tinos
  Arial           → Arimo
  Garamond        → EB Garamond
  Calibri         → Source Sans 3

- packages/fonts:
  - new `legacyFontAliases` map and `resolveLegacyFontAlias` helper.
  - `getFont` falls back to the alias map when the direct lookup misses,
    so any caller that asked 'is this a known family?' now answers
    truthfully for the legacy names.
  - `getFontDisplayName` is intentionally unchanged: the typography
    sidebar keeps showing the user's original choice ('Times New Roman'),
    while the renderer transparently swaps in the alias target.

- packages/pdf/use-register-fonts:
  - `resolvePdfFontFamily` returns the alias target when one applies,
    so `Font.register` runs against the right web font and templates
    receive a family name they can actually render.

Backwards compatible: families that were never aliased (Roboto, IBM
Plex Serif, the standard PDF fonts, ...) take exactly the same code
path as before. The CJK glyph fallback added in #2986 / PR #3013
continues to apply on top of the resolved primary family.

* fix(fonts): use Carlito (not Source Sans 3) as Calibri alias

Per maintainer review feedback: Carlito is metric-compatible with
Calibri, while Source Sans 3 only matches visually. Switching gives
upgraded resumes the same line widths, line breaks and page counts
they had under v5.0.x.

- packages/fonts/webfontlist.json: add Carlito (Google Fonts, weights
  400/700 + italics) so it's a registerable target.
- packages/scripts/fonts/generate.ts: add a getMetricCompatibleFonts
  helper and merge it into the output, mirroring how Computer Modern
  fonts are appended. This way regenerating the list (`pnpm generate`)
  re-emits Carlito automatically and dedupes if it ever enters the
  Google Fonts popularity slice.
- packages/fonts/src/index.ts: alias `Calibri → Carlito`.
- packages/fonts/src/index.test.ts: update alias test cases.
2026-05-14 11:36:15 +02:00
Amruth Pillai affa1d6646 docs: enhance documentation and guides with new features and updates 2026-05-14 03:38:39 +02:00
SirSKillzandAmruth Pillai c71f3b0b92 Feat: Add configurable AI provider base URL flag and update documentation (#3059)
* feat: add FLAG_ALLOW_UNSAFE_AI_BASE_URL for configurable AI provider base URLs

* feat: add FLAG_ALLOW_UNSAFE_AI_BASE_URL documentation

* fix: remove AI_ALLOWED_BASE_URLS from documentation and environment variable reference

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-05-14 03:01:37 +02:00
Amruth Pillai 6c4a4b2aa5 Render public resumes with PDF.js (#3061)
* fix(web): use native pdf viewer for public resumes

* fix(web): render public resumes with pdf.js

* chore: revert vite hook paths

* chore(web): address pdf viewer review
2026-05-14 02:49:43 +02:00
Amruth Pillai 1294d3354a feat(docker): enhance development setup with reactive_resume service and health checks 2026-05-13 15:35:08 +02:00
Claudeamruthpillaianthropic-code-agent[bot] <242468646+Claude@users.noreply.github.com>
42fc78dca1 [WIP] Fix dead link to Using Custom CSS in docs (#3056)
* Initial plan

* docs: remove reference to removed Custom CSS guide

Agent-Logs-Url: https://github.com/amruthpillai/reactive-resume/sessions/82961e42-251b-41da-80ee-7697968566f7

Co-authored-by: amruthpillai <1134738+amruthpillai@users.noreply.github.com>

---------

Co-authored-by: anthropic-code-agent[bot] <242468646+Claude@users.noreply.github.com>
Co-authored-by: amruthpillai <1134738+amruthpillai@users.noreply.github.com>
2026-05-13 11:22:25 +02:00
192 changed files with 43317 additions and 15506 deletions
+15
View File
@@ -77,6 +77,15 @@ S3_ENDPOINT="http://localhost:8333"
S3_BUCKET="reactive-resume"
S3_FORCE_PATH_STYLE="true"
# --- AI Agent Workspace (optional) ---
# Required only for the authenticated /agent workspace and saved AI providers.
REDIS_URL="redis://localhost:6379"
ENCRYPTION_SECRET="change-me-to-a-secure-agent-secret-in-production"
# Optional Cloudflare Browser Run credentials used as a URL extraction fallback for agent web access.
CLOUDFLARE_ACCOUNT_ID=""
CLOUDFLARE_API_TOKEN=""
# --- Feature Flags ---
# This flag disables new signups, both on the web app and the server.
FLAG_DISABLE_SIGNUPS="false"
@@ -89,6 +98,12 @@ FLAG_DISABLE_EMAIL_AUTH="false"
# This is useful if you are using a machine with limited resources, like a Raspberry Pi.
FLAG_DISABLE_IMAGE_PROCESSING="false"
# Allows AI providers to be configured with any base URL, including http:// and
# private/loopback addresses (e.g. http://localhost:11434 for a local Ollama instance).
# WARNING: Enabling this on a multi-tenant deployment is a Server-Side Request Forgery (SSRF)
# risk. Only enable this on a trusted, single-tenant self-hosted instance.
FLAG_ALLOW_UNSAFE_AI_BASE_URL="false"
# --- Others ---
# Google Cloud API Key (optional)
# For font-list generation tooling.
+3 -1
View File
@@ -1,6 +1,7 @@
// @ts-check
const betaPackages = ["drizzle-orm", "drizzle-kit", "drizzle-zod"];
const betaPackages = ["drizzle-zod"];
const rcPackages = ["drizzle-orm", "drizzle-kit"];
/** @type {import('npm-check-updates').RunOptions} */
module.exports = {
@@ -10,6 +11,7 @@ module.exports = {
packageManager: "pnpm",
target: (packageName) => {
if (betaPackages.includes(packageName)) return "@beta";
if (rcPackages.includes(packageName)) return "@rc";
return "latest";
},
};
+1
View File
@@ -104,3 +104,4 @@ Vitest test paths are package-relative when running through `pnpm --filter <pack
- Biome uses tabs, double quotes, line width 120, organized import groups, and sorted Tailwind classes for `clsx`, `cva`, and `cn`.
- Most packages use `tsgo --noEmit` for typechecking and `vitest run --passWithNoTests` for tests.
- There may be unrelated local edits in the worktree. Inspect `git status --short` first and avoid reverting files you did not touch.
- **New env vars require a `turbo.json` entry.** Turborepo 2.x runs in strict env mode by default — it filters out env vars that are not listed in `globalEnv` (or task-level `env`/`passThroughEnv`). Any new environment variable added to `packages/env/src/server.ts` must also be added to the `globalEnv` array in `turbo.json`, or the variable will be `undefined` inside child processes at runtime even if it is correctly set in the OS/container environment.
+347
View File
@@ -0,0 +1,347 @@
---
version: alpha
name: Reactive Resume
description: A monochrome, content-first design system for a free and open-source resume builder. Dark-by-default with light mode support.
colors:
primary: "#343434"
primary-foreground: "#FBFBFB"
secondary: "#F7F7F7"
secondary-foreground: "#343434"
background: "#FFFFFF"
foreground: "#252525"
muted: "#F7F7F7"
muted-foreground: "#8E8E8E"
card: "#FFFFFF"
card-foreground: "#252525"
border: "#EBEBEB"
input: "#EBEBEB"
ring: "#B5B5B5"
destructive: "#DC2626"
on-destructive: "#FFFFFF"
typography:
heading:
fontFamily: IBM Plex Sans Variable
fontSize: 1rem
fontWeight: 500
body:
fontFamily: IBM Plex Sans Variable
fontSize: 0.875rem
fontWeight: 400
body-sm:
fontFamily: IBM Plex Sans Variable
fontSize: 0.75rem
fontWeight: 400
label:
fontFamily: IBM Plex Sans Variable
fontSize: 0.8rem
fontWeight: 500
hero-heading:
fontFamily: IBM Plex Sans Variable
fontSize: 3.75rem
fontWeight: 700
letterSpacing: -0.025em
rounded:
sm: 0.18rem
md: 0.24rem
lg: 0.3rem
xl: 0.42rem
2xl: 0.54rem
3xl: 0.66rem
4xl: 0.78rem
spacing:
xs: 4px
sm: 8px
md: 16px
lg: 24px
xl: 32px
2xl: 48px
components:
button-default:
backgroundColor: "{colors.primary}"
textColor: "{colors.primary-foreground}"
rounded: "{rounded.lg}"
padding: 10px
height: 36px
button-outline:
backgroundColor: "{colors.background}"
textColor: "{colors.foreground}"
rounded: "{rounded.lg}"
padding: 10px
height: 36px
button-secondary:
backgroundColor: "{colors.secondary}"
textColor: "{colors.secondary-foreground}"
rounded: "{rounded.lg}"
padding: 10px
height: 36px
button-ghost:
backgroundColor: "{colors.background}"
textColor: "{colors.foreground}"
rounded: "{rounded.lg}"
padding: 10px
height: 36px
button-destructive:
backgroundColor: "{colors.destructive}"
textColor: "{colors.on-destructive}"
rounded: "{rounded.lg}"
padding: 10px
height: 36px
card:
backgroundColor: "{colors.card}"
textColor: "{colors.card-foreground}"
rounded: "{rounded.lg}"
padding: 16px
input:
backgroundColor: "{colors.background}"
textColor: "{colors.foreground}"
rounded: "{rounded.lg}"
height: 36px
padding: 10px
input-focus:
backgroundColor: "{colors.background}"
textColor: "{colors.foreground}"
rounded: "{rounded.lg}"
height: 36px
padding: 10px
badge:
backgroundColor: "{colors.primary}"
textColor: "{colors.primary-foreground}"
rounded: "{rounded.md}"
padding: 4px
popover:
backgroundColor: "{colors.card}"
textColor: "{colors.card-foreground}"
rounded: "{rounded.xl}"
padding: 4px
sidebar:
backgroundColor: "{colors.muted}"
textColor: "{colors.foreground}"
padding: 8px
sidebar-item:
backgroundColor: "{colors.muted}"
textColor: "{colors.muted-foreground}"
rounded: "{rounded.lg}"
padding: 8px
sidebar-item-active:
backgroundColor: "{colors.primary}"
textColor: "{colors.primary-foreground}"
rounded: "{rounded.lg}"
padding: 8px
tooltip:
backgroundColor: "{colors.primary}"
textColor: "{colors.primary-foreground}"
rounded: "{rounded.md}"
padding: 6px
separator:
backgroundColor: "{colors.border}"
height: 1px
dialog:
backgroundColor: "{colors.card}"
textColor: "{colors.card-foreground}"
rounded: "{rounded.xl}"
padding: 24px
input-invalid:
backgroundColor: "{colors.background}"
textColor: "{colors.destructive}"
rounded: "{rounded.lg}"
height: 36px
padding: 10px
---
## Overview
Reactive Resume is a monochrome, content-first design system built for a resume builder used by tens of thousands of people worldwide. The visual identity prioritizes readability and unobtrusiveness — the user's resume content is always the hero, never the chrome around it.
The system defaults to dark mode with a warm near-black backdrop that makes the resume preview "float" as the visual anchor. Light mode is supported as a full alternative. The authenticated app shell (dashboard, builder, settings) uses an entirely achromatic grayscale palette — the sole chromatic exception is destructive red for dangerous actions. The landing page introduces subtle chromatic accents: blue-tinted spotlight gradients on the hero, a multicolor text-mask animation on hover, and social auth provider brand colors (Google blue, LinkedIn blue) on the login page.
The overall aesthetic is a professional tool UI: clean grid lines, subtle borders, generous whitespace, and typography that steps back to let the content shine. Think "VS Code meets Figma" — a productivity workspace, not a marketing site.
One deliberate counterpoint to the serious UI: all resume templates are named after Pokemon (Azurill, Bronzor, Chikorita, Ditgar, Gengar, Pikachu, etc.). This is an intentional brand choice — playful naming for templates injects personality into an otherwise utilitarian interface, making templates feel collectible and memorable rather than generic ("Template 1", "Modern", "Classic").
## Colors
The palette is rooted in achromatic OKLch values (chroma = 0), producing a pure grayscale scale without warm or cool casts. Colors are defined as CSS custom properties using `oklch()` and consumed through Tailwind CSS 4 theme tokens. Always prefer CSS variables (e.g., `var(--primary)`) or Tailwind tokens (e.g., `bg-primary`) over raw color values. The hex values in this document's YAML front matter are agent-friendly approximations of the canonical OKLch definitions in `packages/ui/src/styles/globals.css` — use hex only where OKLch is unavailable.
- **Primary (#343434 light / #EBEBEB dark):** Used for high-emphasis interactive surfaces — default buttons, selected states, and text selection. In dark mode this inverts to near-white so buttons remain prominent.
- **Foreground (#252525 light / #FBFBFB dark):** Body text and headings. High contrast against the background in both themes.
- **Background (#FFFFFF light / #252525 dark):** The canvas. Pure white in light mode, warm near-black in dark mode.
- **Card (#FFFFFF light / #343434 dark):** Elevated surface for cards, panels, and the builder sidebar. In dark mode, one step lighter than the background to create subtle depth.
- **Muted (#F7F7F7 light / #454545 dark):** De-emphasized backgrounds for secondary UI regions, hover states, and inactive tabs.
- **Muted Foreground (#8E8E8E light / #B5B5B5 dark):** Captions, helper text, timestamps, and metadata. Deliberately low-contrast against the background to recede visually.
- **Border (#EBEBEB light / white at 10% opacity dark):** Thin separator lines. In dark mode, uses transparent white rather than a solid gray to blend naturally with any underlying surface color.
- **Input (#EBEBEB light / white at 15% opacity dark):** Form field borders, slightly more prominent than general borders to make input areas discoverable.
- **Destructive (#DC2626 light / #EF4444 dark):** The only chromatic color in the palette. Reserved exclusively for delete actions, error states, and danger-zone operations. Used at 10% opacity as a background tint with full saturation for text, creating a soft but unmistakable warning.
- **Ring (#B5B5B5 light / #8E8E8E dark):** Focus ring indicator at 50% opacity, surrounding focused interactive elements.
- **Sidebar Primary (dark only, #6366F1):** An indigo value inherited from the shadcn/ui defaults. Not actively used in the current UI — sidebar active states use the standard grayscale primary token instead. Retained in the CSS custom properties for potential future customization.
Resume templates have their own independent color system — users pick primary, text, and background colors per resume through a color picker in the builder's Design panel. These template colors are completely separate from the app shell palette.
## Typography
The entire application uses a single typeface: **IBM Plex Sans Variable**. This is a humanist sans-serif with an extensive weight range (100–900) and excellent readability at small sizes, both on screen and in PDFs.
- **Hero heading (responsive: 2.25rem mobile / 3rem tablet / 3.75rem desktop, weight 700, tracking-tight):** Landing page headline only. Large, bold, and commanding. Scales across three breakpoints.
- **Section heading (1rem / 16px, weight 500):** Used for section titles in the builder sidebar, settings panels, and dashboard cards. Medium weight provides hierarchy without shouting.
- **Body (0.875rem / 14px, weight 400):** The workhorse. All form labels, descriptions, card content, and general UI text.
- **Small body (0.75rem / 12px, weight 400):** Captions, helper text, timestamps, and metadata.
- **Label (0.8rem / ~13px, weight 500):** Button text, badge labels, and form field labels. Slightly heavier than body to denote interactivity.
The resume content itself uses a separate font system — users choose from 1,000+ Google Fonts for their resume headings and body text, with category-aware fallback stacks including CJK support (Noto Sans SC, PingFang SC, Hiragino Sans GB for sans-serif; Noto Serif SC, Songti SC for serif). Standard PDF fonts (Helvetica, Courier, Times-Roman) are available as offline fallbacks.
Font rendering uses `antialiased` (grayscale AA) and `proportional-nums` across the board for clean rendering and properly spaced numerals in dates and phone numbers.
## Layout
### Builder (Three-Panel Workspace)
The core builder uses a resizable three-panel layout powered by `react-resizable-panels`:
- **Left sidebar (default 22%):** Resume section forms — personal info, experience, education, skills, and custom sections. Scrollable with collapsible section groups.
- **Center artboard (default 56%):** Live resume preview rendered via PDF.js canvas. Supports zoom, pan, and pinch gestures via `react-zoom-pan-pinch`. The preview maintains A4 aspect ratio (210:297) with a subtle shadow to simulate a physical page.
- **Right sidebar (default 22%):** Design controls — template picker, font selection, color picker, layout manager (page assignments, section ordering via drag-and-drop).
Panel sizes persist in cookies. On mobile (< 768px), sidebars collapse to 0% width and become toggleable overlays (max 95% width when open). The desktop minimum collapsed width is 48px (icon rail).
### Dashboard
Standard sidebar navigation layout using the `Sidebar` component system. The sidebar contains: logo, resume list link, agent link, settings subnavigation (profile, preferences, authentication, API keys, integrations, danger zone), and a footer with user avatar. Content area shows a responsive grid of resume cards.
### Landing Page
Full-width single-column marketing layout:
1. **Floating builder preview** — A non-interactive screenshot of the builder as a hero visual, creating an immediate "this is what you get" impression.
2. **Hero** — Centered headline, subheadline, and two CTAs (primary "Get Started" with arrow, ghost "Learn More" with icon).
3. **Features grid** — 4-column responsive grid with icon + title + description cards, separated by thin border lines.
4. **Template carousel** — Horizontally scrolling row of template preview thumbnails with Pokemon-themed names.
5. **Testimonials** — Tiled user quotes in a masonry-style grid.
6. **Support / FAQ / Footer** — Accordion FAQ, community section, and a 4-column footer with logo, resource links, community links, and license info.
### Responsive Breakpoints
Mobile detection uses a 768px threshold via `MediaQueryList`. The layout is optimized for workspace productivity on larger screens, with responsive mobile support that adapts the multi-panel builder into a streamlined single-panel experience. Both desktop and mobile are supported experiences — the builder's three-panel layout leverages desktop space, while mobile surfaces the same editing capabilities through collapsible overlays.
### Page Aspect Ratio
A custom Tailwind token `--aspect-page: 210 / 297` enforces A4 paper proportions wherever resume pages are rendered (builder preview, public view, PDF export).
## Animation
Animations use the Motion library (formerly Framer Motion) and follow a consistent choreography pattern:
**Entrance animations** use a fade-up reveal: elements start at `opacity: 0, y: 20-100` and animate to `opacity: 1, y: 0`. The hero section uses a larger y-offset (100px) for dramatic effect; subsequent sections use 20px for subtlety.
**Timing principles:**
- **Base duration:** 0.35s–0.6s for standard section reveals, 0.45s for hero elements, up to 1.1s for the hero video entrance.
- **Stagger pattern:** Sequential delays within a group, typically 0.1s–0.15s apart (hero: 0.55s, 0.7s, 0.82s, 0.95s). For grids, use `index * 0.03`–`0.1` for per-item stagger.
- **Easing:** `easeOut` for entrances (elements decelerate into position). `easeInOut` for looping/ambient animations.
- **Performance:** Apply `will-change-[transform,opacity]` on animated elements and `will-change-transform` on continuously animated elements.
**Hover/interaction animations** are quick (0.2s) and subtle — small scale bumps (`scale: 1.01`), slight y-offsets (`y: -2`), and `active:translate-y-px` for button press.
**Ambient animations** loop infinitely with `easeInOut` — the scroll indicator bounces gently (`y: [0, 5, 0]` over 1.5s).
**Reduced motion:** All CSS transitions and animations collapse to `0.01ms` duration and single iteration when `prefers-reduced-motion: reduce` is active. Motion library animations should also respect this preference.
## Elevation & Depth
Elevation is handled through background color layering rather than drop shadows:
- **Level 0 — Background:** The base canvas (`--background`).
- **Level 1 — Card:** One step lighter in dark mode (`--card`), used for sidebars, panels, and cards.
- **Level 2 — Popover:** Same as card, but appears above the content layer in popovers, dropdowns, and command palette.
- **Level 3 — Overlay:** Backdrop blur (`backdrop-blur-xs` at 0.5px or `backdrop-blur-2xl` at 40px) with `backdrop-saturate-150` for modal overlays, creating a frosted-glass effect over the workspace.
The resume preview page uses a subtle drop shadow to simulate a physical sheet of paper floating above the dark artboard — one of the few places actual shadows appear.
## Shapes
Border radius follows a multiplicative scale from a single `--radius` base of `0.3rem`:
| Token | Value | Usage |
|:------|:------|:------|
| `sm` | 0.18rem (≈3px) | Small badges, inline chips |
| `md` | 0.24rem (≈4px) | XS/SM buttons, compact elements |
| `lg` | 0.3rem (≈5px) | Default buttons, cards, inputs |
| `xl` | 0.42rem (≈7px) | Larger cards, modal corners |
| `2xl` | 0.54rem (≈9px) | Dialog containers |
| `3xl` | 0.66rem (≈11px) | Large panels |
| `4xl` | 0.78rem (≈12px) | Full-page modals |
The radius scale is deliberately tight — the largest value (0.78rem) is still quite subtle. This avoids the "rounded everything" aesthetic and keeps the UI feeling precise and tool-like. Interactive elements consistently use `rounded-lg` as the default.
## Components
### Buttons
Six variants, all sharing `rounded-lg` corners, `font-medium`, `text-sm`, and a 1px `translate-y` on active press (except when the button opens a popup):
- **Default:** Solid primary background. The highest-emphasis action on any screen.
- **Outline:** Transparent with a border. For secondary actions that need clear boundaries.
- **Secondary:** Muted background. For paired actions alongside a primary button.
- **Ghost:** No background or border. For toolbar actions and inline controls where chrome would be noise.
- **Destructive:** Red at 10% opacity background with red text. Visually alarming without being garish.
- **Link:** Underline-on-hover text. For inline navigation within prose.
Size scale: `xs` (28px), `sm` (32px), `default` (36px), `lg` (40px), plus `icon` variants at each size for square icon-only buttons.
### Cards
White/dark surface with foreground text. Composed of `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter`, and `CardAction` slots. Default vertical padding is `py-4` (compact: `py-3`).
### Forms
Built on TanStack Form with Zod validation. Composed of `FormItem`, `FormLabel`, `FormControl`, `FormMessage`, and `FormDescription`. Validation errors only appear after field touch. Invalid fields get a red destructive border with a ring.
### Dialogs
Centralized dialog manager with 40+ dialog types, all rendered via pattern matching (`ts-pattern`). Dialogs support before-close validation, form blocking for unsaved changes, and confirmation prompts. Used for all CRUD operations on resume sections, settings changes, and import/export flows.
### Command Palette
Triggered by `Cmd+K` / `Ctrl+K`. Built on `cmdk` with fuzzy search via `Fuse.js`. Multi-page navigation (resumes, settings, preferences) with back navigation via Backspace. Screen-reader accessible with `sr-only` headings.
### Toast Notifications
Powered by Sonner, positioned bottom-right with rich colors. Used for auto-save feedback, form submission status, error reporting, and donation prompts. Loading toasts are used during async operations (PDF generation, resume creation) with dismiss-on-complete.
### Drag and Drop
Powered by `@dnd-kit` with `PointerSensor` and `KeyboardSensor`. Used in chip inputs (skill tags, URL lists) and page layout management (section ordering across resume pages). Smooth animations via Motion library.
## Internationalization
The app supports 40+ locales including RTL languages (Arabic, Hebrew, Persian, Urdu, Uyghur, Yiddish). i18n is not an afterthought — it shapes layout decisions:
**Direction:** The `<html>` element receives `dir="rtl"` or `dir="ltr"` based on the active locale, detected via `isRTL()` which checks the language prefix against a known RTL set. All layout mirroring flows from this single attribute.
**Logical properties:** Use CSS logical properties (`ps-`, `pe-`, `ms-`, `me-`, `inline-start`, `inline-end`, `inset-s-`, `inset-e-`) instead of physical (`pl-`, `pr-`, `ml-`, `mr-`, `left`, `right`). Button components already use `has-data-[icon=inline-start]:ps-2` and `has-data-[icon=inline-end]:pe-2` patterns. This ensures correct spacing in both LTR and RTL layouts without separate stylesheets.
**Variable-length text:** Translations can be 30–50% longer than English (German, Finnish) or significantly shorter (CJK). UI elements should accommodate variable text length — avoid fixed widths on buttons and labels. Use `whitespace-nowrap` only where truncation is acceptable, and prefer `min-w-0` with `truncate` over fixed-width containers.
**Icons:** Directional icons (arrows, chevrons, progress indicators) should mirror in RTL contexts. Phosphor Icons provides mirrored variants for directional icons. Non-directional icons (settings gear, checkmark, delete) do not mirror.
**Strings:** All user-facing strings use Lingui macros (`t`, `msg`, `<Trans>`) — never hardcode English text in components. Translation files are `.po` format under `/locale/`.
## Do's and Don'ts
### Do
- **Use the grayscale palette for all app chrome.** The absence of color is the brand. The resume content is the only thing that should be colorful.
- **Default to dark mode.** The dark workspace makes resume previews pop and reduces eye strain during extended editing sessions.
- **Use `text-sm` (14px) as the base text size.** The UI is information-dense — form fields, section labels, metadata — and needs to be scannable without feeling cramped.
- **Keep border radius tight.** Use `rounded-lg` (0.3rem) as the default. The tool should feel precise, not playful.
- **Respect reduced motion preferences.** All animations collapse to 0.01ms when `prefers-reduced-motion: reduce` is active.
- **Use Phosphor Icons consistently.** Regular weight, `size-4` (16px) default. Icons should be functional labels, not decorative.
- **Maintain the three-panel builder proportions.** The center artboard should always dominate. Sidebars are support panels, not equal peers.
- **Use transparent-white borders in dark mode.** `oklch(1 0 0 / 10%)` blends naturally with any surface rather than introducing a distinct gray band.
### Don't
- **Don't introduce accent colors into the app shell.** No blues, greens, or purples for primary actions. The only chromatic color is destructive red. The inherited indigo sidebar-primary token exists in CSS custom properties but is not actively used.
- **Don't use drop shadows for elevation.** Rely on background color layering and border separation. The one exception is the resume page preview shadow.
- **Don't make the UI compete with the resume content.** If a new feature draws more visual attention than the resume preview, it needs to be toned down.
- **Don't use large border radii.** Nothing above `rounded-xl` on standard components. Large pills and full-round shapes conflict with the precision-tool aesthetic.
- **Don't hardcode colors outside the token system.** All colors flow through CSS custom properties so that dark/light mode switching works automatically.
- **Don't use multiple typefaces in the app shell.** IBM Plex Sans Variable is the only UI font. Resume templates have their own font system, but the chrome stays single-family.
- **Don't skip the `data-slot` attribute on components.** It's used for styling hooks and accessibility selectors throughout the component library.
- **Don't forget RTL.** The app supports 40+ locales including Arabic, Hebrew, Persian, and Urdu. Use logical properties (`ps`, `pe`, `ms`, `me`) instead of physical (`pl`, `pr`, `ml`, `mr`).
+42
View File
@@ -0,0 +1,42 @@
# syntax=docker/dockerfile:1.7
ARG NODE_VERSION=24
FROM node:${NODE_VERSION}-slim AS dev
WORKDIR /app
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
PNPM_HOME="/pnpm" \
PATH="/pnpm:$PATH" \
NODE_ENV=development \
TURBO_TELEMETRY_DISABLED=1
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY apps/web/package.json ./apps/web/package.json
COPY packages/ai/package.json ./packages/ai/package.json
COPY packages/api/package.json ./packages/api/package.json
COPY packages/auth/package.json ./packages/auth/package.json
COPY packages/config/package.json ./packages/config/package.json
COPY packages/db/package.json ./packages/db/package.json
COPY packages/email/package.json ./packages/email/package.json
COPY packages/env/package.json ./packages/env/package.json
COPY packages/fonts/package.json ./packages/fonts/package.json
COPY packages/import/package.json ./packages/import/package.json
COPY packages/pdf/package.json ./packages/pdf/package.json
COPY packages/runtime-externals/package.json ./packages/runtime-externals/package.json
COPY packages/schema/package.json ./packages/schema/package.json
COPY packages/scripts/package.json ./packages/scripts/package.json
COPY packages/ui/package.json ./packages/ui/package.json
COPY packages/utils/package.json ./packages/utils/package.json
RUN --mount=type=cache,id=reactive-resume-dev-pnpm-store,target=/pnpm/store,sharing=locked \
pnpm install --frozen-lockfile
COPY . .
EXPOSE 3000/tcp
CMD ["pnpm", "run", "dev:web"]
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+472 -203
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+473 -205
View File
File diff suppressed because it is too large Load Diff
+449 -180
View File
File diff suppressed because it is too large Load Diff
+15 -13
View File
@@ -16,7 +16,7 @@
"lingui:extract": "lingui extract --clean --overwrite"
},
"dependencies": {
"@ai-sdk/react": "^3.0.182",
"@ai-sdk/react": "^3.0.184",
"@base-ui/react": "^1.4.1",
"@better-auth/api-key": "^1.6.11",
"@better-auth/infra": "^0.2.8",
@@ -54,20 +54,21 @@
"@tanstack/react-router": "^1.169.2",
"@tanstack/react-router-ssr-query": "^1.166.12",
"@tanstack/react-start": "^1.167.65",
"@tiptap/extension-color": "^3.23.2",
"@tiptap/extension-highlight": "^3.23.2",
"@tiptap/extension-table": "^3.23.2",
"@tiptap/extension-text-align": "^3.23.2",
"@tiptap/extension-text-style": "^3.23.2",
"@tiptap/pm": "^3.23.2",
"@tiptap/react": "^3.23.2",
"@tiptap/starter-kit": "^3.23.2",
"@tiptap/extension-color": "^3.23.4",
"@tiptap/extension-highlight": "^3.23.4",
"@tiptap/extension-table": "^3.23.4",
"@tiptap/extension-text-align": "^3.23.4",
"@tiptap/extension-text-style": "^3.23.4",
"@tiptap/pm": "^3.23.4",
"@tiptap/react": "^3.23.4",
"@tiptap/starter-kit": "^3.23.4",
"@types/js-cookie": "^3.0.6",
"@uiw/color-convert": "^2.10.1",
"@uiw/react-color-colorful": "^2.10.1",
"ai": "^6.0.182",
"better-auth": "1.6.11",
"cmdk": "^1.1.1",
"drizzle-orm": "1.0.0-beta.22",
"drizzle-orm": "1.0.0-rc.2",
"es-toolkit": "^1.46.1",
"fuse.js": "^7.3.0",
"immer": "^11.1.8",
@@ -78,7 +79,8 @@
"qrcode.react": "^4.2.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-resizable-panels": "^4.11.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.11.1",
"react-window": "^2.2.7",
"react-zoom-pan-pinch": "^4.0.3",
"sonner": "^2.0.7",
@@ -98,12 +100,12 @@
"@types/pg": "^8.20.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "7.0.0-dev.20260512.1",
"@typescript/native-preview": "7.0.0-dev.20260514.1",
"@vitejs/plugin-react": "^6.0.1",
"babel-plugin-macros": "^3.1.0",
"nitro": "3.0.260429-beta",
"typescript": "^6.0.3",
"vite": "^8.0.12",
"vite": "^8.0.13",
"vite-plugin-pwa": "^1.3.0"
}
}
@@ -1,6 +1,7 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
ChatCircleDotsIcon,
GearIcon,
HouseSimpleIcon,
KeyIcon,
@@ -44,6 +45,16 @@ export function NavigationCommandGroup() {
<Trans>Resumes</Trans>
</CommandItem>
<CommandItem
disabled={!session}
keywords={[t`Agent`, t`Artificial Intelligence`]}
value="navigation.agent"
onSelect={() => onNavigate("/agent")}
>
<ChatCircleDotsIcon />
<Trans>Agent</Trans>
</CommandItem>
<CommandItem
disabled={!session}
keywords={[t`Settings`]}
@@ -280,20 +280,6 @@ export function usePatchResume() {
return useResumeStore((state) => state.patchResume);
}
export function useReplaceResumeFromServer() {
const queryClient = useQueryClient();
const replaceResumeFromServer = useResumeStore((state) => state.replaceResumeFromServer);
return useCallback(
(resume: Resume) => {
bindRuntimeQueryClient(resume.id, queryClient);
queryClient.setQueryData(getResumeQueryKey(resume.id), resume);
replaceResumeFromServer(resume);
},
[queryClient, replaceResumeFromServer],
);
}
function useBuilderResumeSelector<T>(selector: (resume: Resume) => T): T | undefined {
const params = useParams({ strict: false }) as { resumeId?: string };
const resumeId = params.resumeId;
@@ -93,20 +93,14 @@ describe("ResumePreviewClient", () => {
previewMock.builderResumeData = resumeDataWithPageCount(3);
previewMock.toBlob.mockImplementation(() => new Promise<Blob>(() => {}));
render(<ResumePreviewClient pageGap="1rem" pageLayout="vertical" pageScale={1.25} showPageNumbers={false} />);
render(<ResumePreviewClient pageGap={16} pageLayout="vertical" pageScale={1.25} showPageNumbers={false} />);
expect(screen.getAllByRole("img", { name: /Loading resume page/ })).toHaveLength(3);
});
it("renders from explicit resume data when no builder resume is active", async () => {
render(
<ResumePreviewClient
data={sampleResumeData}
pageGap="1rem"
pageLayout="vertical"
pageScale={1.25}
showPageNumbers={false}
/>,
<ResumePreviewClient data={sampleResumeData} pageLayout="vertical" pageScale={1.25} showPageNumbers={false} />,
);
expect(await screen.findByRole("img", { name: "Resume page 1 of 1" })).toBeTruthy();
@@ -1,3 +1,4 @@
import type { CSSProperties } from "react";
import type { PreviewPageSize, ResolvedResumePreviewProps } from "./preview.shared";
import { pdf } from "@react-pdf/renderer";
import { AnimatePresence, motion } from "motion/react";
@@ -6,7 +7,7 @@ import { cn } from "@reactive-resume/utils/style";
import { useLocalizedResumeDocument } from "@/libs/resume/pdf-document";
import { useResumeData } from "./builder-resume-draft";
import { PdfCanvasDocument, PdfCanvasPage } from "./pdf-canvas";
import { getResumePreviewPageCount, ResumePreviewLoader } from "./preview.shared";
import { getResumePreviewGapValue, getResumePreviewPageCount, ResumePreviewLoader } from "./preview.shared";
type PreviewPdf = {
file: Blob;
@@ -84,7 +85,7 @@ const removePreviewLayer = (layers: PreviewPdf[], layerId: number) => layers.fil
export function ResumePreviewClient({
className,
data,
pageGap,
pageGap = 16,
pageLayout,
pageScale,
pageClassName,
@@ -133,6 +134,7 @@ export function ResumePreviewClient({
if (!resumeData) return null;
const visiblePdf = getActivePreviewLayer(previewLayers);
const resolvedPageGap = getResumePreviewGapValue(pageGap);
if (!visiblePdf) {
return (
@@ -154,8 +156,8 @@ export function ResumePreviewClient({
<motion.div
key={visiblePdf.id}
aria-hidden={visiblePdf.phase !== "active"}
style={{ "--resume-preview-page-gap": resolvedPageGap } as CSSProperties}
className={cn("col-start-1 row-start-1", visiblePdf.phase !== "active" && "pointer-events-none")}
style={{ "--resume-preview-page-gap": pageGap } as React.CSSProperties}
initial={{ opacity: visiblePdf.phase === "active" ? 1 : 0 }}
animate={{ opacity: visiblePdf.phase === "active" ? 1 : 0 }}
exit={{ opacity: 0 }}
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it } from "vitest";
import {
DEFAULT_PDF_PAGE_SIZE,
getPreviewCanvasScale,
getResumePreviewGapValue,
getScaledPreviewPageSize,
normalizeResumePreviewProps,
} from "./preview.shared";
@@ -12,7 +13,7 @@ describe("normalizeResumePreviewProps", () => {
it("applies the documented defaults when fields are omitted", () => {
const result = normalizeResumePreviewProps({});
expect(result).toMatchObject({
pageGap: 40,
pageGap: 16,
pageLayout: "horizontal",
pageScale: 1,
showPageNumbers: false,
@@ -52,6 +53,20 @@ describe("getScaledPreviewPageSize", () => {
});
});
describe("getResumePreviewGapValue", () => {
it("adds px units for numeric custom-property gap values", () => {
expect(getResumePreviewGapValue(96)).toBe("96px");
});
it("preserves explicit zero gap", () => {
expect(getResumePreviewGapValue(0)).toBe(0);
});
it("preserves string gap values", () => {
expect(getResumePreviewGapValue("1rem")).toBe("1rem");
});
});
const setDevicePixelRatio = (value: number) => {
Object.defineProperty(window, "devicePixelRatio", {
writable: true,
@@ -29,6 +29,14 @@ describe("ResumePreviewLoader", () => {
expect(screen.getAllByRole("img", { name: /Loading resume page/ })).toHaveLength(pageCount);
});
it("writes numeric page gaps as valid CSS custom-property lengths", () => {
const { container } = render(<ResumePreviewLoader pageGap={96} />);
expect((container.firstElementChild as HTMLElement).style.getPropertyValue("--resume-preview-page-gap")).toBe(
"96px",
);
});
});
describe("getResumePreviewPageCount", () => {
@@ -1,11 +1,12 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { CSSProperties } from "react";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { cn } from "@reactive-resume/utils/style";
export type ResumePreviewProps = {
className?: string;
data?: ResumeData;
pageGap?: React.CSSProperties["gap"];
pageGap?: CSSProperties["gap"];
pageLayout?: "horizontal" | "vertical";
pageScale?: number;
pageClassName?: string;
@@ -13,7 +14,6 @@ export type ResumePreviewProps = {
};
export type ResolvedResumePreviewProps = ResumePreviewProps & {
pageGap: React.CSSProperties["gap"];
pageLayout: "horizontal" | "vertical";
pageScale: number;
showPageNumbers: boolean;
@@ -26,7 +26,7 @@ export type PreviewPageSize = {
type ResumePreviewLoaderProps = Pick<ResumePreviewProps, "pageClassName" | "showPageNumbers"> & {
pageCount?: number;
pageGap?: React.CSSProperties["gap"];
pageGap?: CSSProperties["gap"];
pageLayout?: "horizontal" | "vertical";
pageScale?: number;
};
@@ -39,7 +39,7 @@ export const DEFAULT_PDF_PAGE_SIZE: PreviewPageSize = {
};
export const normalizeResumePreviewProps = ({
pageGap = 40,
pageGap = 16,
pageLayout = "horizontal",
pageScale = 1,
showPageNumbers = false,
@@ -67,25 +67,29 @@ export const getScaledPreviewPageSize = (pageSize: PreviewPageSize, pageScale: n
width: pageSize.width * pageScale,
});
export const getResumePreviewGapValue = (pageGap: CSSProperties["gap"]) =>
typeof pageGap === "number" && pageGap !== 0 ? `${pageGap}px` : pageGap;
export const getResumePreviewPageCount = (data?: ResumeData) => Math.max(1, data?.metadata.layout.pages.length ?? 1);
export function ResumePreviewLoader({
pageCount = 1,
pageClassName,
pageGap = 40,
pageGap = 16,
pageLayout = "horizontal",
pageScale = 1,
showPageNumbers = false,
}: ResumePreviewLoaderProps) {
const pageSize = getScaledPreviewPageSize(DEFAULT_PDF_PAGE_SIZE, pageScale);
const resolvedPageGap = getResumePreviewGapValue(pageGap);
return (
<div
style={{ "--resume-preview-page-gap": resolvedPageGap } as CSSProperties}
className={cn(
"flex justify-start gap-(--resume-preview-page-gap)",
pageLayout === "horizontal" ? "flex-row items-start" : "flex-col items-center",
)}
style={{ "--resume-preview-page-gap": pageGap } as React.CSSProperties}
>
{Array.from({ length: pageCount }, (_, index) => {
const pageNumber = index + 1;
+9 -15
View File
@@ -4,12 +4,11 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { DownloadSimpleIcon, FileIcon, UploadSimpleIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import z from "zod";
import { useAIStore } from "@reactive-resume/ai/store";
import { JSONResumeImporter } from "@reactive-resume/import/json-resume";
import { ReactiveResumeJSONImporter } from "@reactive-resume/import/reactive-resume-json";
import { ReactiveResumeV4JSONImporter } from "@reactive-resume/import/reactive-resume-v4-json";
@@ -91,7 +90,6 @@ function fileToBase64(file: File): Promise<string> {
export function ImportResumeDialog(_: DialogProps<"resume.import">) {
const navigate = useNavigate();
const { enabled: isAIEnabled, provider, model, apiKey, baseURL } = useAIStore();
const closeDialog = useDialogStore((state) => state.closeDialog);
const prevTypeRef = useRef<string>("");
@@ -99,6 +97,8 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
const [isLoading, setIsLoading] = useState<boolean>(false);
const { mutateAsync: importResume } = useMutation(orpc.resume.import.mutationOptions());
const { data: aiProviders, isLoading: isLoadingAiProviders } = useQuery(orpc.aiProviders.list.queryOptions());
const hasAIProvider = aiProviders?.some((provider) => provider.enabled && provider.testStatus === "success") ?? false;
const form = useAppForm({
defaultValues: {
@@ -137,23 +137,21 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
}
if (value.type === "pdf") {
if (!isAIEnabled)
throw new Error(t`This feature requires AI Integration to be enabled. Please enable it in the settings.`);
if (isLoadingAiProviders) throw new Error(t`Loading AI providers. Please try again in a moment.`);
if (!hasAIProvider)
throw new Error(t`This feature requires a tested AI provider. Please add one in the settings.`);
const base64 = await fileToBase64(value.file);
data = await client.ai.parsePdf({
provider,
model,
apiKey,
baseURL,
file: { name: value.file.name, data: base64 },
});
}
if (value.type === "docx") {
if (!isAIEnabled)
throw new Error(t`This feature requires AI Integration to be enabled. Please enable it in the settings.`);
if (isLoadingAiProviders) throw new Error(t`Loading AI providers. Please try again in a moment.`);
if (!hasAIProvider)
throw new Error(t`This feature requires a tested AI provider. Please add one in the settings.`);
const base64 = await fileToBase64(value.file);
@@ -163,10 +161,6 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
: ("application/vnd.openxmlformats-officedocument.wordprocessingml.document" as const);
data = await client.ai.parseDocx({
provider,
model,
apiKey,
baseURL,
mediaType,
file: { name: value.file.name, data: base64 },
});
+92
View File
@@ -12,10 +12,12 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as SchemaDotjsonRouteImport } from './routes/schema[.]json'
import { Route as DashboardRouteRouteImport } from './routes/dashboard/route'
import { Route as AuthRouteRouteImport } from './routes/auth/route'
import { Route as AgentRouteRouteImport } from './routes/agent/route'
import { Route as HomeRouteRouteImport } from './routes/_home/route'
import { Route as McpIndexRouteImport } from './routes/mcp/index'
import { Route as DashboardIndexRouteImport } from './routes/dashboard/index'
import { Route as AuthIndexRouteImport } from './routes/auth/index'
import { Route as AgentIndexRouteImport } from './routes/agent/index'
import { Route as HomeIndexRouteImport } from './routes/_home/index'
import { Route as TemplatesSplatRouteImport } from './routes/templates/$'
import { Route as AuthVerify2faBackupRouteImport } from './routes/auth/verify-2fa-backup'
@@ -27,6 +29,8 @@ import { Route as AuthOauthRouteImport } from './routes/auth/oauth'
import { Route as AuthLoginRouteImport } from './routes/auth/login'
import { Route as AuthForgotPasswordRouteImport } from './routes/auth/forgot-password'
import { Route as ApiHealthRouteImport } from './routes/api/health'
import { Route as AgentNewRouteImport } from './routes/agent/new'
import { Route as AgentThreadIdRouteImport } from './routes/agent/$threadId'
import { Route as DotwellKnownOpenidConfigurationRouteImport } from './routes/[.]well-known/openid-configuration'
import { Route as DotwellKnownOauthProtectedResourceRouteImport } from './routes/[.]well-known/oauth-protected-resource'
import { Route as DotwellKnownOauthAuthorizationServerRouteImport } from './routes/[.]well-known/oauth-authorization-server'
@@ -66,6 +70,11 @@ const AuthRouteRoute = AuthRouteRouteImport.update({
path: '/auth',
getParentRoute: () => rootRouteImport,
} as any)
const AgentRouteRoute = AgentRouteRouteImport.update({
id: '/agent',
path: '/agent',
getParentRoute: () => rootRouteImport,
} as any)
const HomeRouteRoute = HomeRouteRouteImport.update({
id: '/_home',
getParentRoute: () => rootRouteImport,
@@ -85,6 +94,11 @@ const AuthIndexRoute = AuthIndexRouteImport.update({
path: '/',
getParentRoute: () => AuthRouteRoute,
} as any)
const AgentIndexRoute = AgentIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AgentRouteRoute,
} as any)
const HomeIndexRoute = HomeIndexRouteImport.update({
id: '/',
path: '/',
@@ -140,6 +154,16 @@ const ApiHealthRoute = ApiHealthRouteImport.update({
path: '/api/health',
getParentRoute: () => rootRouteImport,
} as any)
const AgentNewRoute = AgentNewRouteImport.update({
id: '/new',
path: '/new',
getParentRoute: () => AgentRouteRoute,
} as any)
const AgentThreadIdRoute = AgentThreadIdRouteImport.update({
id: '/$threadId',
path: '/$threadId',
getParentRoute: () => AgentRouteRoute,
} as any)
const DotwellKnownOpenidConfigurationRoute =
DotwellKnownOpenidConfigurationRouteImport.update({
id: '/.well-known/openid-configuration',
@@ -271,6 +295,7 @@ const ApiUploadsUserIdSplatRoute = ApiUploadsUserIdSplatRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof HomeIndexRoute
'/agent': typeof AgentRouteRouteWithChildren
'/auth': typeof AuthRouteRouteWithChildren
'/dashboard': typeof DashboardRouteRouteWithChildren
'/schema.json': typeof SchemaDotjsonRoute
@@ -280,6 +305,8 @@ export interface FileRoutesByFullPath {
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRouteWithChildren
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
'/agent/$threadId': typeof AgentThreadIdRoute
'/agent/new': typeof AgentNewRoute
'/api/health': typeof ApiHealthRoute
'/auth/forgot-password': typeof AuthForgotPasswordRoute
'/auth/login': typeof AuthLoginRoute
@@ -290,6 +317,7 @@ export interface FileRoutesByFullPath {
'/auth/verify-2fa': typeof AuthVerify2faRoute
'/auth/verify-2fa-backup': typeof AuthVerify2faBackupRoute
'/templates/$': typeof TemplatesSplatRoute
'/agent/': typeof AgentIndexRoute
'/auth/': typeof AuthIndexRoute
'/dashboard/': typeof DashboardIndexRoute
'/mcp/': typeof McpIndexRoute
@@ -318,6 +346,8 @@ export interface FileRoutesByTo {
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRouteWithChildren
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
'/agent/$threadId': typeof AgentThreadIdRoute
'/agent/new': typeof AgentNewRoute
'/api/health': typeof ApiHealthRoute
'/auth/forgot-password': typeof AuthForgotPasswordRoute
'/auth/login': typeof AuthLoginRoute
@@ -329,6 +359,7 @@ export interface FileRoutesByTo {
'/auth/verify-2fa-backup': typeof AuthVerify2faBackupRoute
'/templates/$': typeof TemplatesSplatRoute
'/': typeof HomeIndexRoute
'/agent': typeof AgentIndexRoute
'/auth': typeof AuthIndexRoute
'/dashboard': typeof DashboardIndexRoute
'/mcp': typeof McpIndexRoute
@@ -353,6 +384,7 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_home': typeof HomeRouteRouteWithChildren
'/agent': typeof AgentRouteRouteWithChildren
'/auth': typeof AuthRouteRouteWithChildren
'/dashboard': typeof DashboardRouteRouteWithChildren
'/schema.json': typeof SchemaDotjsonRoute
@@ -362,6 +394,8 @@ export interface FileRoutesById {
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRouteWithChildren
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
'/agent/$threadId': typeof AgentThreadIdRoute
'/agent/new': typeof AgentNewRoute
'/api/health': typeof ApiHealthRoute
'/auth/forgot-password': typeof AuthForgotPasswordRoute
'/auth/login': typeof AuthLoginRoute
@@ -373,6 +407,7 @@ export interface FileRoutesById {
'/auth/verify-2fa-backup': typeof AuthVerify2faBackupRoute
'/templates/$': typeof TemplatesSplatRoute
'/_home/': typeof HomeIndexRoute
'/agent/': typeof AgentIndexRoute
'/auth/': typeof AuthIndexRoute
'/dashboard/': typeof DashboardIndexRoute
'/mcp/': typeof McpIndexRoute
@@ -398,6 +433,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/agent'
| '/auth'
| '/dashboard'
| '/schema.json'
@@ -407,6 +443,8 @@ export interface FileRouteTypes {
| '/.well-known/oauth-authorization-server'
| '/.well-known/oauth-protected-resource'
| '/.well-known/openid-configuration'
| '/agent/$threadId'
| '/agent/new'
| '/api/health'
| '/auth/forgot-password'
| '/auth/login'
@@ -417,6 +455,7 @@ export interface FileRouteTypes {
| '/auth/verify-2fa'
| '/auth/verify-2fa-backup'
| '/templates/$'
| '/agent/'
| '/auth/'
| '/dashboard/'
| '/mcp/'
@@ -445,6 +484,8 @@ export interface FileRouteTypes {
| '/.well-known/oauth-authorization-server'
| '/.well-known/oauth-protected-resource'
| '/.well-known/openid-configuration'
| '/agent/$threadId'
| '/agent/new'
| '/api/health'
| '/auth/forgot-password'
| '/auth/login'
@@ -456,6 +497,7 @@ export interface FileRouteTypes {
| '/auth/verify-2fa-backup'
| '/templates/$'
| '/'
| '/agent'
| '/auth'
| '/dashboard'
| '/mcp'
@@ -479,6 +521,7 @@ export interface FileRouteTypes {
id:
| '__root__'
| '/_home'
| '/agent'
| '/auth'
| '/dashboard'
| '/schema.json'
@@ -488,6 +531,8 @@ export interface FileRouteTypes {
| '/.well-known/oauth-authorization-server'
| '/.well-known/oauth-protected-resource'
| '/.well-known/openid-configuration'
| '/agent/$threadId'
| '/agent/new'
| '/api/health'
| '/auth/forgot-password'
| '/auth/login'
@@ -499,6 +544,7 @@ export interface FileRouteTypes {
| '/auth/verify-2fa-backup'
| '/templates/$'
| '/_home/'
| '/agent/'
| '/auth/'
| '/dashboard/'
| '/mcp/'
@@ -523,6 +569,7 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
HomeRouteRoute: typeof HomeRouteRouteWithChildren
AgentRouteRoute: typeof AgentRouteRouteWithChildren
AuthRouteRoute: typeof AuthRouteRouteWithChildren
DashboardRouteRoute: typeof DashboardRouteRouteWithChildren
SchemaDotjsonRoute: typeof SchemaDotjsonRoute
@@ -566,6 +613,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/agent': {
id: '/agent'
path: '/agent'
fullPath: '/agent'
preLoaderRoute: typeof AgentRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/_home': {
id: '/_home'
path: ''
@@ -594,6 +648,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthIndexRouteImport
parentRoute: typeof AuthRouteRoute
}
'/agent/': {
id: '/agent/'
path: '/'
fullPath: '/agent/'
preLoaderRoute: typeof AgentIndexRouteImport
parentRoute: typeof AgentRouteRoute
}
'/_home/': {
id: '/_home/'
path: '/'
@@ -671,6 +732,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiHealthRouteImport
parentRoute: typeof rootRouteImport
}
'/agent/new': {
id: '/agent/new'
path: '/new'
fullPath: '/agent/new'
preLoaderRoute: typeof AgentNewRouteImport
parentRoute: typeof AgentRouteRoute
}
'/agent/$threadId': {
id: '/agent/$threadId'
path: '/$threadId'
fullPath: '/agent/$threadId'
preLoaderRoute: typeof AgentThreadIdRouteImport
parentRoute: typeof AgentRouteRoute
}
'/.well-known/openid-configuration': {
id: '/.well-known/openid-configuration'
path: '/.well-known/openid-configuration'
@@ -847,6 +922,22 @@ const HomeRouteRouteWithChildren = HomeRouteRoute._addFileChildren(
HomeRouteRouteChildren,
)
interface AgentRouteRouteChildren {
AgentThreadIdRoute: typeof AgentThreadIdRoute
AgentNewRoute: typeof AgentNewRoute
AgentIndexRoute: typeof AgentIndexRoute
}
const AgentRouteRouteChildren: AgentRouteRouteChildren = {
AgentThreadIdRoute: AgentThreadIdRoute,
AgentNewRoute: AgentNewRoute,
AgentIndexRoute: AgentIndexRoute,
}
const AgentRouteRouteWithChildren = AgentRouteRoute._addFileChildren(
AgentRouteRouteChildren,
)
interface AuthRouteRouteChildren {
AuthForgotPasswordRoute: typeof AuthForgotPasswordRoute
AuthLoginRoute: typeof AuthLoginRoute
@@ -948,6 +1039,7 @@ const DotwellKnownOauthProtectedResourceRouteWithChildren =
const rootRouteChildren: RootRouteChildren = {
HomeRouteRoute: HomeRouteRouteWithChildren,
AgentRouteRoute: AgentRouteRouteWithChildren,
AuthRouteRoute: AuthRouteRouteWithChildren,
DashboardRouteRoute: DashboardRouteRouteWithChildren,
SchemaDotjsonRoute: SchemaDotjsonRoute,
@@ -0,0 +1,11 @@
.pdf-viewer .pdfViewer {
padding-block: 0;
}
.pdf-viewer .pdfViewer .page {
margin-block: 0 1rem;
}
.pdf-viewer .pdfViewer .page:last-child {
margin-bottom: 0;
}
@@ -0,0 +1,119 @@
// @vitest-environment happy-dom
import { render, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
const pdfViewerMock = vi.hoisted(() => {
const pdfDocument = {
destroy: vi.fn(),
numPages: 1,
};
const loadingTask = {
destroy: vi.fn(),
promise: Promise.resolve(pdfDocument),
};
return {
constructorOptions: [] as Array<{ abortSignal?: AbortSignal; container: HTMLDivElement }>,
createResumePdfBlob: vi.fn(async () => new Blob(["%PDF"], { type: "application/pdf" })),
getDocument: vi.fn(() => loadingTask),
instances: [] as Array<{
abortSignal?: AbortSignal;
setDocument: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
}>,
loadingTask,
pdfDocument,
};
});
vi.mock("@/libs/resume/pdf-document", () => ({
createResumePdfBlob: pdfViewerMock.createResumePdfBlob,
}));
vi.mock("pdfjs-dist/legacy/build/pdf.mjs", () => ({
AnnotationMode: { ENABLE_FORMS: 2 },
getDocument: pdfViewerMock.getDocument,
GlobalWorkerOptions: {},
}));
vi.mock("pdfjs-dist/legacy/web/pdf_viewer.mjs", () => {
class EventBus {
private readonly listeners = new Map<string, Set<() => void>>();
on(name: string, listener: () => void) {
const listeners = this.listeners.get(name) ?? new Set<() => void>();
listeners.add(listener);
this.listeners.set(name, listeners);
}
off(name: string, listener: () => void) {
this.listeners.get(name)?.delete(listener);
}
dispatch(name: string) {
for (const listener of this.listeners.get(name) ?? []) listener();
}
}
class PDFLinkService {
setDocument = vi.fn();
setViewer = vi.fn();
}
class PDFViewer {
currentScaleValue = "";
setDocument: ReturnType<typeof vi.fn>;
update = vi.fn();
constructor(options: { abortSignal?: AbortSignal; container: HTMLDivElement; eventBus: EventBus }) {
pdfViewerMock.constructorOptions.push(options);
this.setDocument = vi.fn((document: unknown) => {
if (document) options.eventBus.dispatch("pagesinit");
});
pdfViewerMock.instances.push(this);
}
}
return {
EventBus,
LinkTarget: { BLANK: 2 },
PDFLinkService,
PDFViewer,
};
});
const { PdfViewer } = await import("./pdf-viewer");
beforeEach(() => {
pdfViewerMock.constructorOptions.length = 0;
pdfViewerMock.instances.length = 0;
pdfViewerMock.createResumePdfBlob.mockClear();
pdfViewerMock.getDocument.mockClear();
pdfViewerMock.loadingTask.destroy.mockClear();
pdfViewerMock.pdfDocument.destroy.mockClear();
});
describe("PdfViewer", () => {
it("uses PDF.js' absolute container contract and disposes viewer state", async () => {
const view = render(<PdfViewer data={sampleResumeData} />);
await waitFor(() => expect(pdfViewerMock.constructorOptions).toHaveLength(1));
const [{ abortSignal, container }] = pdfViewerMock.constructorOptions;
const [viewer] = pdfViewerMock.instances;
expect(container).toHaveClass("absolute", "inset-0", "overflow-visible");
expect(abortSignal).toBeInstanceOf(AbortSignal);
expect(viewer.setDocument).toHaveBeenCalledWith(pdfViewerMock.pdfDocument);
view.unmount();
expect(abortSignal?.aborted).toBe(true);
expect(viewer.setDocument).toHaveBeenCalledWith(null);
expect(pdfViewerMock.pdfDocument.destroy).toHaveBeenCalledTimes(1);
expect(pdfViewerMock.loadingTask.destroy).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,190 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { PDFDocumentLoadingTask, PDFDocumentProxy } from "pdfjs-dist";
import { AnnotationMode, GlobalWorkerOptions, getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
import { EventBus, LinkTarget, PDFLinkService, PDFViewer } from "pdfjs-dist/legacy/web/pdf_viewer.mjs";
import { useEffect, useRef, useState } from "react";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { cn } from "@reactive-resume/utils/style";
import { createResumePdfBlob } from "@/libs/resume/pdf-document";
import "pdfjs-dist/legacy/web/pdf_viewer.css";
import "./pdf-viewer.css";
GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/legacy/build/pdf.worker.min.mjs", import.meta.url).toString();
type PdfViewerProps = {
className?: string;
data: ResumeData;
};
type PdfViewerOptions = ConstructorParameters<typeof PDFViewer>[0] & {
abortSignal: AbortSignal;
};
const clearPdfViewerDocument = (pdfViewer: PDFViewer) => {
(pdfViewer.setDocument as (document: PDFDocumentProxy | null) => void)(null);
};
export function PdfViewer({ className, data }: PdfViewerProps) {
const rootRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const viewerRef = useRef<HTMLDivElement>(null);
const [file, setFile] = useState<Blob | null>(null);
const [error, setError] = useState(false);
const [isReady, setIsReady] = useState(false);
const [viewerHeight, setViewerHeight] = useState<number | null>(null);
useEffect(() => {
let isCancelled = false;
setError(false);
setFile(null);
setIsReady(false);
setViewerHeight(null);
void createResumePdfBlob(data)
.then((blob) => {
if (!isCancelled) setFile(blob);
})
.catch((error: unknown) => {
if (!isCancelled) {
console.error("Failed to generate public resume PDF", error);
setError(true);
}
});
return () => {
isCancelled = true;
};
}, [data]);
useEffect(() => {
const root = rootRef.current;
const container = containerRef.current;
const viewer = viewerRef.current;
if (!file || !root || !container || !viewer) return;
let isCancelled = false;
let animationFrameId = 0;
let resizeObserver: ResizeObserver | undefined;
const abortController = new AbortController();
let loadingTask: PDFDocumentLoadingTask | undefined;
let pdfDocument: PDFDocumentProxy | undefined;
let pdfViewer: PDFViewer | undefined;
const eventBus = new EventBus();
const linkService = new PDFLinkService({
eventBus,
externalLinkTarget: LinkTarget.BLANK,
externalLinkRel: "noreferrer",
});
const syncViewerHeight = () => {
if (isCancelled) return;
window.cancelAnimationFrame(animationFrameId);
animationFrameId = window.requestAnimationFrame(() => {
if (isCancelled) return;
const nextHeight = Math.ceil(viewer.scrollHeight);
if (nextHeight > 0) setViewerHeight(nextHeight);
pdfViewer?.update();
});
};
const setInitialScale = () => {
if (!isCancelled && pdfViewer) {
pdfViewer.currentScaleValue = "page-width";
syncViewerHeight();
}
};
eventBus.on("pagesinit", setInitialScale);
eventBus.on("pagesloaded", syncViewerHeight);
eventBus.on("pagerendered", syncViewerHeight);
viewer.replaceChildren();
setError(false);
setIsReady(false);
setViewerHeight(null);
resizeObserver = new ResizeObserver(syncViewerHeight);
resizeObserver.observe(viewer);
const loadDocument = async () => {
const arrayBuffer = await file.arrayBuffer();
if (isCancelled) return;
loadingTask = getDocument({
data: new Uint8Array(arrayBuffer),
docBaseUrl: window.location.href,
});
const nextDocument = await loadingTask.promise;
if (isCancelled) {
void nextDocument.destroy();
return;
}
pdfDocument = nextDocument;
const pdfViewerOptions = {
annotationMode: AnnotationMode.ENABLE_FORMS,
container,
eventBus,
linkService,
removePageBorders: true,
abortSignal: abortController.signal,
viewer,
} satisfies PdfViewerOptions;
pdfViewer = new PDFViewer(pdfViewerOptions);
linkService.setViewer(pdfViewer);
pdfViewer.setDocument(pdfDocument);
linkService.setDocument(pdfDocument);
syncViewerHeight();
setIsReady(true);
};
void loadDocument().catch((error: unknown) => {
if (!isCancelled) {
console.error("Failed to render public resume PDF with PDF.js", error);
setError(true);
}
});
return () => {
isCancelled = true;
eventBus.off("pagesinit", setInitialScale);
eventBus.off("pagesloaded", syncViewerHeight);
eventBus.off("pagerendered", syncViewerHeight);
abortController.abort();
window.cancelAnimationFrame(animationFrameId);
resizeObserver?.disconnect();
if (pdfViewer) clearPdfViewerDocument(pdfViewer);
void pdfDocument?.destroy();
void loadingTask?.destroy();
viewer.replaceChildren();
};
}, [file]);
return (
<div
ref={rootRef}
className={cn("pdf-viewer relative bg-neutral-100", viewerHeight ? "min-h-0" : "min-h-48", className)}
style={viewerHeight ? { height: viewerHeight } : undefined}
>
<div ref={containerRef} className="absolute inset-0 overflow-visible">
<div ref={viewerRef} className="pdfViewer" />
</div>
{error ? (
<div className="absolute inset-0 flex items-center justify-center bg-background px-6 text-center text-muted-foreground text-sm">
Unable to display PDF preview.
</div>
) : isReady ? null : (
<div className="absolute inset-0 flex items-center justify-center bg-background">
<Spinner className="size-6" />
</div>
)}
</div>
);
}
@@ -0,0 +1,97 @@
// @vitest-environment happy-dom
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { ReactNode } from "react";
import { render, screen } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
const publicResumeMock = vi.hoisted(() => ({
createResumePdfBlob: vi.fn(async () => new Blob(["%PDF"], { type: "application/pdf" })),
downloadWithAnchor: vi.fn(),
generateFilename: vi.fn((name: string, extension: string) => `${name}.${extension}`),
PdfViewer: vi.fn<(_props: { className?: string; data: ResumeData }) => ReactNode>(() => null),
resume: undefined as
| undefined
| {
data: ResumeData;
name: string;
slug: string;
},
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: publicResumeMock.resume }),
}));
vi.mock("@tanstack/react-router", () => ({
getRouteApi: () => ({
useParams: () => ({ username: "amruth", slug: "sample" }),
}),
}));
vi.mock("@reactive-resume/utils/file", () => ({
downloadWithAnchor: publicResumeMock.downloadWithAnchor,
generateFilename: publicResumeMock.generateFilename,
}));
vi.mock("./pdf-viewer", () => ({
PdfViewer: publicResumeMock.PdfViewer,
}));
vi.mock("@/libs/orpc/client", () => ({
orpc: { resume: { getBySlug: { queryOptions: () => ({}) } } },
}));
vi.mock("@/libs/resume/pdf-document", () => ({
createResumePdfBlob: publicResumeMock.createResumePdfBlob,
}));
const { PublicResumeRoute } = await import("./public-resume");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
beforeEach(() => {
publicResumeMock.resume = {
data: sampleResumeData,
name: "Sample Resume",
slug: "sample",
};
publicResumeMock.PdfViewer.mockClear();
publicResumeMock.PdfViewer.mockImplementation(({ className }) => (
<div className={className} data-testid="pdf-viewer" />
));
});
const renderPublicResumeRoute = () =>
render(
<I18nProvider i18n={i18n}>
<PublicResumeRoute />
</I18nProvider>,
);
describe("PublicResumeRoute", () => {
it("renders the public resume through the route-local PDF.js viewer", () => {
renderPublicResumeRoute();
expect(screen.getByTestId("pdf-viewer")).toHaveClass("block", "w-full");
expect(publicResumeMock.PdfViewer).toHaveBeenCalledWith(
expect.objectContaining({ data: sampleResumeData }),
undefined,
);
});
it("lets the public resume page grow to the full PDF length", () => {
renderPublicResumeRoute();
const viewerFrame = screen.getByTestId("pdf-viewer").parentElement;
const page = viewerFrame?.parentElement;
expect(page).not.toHaveClass("min-h-svh", "h-svh", "max-h-svh", "overflow-hidden");
expect(viewerFrame).not.toHaveClass("min-h-0", "flex-1", "overflow-hidden");
});
});
@@ -8,9 +8,9 @@ import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
import { Button } from "@reactive-resume/ui/components/button";
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
import { LoadingScreen } from "@/components/layout/loading-screen";
import { ResumePreview } from "@/components/resume/preview";
import { orpc } from "@/libs/orpc/client";
import { createResumePdfBlob } from "@/libs/resume/pdf-document";
import { PdfViewer } from "./pdf-viewer";
const publicResumeRoute = getRouteApi("/$username/$slug");
@@ -43,14 +43,10 @@ export function PublicResumeRoute() {
return (
<>
<div className="mx-auto my-12 flex flex-col items-center gap-12 print:m-0 print:block print:max-w-full print:px-0">
<ResumePreview
data={resume.data}
pageGap="1rem"
pageScale={1.25}
pageLayout="vertical"
pageClassName="print:w-full! w-full max-w-full"
/>
<div className="mx-auto flex w-full flex-col items-center gap-6 px-4 py-6 print:m-0 print:block print:max-w-full print:p-0">
<div className="w-full max-w-5xl bg-white print:max-w-full">
<PdfViewer data={resume.data} className="block w-full" />
</div>
<footer className="flex justify-center print:hidden">
<BrandIcon variant="icon" className="size-8 opacity-60" />
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import {
attachmentIdsFromTransportBody,
attachmentToFilePart,
buildAgentChatSubmission,
} from "./-helpers/chat-attachments";
describe("agent chat attachment helpers", () => {
it("builds safe UI file parts without embedding file bytes", () => {
expect(attachmentToFilePart({ id: "attachment-1", filename: "resume.pdf", mediaType: "application/pdf" })).toEqual({
type: "file",
url: "agent-attachment:attachment-1",
mediaType: "application/pdf",
filename: "resume.pdf",
});
});
it("extracts only string attachment IDs from transport body metadata", () => {
expect(attachmentIdsFromTransportBody({ attachmentIds: ["a", 1, "b", null] })).toEqual(["a", "b"]);
expect(attachmentIdsFromTransportBody({ attachmentIds: "a" })).toBeUndefined();
expect(attachmentIdsFromTransportBody(undefined)).toBeUndefined();
});
it("keeps attachment IDs in transport metadata while sending file parts in the UI message", () => {
const submission = buildAgentChatSubmission(" Tailor this ", [
{ id: "attachment-1", filename: "job.txt", mediaType: "text/plain" },
{ id: "attachment-2", filename: "portfolio.png", mediaType: "image/png" },
]);
expect(submission).toEqual({
message: {
text: "Tailor this",
files: [
{
type: "file",
url: "agent-attachment:attachment-1",
mediaType: "text/plain",
filename: "job.txt",
},
{
type: "file",
url: "agent-attachment:attachment-2",
mediaType: "image/png",
filename: "portfolio.png",
},
],
},
options: { body: { attachmentIds: ["attachment-1", "attachment-2"] } },
});
});
it("supports attachment-only submissions", () => {
const submission = buildAgentChatSubmission(" ", [
{ id: "attachment-1", filename: "job.txt", mediaType: "text/plain" },
]);
expect(submission.message).toEqual({
files: [
{
type: "file",
url: "agent-attachment:attachment-1",
mediaType: "text/plain",
filename: "job.txt",
},
],
});
expect(submission.options).toEqual({ body: { attachmentIds: ["attachment-1"] } });
});
});
@@ -0,0 +1,209 @@
import type { AIProvider } from "@reactive-resume/ai/types";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon, ChatCircleDotsIcon, FilePlusIcon, GearSixIcon } from "@phosphor-icons/react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { useIsClient } from "usehooks-ts";
import { Badge } from "@reactive-resume/ui/components/badge";
import { Button } from "@reactive-resume/ui/components/button";
import { Label } from "@reactive-resume/ui/components/label";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { Combobox } from "@/components/ui/combobox";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
function providerLabel(provider: { label: string; provider: AIProvider; model: string }) {
return `${provider.label} · ${provider.provider} · ${provider.model}`;
}
function isAgentConfigError(error: unknown) {
if (!error || typeof error !== "object") return false;
const message = (error as { message?: unknown }).message;
const status = (error as { status?: unknown; code?: unknown }).status ?? (error as { code?: unknown }).code;
if (status === "PRECONDITION_FAILED" || status === 412) return true;
return typeof message === "string" && /REDIS_URL|ENCRYPTION_SECRET/.test(message);
}
export function NewThreadSetup({ resumeId }: { resumeId?: string }) {
const isClient = useIsClient();
const navigate = useNavigate();
const {
data: providers,
isLoading: isLoadingProviders,
error: providersError,
} = useQuery(orpc.aiProviders.list.queryOptions());
const { data: resumes, isLoading: isLoadingResumes } = useQuery(
orpc.resume.list.queryOptions({ input: { sort: "lastUpdatedAt", tags: [] } }),
);
const { mutate: createThread, isPending } = useMutation(orpc.agent.threads.create.mutationOptions());
const usableProviders = useMemo(
() => providers?.filter((provider) => provider.enabled && provider.testStatus === "success") ?? [],
[providers],
);
const [aiProviderId, setAiProviderId] = useState<string | null>(null);
const [sourceResumeId, setSourceResumeId] = useState<string | null>(resumeId ?? null);
useEffect(() => {
if (aiProviderId || usableProviders.length === 0) return;
setAiProviderId(usableProviders[0]?.id ?? null);
}, [aiProviderId, usableProviders]);
useEffect(() => {
setSourceResumeId(resumeId ?? null);
}, [resumeId]);
const providerOptions = usableProviders.map((provider) => ({
value: provider.id,
label: providerLabel(provider),
keywords: [provider.label, provider.provider, provider.model],
}));
const resumeOptions = [
{ value: "__scratch__", label: t`Create from scratch` },
...(resumes?.map((resume) => ({
value: resume.id,
label: resume.name,
keywords: [resume.name, resume.slug, ...resume.tags],
})) ?? []),
];
const selectedResumeValue = sourceResumeId ?? "__scratch__";
const canCreate = !!aiProviderId && usableProviders.length > 0;
if (!isClient) return null;
return (
<div className="mx-auto grid w-full max-w-4xl gap-6 self-center p-4 lg:p-6">
<div className="flex items-start gap-4">
<div className="grid size-12 shrink-0 place-items-center rounded-md border bg-card shadow-sm lg:size-14">
<ChatCircleDotsIcon className="size-6 text-foreground" weight="fill" />
</div>
<div className="min-w-0">
<h1 className="font-semibold text-3xl tracking-tight lg:text-4xl">
<Trans>Start a thread</Trans>
</h1>
<p className="mt-1 text-muted-foreground">
<Trans>Choose a model and resume draft.</Trans>
</p>
</div>
</div>
{providersError ? (
<div className="rounded-md border border-amber-300 bg-amber-50 p-4 text-amber-950 text-sm dark:bg-amber-950/20 dark:text-amber-200">
{isAgentConfigError(providersError) ? (
<Trans>AI agent setup is unavailable until REDIS_URL and ENCRYPTION_SECRET are configured.</Trans>
) : (
<Trans>AI agent setup is unavailable right now. Please try again in a moment.</Trans>
)}
</div>
) : null}
<div className="rounded-md border bg-card p-4 shadow-sm lg:p-6">
<div className="grid gap-x-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="relative isolate min-h-32 overflow-hidden rounded-md p-1 lg:p-2">
<span
aria-hidden="true"
className="pointer-events-none absolute -top-7 right-1 -z-10 select-none font-black text-8xl text-foreground/[0.045] leading-none lg:-top-10 lg:right-3 lg:text-[8rem]"
>
1
</span>
<div className="space-y-3">
<Label>
<Trans>Select an agent model</Trans>
</Label>
<Combobox
value={aiProviderId}
options={providerOptions}
disabled={isLoadingProviders || providerOptions.length === 0}
placeholder={isLoadingProviders ? t`Loading providers...` : t`Select a tested provider`}
onValueChange={setAiProviderId}
/>
{providerOptions.length === 0 && !isLoadingProviders ? (
<div className="flex flex-col gap-3 rounded-md border border-dashed p-3 text-sm lg:flex-row lg:items-center lg:justify-between">
<span className="text-muted-foreground">
<Trans>Add and test a provider before starting a thread.</Trans>
</span>
<Button
size="sm"
variant="outline"
nativeButton={false}
render={<Link to="/dashboard/settings/integrations" />}
>
<GearSixIcon />
<Trans>Settings</Trans>
</Button>
</div>
) : null}
</div>
</div>
<div className="relative isolate min-h-32 overflow-hidden rounded-md p-1 lg:p-2">
<span
aria-hidden="true"
className="pointer-events-none absolute -top-7 right-1 -z-10 select-none font-black text-8xl text-foreground/[0.045] leading-none lg:-top-10 lg:right-3 lg:text-[8rem]"
>
2
</span>
<div className="space-y-3">
<Label>
<Trans>Select a resume</Trans>
</Label>
<Combobox
value={selectedResumeValue}
showClear={false}
options={resumeOptions}
disabled={isLoadingResumes}
placeholder={isLoadingResumes ? t`Loading resumes...` : t`Choose a resume`}
onValueChange={(value) => setSourceResumeId(value && value !== "__scratch__" ? value : null)}
/>
<div className="flex flex-wrap items-center gap-2 text-muted-foreground text-sm">
<Badge variant="secondary" className="h-7 gap-1.5 rounded-md px-2">
<FilePlusIcon />
{sourceResumeId ? <Trans>Duplicate as AI Draft</Trans> : <Trans>Blank draft</Trans>}
</Badge>
</div>
</div>
</div>
</div>
<div className="mt-2 flex border-t pt-5 lg:justify-end">
<Button
size="lg"
className="h-11 w-full gap-2 px-5 lg:w-auto"
disabled={!canCreate || isPending}
onClick={() =>
createThread(
{
...(aiProviderId ? { aiProviderId } : {}),
...(sourceResumeId ? { sourceResumeId } : {}),
},
{
onSuccess: (thread) => {
void navigate({ to: "/agent/$threadId", params: { threadId: thread.id } });
},
onError: (error) =>
toast.error(
getOrpcErrorMessage(error, {
byCode: {
PRECONDITION_FAILED: t`AI agent setup is unavailable until REDIS_URL and ENCRYPTION_SECRET are configured.`,
BAD_REQUEST: t`Select a tested provider before starting a thread.`,
},
fallback: t`Failed to start agent thread.`,
}),
),
},
)
}
>
<Trans>Start Thread</Trans>
{isPending ? <Spinner /> : <ArrowRightIcon />}
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,217 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import {
ArchiveIcon,
ArrowLeftIcon,
ChatCircleDotsIcon,
DotsThreeVerticalIcon,
PlusIcon,
TrashIcon,
} from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
import { Button } from "@reactive-resume/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
import { cn } from "@reactive-resume/utils/style";
import { useConfirm } from "@/hooks/use-confirm";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
type AgentThreadSummary = RouterOutput["agent"]["threads"]["list"][number];
function formatRelativeTime(value: Date | string, locale: string) {
const date = value instanceof Date ? value : new Date(value);
const diffMs = date.getTime() - Date.now();
const absMs = Math.abs(diffMs);
const divisions: Array<{ amount: number; unit: Intl.RelativeTimeFormatUnit }> = [
{ amount: 31_536_000_000, unit: "year" },
{ amount: 2_592_000_000, unit: "month" },
{ amount: 604_800_000, unit: "week" },
{ amount: 86_400_000, unit: "day" },
{ amount: 3_600_000, unit: "hour" },
{ amount: 60_000, unit: "minute" },
];
if (absMs < 60_000) return new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(0, "second");
const division = divisions.find((candidate) => absMs >= candidate.amount);
if (!division) return "";
return new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(
Math.round(diffMs / division.amount),
division.unit,
);
}
function ThreadActions({ thread, activeThreadId }: { thread: AgentThreadSummary; activeThreadId: string | null }) {
const navigate = useNavigate();
const confirm = useConfirm();
const queryClient = useQueryClient();
const archiveMutation = useMutation(orpc.agent.threads.archive.mutationOptions());
const deleteMutation = useMutation(orpc.agent.threads.delete.mutationOptions());
const isArchived = thread.status === "archived";
const handleArchive = () => {
archiveMutation.mutate(
{ id: thread.id },
{
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: orpc.agent.threads.list.queryKey() });
if (activeThreadId === thread.id) {
await queryClient.invalidateQueries({
queryKey: orpc.agent.threads.get.queryKey({ input: { id: thread.id } }),
});
}
},
onError: (error) => toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to archive thread.` })),
},
);
};
const handleDelete = async () => {
const confirmed = await confirm(t`Delete this agent thread?`, {
description: t`This action cannot be undone. Messages and thread attachments will be removed.`,
});
if (!confirmed) return;
deleteMutation.mutate(
{ id: thread.id },
{
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: orpc.agent.threads.list.queryKey() });
if (activeThreadId === thread.id) void navigate({ to: "/agent" });
},
onError: (error) => toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to delete thread.` })),
},
);
};
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
size="icon-sm"
variant="ghost"
className="absolute end-1.5 top-2 opacity-60 transition-opacity hover:opacity-100 focus-visible:opacity-100 group-hover/thread:opacity-100 aria-expanded:opacity-100"
>
<DotsThreeVerticalIcon />
<span className="sr-only">
<Trans>Thread actions</Trans>
</span>
</Button>
}
/>
<DropdownMenuContent align="end">
{!isArchived ? (
<DropdownMenuItem disabled={archiveMutation.isPending} onClick={handleArchive}>
<ArchiveIcon />
<Trans>Archive</Trans>
</DropdownMenuItem>
) : null}
<DropdownMenuItem variant="destructive" disabled={deleteMutation.isPending} onClick={() => void handleDelete()}>
<TrashIcon />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function ThreadRow({ thread, activeThreadId }: { thread: AgentThreadSummary; activeThreadId: string | null }) {
const { i18n } = useLingui();
const isActive = thread.id === activeThreadId;
const isArchived = thread.status === "archived";
const title = thread.title === thread.resumeName ? t`New thread` : thread.title;
return (
<div
className={cn(
"group/thread relative rounded-md transition-colors hover:bg-accent",
isActive && "bg-accent",
isArchived && "opacity-60",
)}
>
<Link
to="/agent/$threadId"
params={{ threadId: thread.id }}
className="block min-w-0 rounded-md px-3 py-2 pe-10 text-sm outline-hidden ring-ring focus-visible:ring-2"
>
<div className="truncate font-medium">{title}</div>
<div className="truncate text-muted-foreground text-xs">
{formatRelativeTime(thread.lastMessageAt, i18n.locale)}
</div>
</Link>
<ThreadActions thread={thread} activeThreadId={activeThreadId} />
</div>
);
}
export function AgentThreadSidebar({
activeThreadId = null,
className,
}: {
activeThreadId?: string | null;
className?: string;
}) {
const { data: threads, isLoading } = useQuery(orpc.agent.threads.list.queryOptions());
return (
<aside className={cn("flex h-full min-h-0 flex-col border-e bg-muted/30", className)}>
<div className="flex h-14 shrink-0 items-center justify-between gap-3 border-b px-3">
<div className="flex min-w-0 items-center gap-2">
<ChatCircleDotsIcon className="shrink-0" />
<div className="min-w-0 truncate font-semibold">
<Trans>Threads</Trans>
</div>
</div>
<Button size="icon-sm" variant="ghost" nativeButton={false} render={<Link to="/dashboard/resumes" />}>
<ArrowLeftIcon />
<span className="sr-only">
<Trans>Back to resumes</Trans>
</span>
</Button>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-1 p-2">
<Button
variant="ghost"
className="mb-2 w-full justify-start border border-dashed bg-background/40 text-muted-foreground hover:text-foreground"
nativeButton={false}
render={<Link to="/agent/new" />}
>
<PlusIcon />
<Trans>New thread</Trans>
</Button>
{isLoading ? (
<div className="px-3 py-2 text-muted-foreground text-sm">
<Trans>Loading threads...</Trans>
</div>
) : null}
{threads?.length === 0 ? (
<div className="rounded-md border border-dashed p-3 text-muted-foreground text-sm">
<Trans>No threads yet.</Trans>
</div>
) : null}
{threads?.map((thread) => (
<ThreadRow key={thread.id} thread={thread} activeThreadId={activeThreadId} />
))}
</div>
</ScrollArea>
</aside>
);
}
@@ -0,0 +1,38 @@
import type { FileUIPart } from "ai";
export type ChatAttachment = {
id: string;
filename: string;
mediaType: string;
};
export function attachmentToFilePart(attachment: ChatAttachment): FileUIPart {
return {
type: "file",
url: `agent-attachment:${attachment.id}`,
mediaType: attachment.mediaType,
filename: attachment.filename,
};
}
export function attachmentIdsFromTransportBody(body: object | undefined) {
return body && "attachmentIds" in body && Array.isArray(body.attachmentIds)
? body.attachmentIds.filter((id): id is string => typeof id === "string")
: undefined;
}
export function buildAgentChatSubmission(text: string, pendingAttachments: ChatAttachment[]) {
const trimmedText = text.trim();
const files = pendingAttachments.map(attachmentToFilePart);
const attachmentIds = pendingAttachments.map((attachment) => attachment.id);
const options = { body: { attachmentIds } };
if (trimmedText) {
return {
message: files.length > 0 ? { text: trimmedText, files } : { text: trimmedText },
options,
};
}
return { message: { files }, options };
}
+44
View File
@@ -0,0 +1,44 @@
import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon, ChatCircleDotsIcon } from "@phosphor-icons/react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { Button } from "@reactive-resume/ui/components/button";
import { AgentThreadSidebar } from "./-components/thread-sidebar";
export const Route = createFileRoute("/agent/")({
component: RouteComponent,
});
function RouteComponent() {
return (
<div className="flex h-svh bg-background">
<div className="w-72 shrink-0">
<AgentThreadSidebar />
</div>
<main className="grid min-w-0 flex-1 place-items-center p-6">
<div className="w-full max-w-xl rounded-md border bg-card p-6 shadow-sm">
<div className="flex items-start gap-4">
<div className="grid size-11 shrink-0 place-items-center rounded-md border bg-background">
<ChatCircleDotsIcon className="size-5" weight="fill" />
</div>
<div className="min-w-0 space-y-2">
<h1 className="font-semibold text-2xl tracking-tight">
<Trans>Select a thread</Trans>
</h1>
<p className="text-muted-foreground text-sm">
<Trans>Choose an existing conversation from the sidebar, or start a new draft-focused thread.</Trans>
</p>
</div>
</div>
<div className="mt-6 flex justify-end border-t pt-4">
<Button nativeButton={false} render={<Link to="/agent/new" />}>
<ArrowRightIcon />
<Trans>Start new thread</Trans>
</Button>
</div>
</div>
</main>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { createFileRoute } from "@tanstack/react-router";
import z from "zod";
import { NewThreadSetup } from "./-components/new-thread-setup";
import { AgentThreadSidebar } from "./-components/thread-sidebar";
const searchSchema = z.object({ resumeId: z.string().optional() });
export const Route = createFileRoute("/agent/new")({
component: RouteComponent,
validateSearch: searchSchema,
});
function RouteComponent() {
const { resumeId } = Route.useSearch();
return (
<div className="flex h-svh bg-background">
<div className="w-72 shrink-0">
<AgentThreadSidebar />
</div>
<main className="grid min-w-0 flex-1 overflow-auto">
<NewThreadSetup resumeId={resumeId} />
</main>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/agent")({
component: RouteComponent,
beforeLoad: async ({ context }) => {
if (!context.session) throw redirect({ to: "/auth/login", replace: true });
return { session: context.session };
},
});
function RouteComponent() {
return <Outlet />;
}
@@ -1,28 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { useBuilderAssistantStore } from "./assistant-store";
afterEach(() => useBuilderAssistantStore.setState({ isOpen: false }));
describe("useBuilderAssistantStore", () => {
it("starts closed", () => {
expect(useBuilderAssistantStore.getState().isOpen).toBe(false);
});
it("setOpen overrides the open state directly", () => {
useBuilderAssistantStore.getState().setOpen(true);
expect(useBuilderAssistantStore.getState().isOpen).toBe(true);
useBuilderAssistantStore.getState().setOpen(false);
expect(useBuilderAssistantStore.getState().isOpen).toBe(false);
});
it("toggleOpen flips the state", () => {
const { toggleOpen } = useBuilderAssistantStore.getState();
toggleOpen();
expect(useBuilderAssistantStore.getState().isOpen).toBe(true);
toggleOpen();
expect(useBuilderAssistantStore.getState().isOpen).toBe(false);
});
});
@@ -1,13 +0,0 @@
import { create } from "zustand/react";
type BuilderAssistantStore = {
isOpen: boolean;
setOpen: (isOpen: boolean) => void;
toggleOpen: () => void;
};
export const useBuilderAssistantStore = create<BuilderAssistantStore>((set) => ({
isOpen: false,
setOpen: (isOpen) => set({ isOpen }),
toggleOpen: () => set((state) => ({ isOpen: !state.isOpen })),
}));
File diff suppressed because it is too large Load Diff
@@ -14,6 +14,7 @@ import {
MagnifyingGlassMinusIcon,
MagnifyingGlassPlusIcon,
} from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router";
import { motion } from "motion/react";
import { useCallback, useMemo, useState } from "react";
import { useControls } from "react-zoom-pan-pinch";
@@ -27,7 +28,6 @@ import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume } from "@/components/resume/builder-resume-draft";
import { authClient } from "@/libs/auth/client";
import { createResumePdfBlob } from "@/libs/resume/pdf-document";
import { useBuilderAssistantStore } from "./assistant-store";
type BuilderDockProps = {
pageLayout: BuilderPreviewPageLayout;
@@ -37,13 +37,12 @@ type BuilderDockProps = {
export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps) {
const { data: session } = authClient.useSession();
const resume = useCurrentResume();
const navigate = useNavigate();
const [_, copyToClipboard] = useCopyToClipboard();
const { zoomIn, zoomOut, centerView } = useControls();
const [isPrinting, setIsPrinting] = useState(false);
const isAssistantOpen = useBuilderAssistantStore((state) => state.isOpen);
const toggleAssistant = useBuilderAssistantStore((state) => state.toggleOpen);
const publicUrl = useMemo(() => {
if (!session?.user.username || !resume?.slug) return "";
@@ -114,9 +113,11 @@ export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps
/>
<DockIcon
icon={ChatCircleDotsIcon}
title={isAssistantOpen ? t`Close AI assistant` : t`Open AI assistant`}
onClick={toggleAssistant}
active={isAssistantOpen}
title={t`Open AI agent`}
onClick={() => {
if (!resume) return;
void navigate({ to: "/agent/new", search: { resumeId: resume.id } });
}}
/>
<div className="mx-1 h-8 w-px bg-border" />
<DockIcon icon={LinkSimpleIcon} title={t`Copy URL`} onClick={() => onCopyUrl()} />
@@ -28,7 +28,7 @@ export function PreviewPage() {
wheel={{ step: 0.001 }}
>
<TransformComponent wrapperClass="h-full! w-full!">
<ResumePreview pageGap="2rem" pageLayout={pageLayout} showPageNumbers />
<ResumePreview showPageNumbers pageLayout={pageLayout} />
</TransformComponent>
<BuilderDock
@@ -6,7 +6,6 @@ import { Link } from "@tanstack/react-router";
import { useMemo } from "react";
import { toast } from "sonner";
import { match } from "ts-pattern";
import { useAIStore } from "@reactive-resume/ai/store";
import { Alert, AlertDescription } from "@reactive-resume/ui/components/alert";
import { Badge } from "@reactive-resume/ui/components/badge";
import { Button } from "@reactive-resume/ui/components/button";
@@ -50,13 +49,11 @@ export function ResumeAnalysisSectionBuilder() {
const queryClient = useQueryClient();
const resume = useResume();
const aiEnabled = useAIStore((state) => state.enabled);
const aiProvider = useAIStore((state) => state.provider);
const aiModel = useAIStore((state) => state.model);
const aiApiKey = useAIStore((state) => state.apiKey);
const aiBaseURL = useAIStore((state) => state.baseURL);
const resumeId = resume?.id ?? "";
const providersQuery = useQuery(orpc.aiProviders.list.queryOptions());
const aiEnabled =
providersQuery.data?.some((provider) => provider.enabled && provider.testStatus === "success") ?? false;
const analysisQuery = useQuery({
...orpc.resume.analysis.getById.queryOptions({ input: { id: resumeId } }),
@@ -106,12 +103,7 @@ export function ResumeAnalysisSectionBuilder() {
if (!resume) return;
analyzeResume({
provider: aiProvider,
model: aiModel,
apiKey: aiApiKey,
baseURL: aiBaseURL,
resumeId: resume.id,
resumeData: resume.data,
});
};
@@ -17,7 +17,6 @@ import {
} from "@/components/resume/builder-resume-draft";
import { useIsMobile } from "@/hooks/use-mobile";
import { orpc } from "@/libs/orpc/client";
import { BuilderAssistant } from "./-components/assistant";
import { BuilderHeader } from "./-components/header";
import { BuilderSidebarLeft } from "./-sidebar/left";
import { BuilderSidebarRight } from "./-sidebar/right";
@@ -168,8 +167,6 @@ function BuilderLayoutShell({ initialLayout }: BuilderLayoutShellProps) {
<BuilderSidebarRight />
</ResizablePanel>
</ResizableGroup>
<BuilderAssistant />
</div>
);
}
@@ -4,6 +4,7 @@ import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import {
BrainIcon,
ChatCircleDotsIcon,
GearSixIcon,
KeyIcon,
ReadCvLogoIcon,
@@ -46,6 +47,11 @@ const appSidebarItems = [
label: msg`Resumes`,
href: "/dashboard/resumes",
},
{
icon: <ChatCircleDotsIcon />,
label: msg`Agents`,
href: "/agent",
},
] as const satisfies SidebarItem[];
const settingsSidebarItems = [
@@ -1,249 +1,355 @@
import type { AIProvider } from "@reactive-resume/ai/types";
import type { ComboboxOption } from "@/components/ui/combobox";
import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { CheckCircleIcon, InfoIcon, XCircleIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { useMemo } from "react";
import { ORPCError } from "@orpc/client";
import { CheckCircleIcon, KeyIcon, PlusIcon, TrashIcon, WarningCircleIcon, XCircleIcon } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { useAIStore } from "@reactive-resume/ai/store";
import { AI_PROVIDER_DEFAULT_BASE_URLS } from "@reactive-resume/ai/types";
import { Badge } from "@reactive-resume/ui/components/badge";
import { Button } from "@reactive-resume/ui/components/button";
import { Input } from "@reactive-resume/ui/components/input";
import { Label } from "@reactive-resume/ui/components/label";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { Switch } from "@reactive-resume/ui/components/switch";
import { cn } from "@reactive-resume/utils/style";
import { Combobox } from "@/components/ui/combobox";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
type SavedProvider = RouterOutput["aiProviders"]["list"][number];
type AIProviderOption = ComboboxOption<AIProvider> & { defaultBaseURL: string };
const providerOptions: AIProviderOption[] = [
{
value: "openai",
label: t({
comment: "AI provider option label in dashboard AI settings",
message: "OpenAI",
}),
label: t`OpenAI`,
keywords: ["openai", "gpt", "chatgpt"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.openai,
},
{
value: "anthropic",
label: t({
comment: "AI provider option label in dashboard AI settings",
message: "Anthropic Claude",
}),
label: t`Anthropic Claude`,
keywords: ["anthropic", "claude", "ai"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.anthropic,
},
{
value: "gemini",
label: t({
comment: "AI provider option label in dashboard AI settings",
message: "Google Gemini",
}),
keywords: ["gemini", "google", "bard"],
label: t`Google Gemini`,
keywords: ["gemini", "google"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.gemini,
},
{
value: "vercel-ai-gateway",
label: t({
comment: "AI provider option label in dashboard AI settings",
message: "Vercel AI Gateway",
}),
label: t`Vercel AI Gateway`,
keywords: ["vercel", "gateway", "ai"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS["vercel-ai-gateway"],
},
{
value: "openrouter",
label: t({
comment: "AI provider option label in dashboard AI settings",
message: "OpenRouter",
}),
keywords: ["openrouter", "router", "multi", "proxy"],
label: t`OpenRouter`,
keywords: ["openrouter", "router"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.openrouter,
},
{
value: "ollama",
label: t({
comment: "AI provider option label in dashboard AI settings",
message: "Ollama",
}),
keywords: ["ollama", "ai", "local"],
label: t`Ollama`,
keywords: ["ollama", "local"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.ollama,
},
{
value: "openai-compatible",
label: t`OpenAI-compatible`,
keywords: ["compatible", "custom", "gateway"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS["openai-compatible"],
},
];
function isValidOptionalBaseURL(value: string) {
const trimmedValue = value.trim();
if (!trimmedValue) return true;
const emptyForm = {
label: "",
provider: "openai" as AIProvider,
model: "",
baseURL: "",
apiKey: "",
};
try {
return new URL(trimmedValue).protocol === "https:";
} catch {
return false;
function statusBadge(provider: SavedProvider) {
if (provider.testStatus === "success") {
return (
<Badge className="bg-emerald-600 text-white">
<Trans>Tested</Trans>
</Badge>
);
}
if (provider.testStatus === "failure") {
return (
<Badge variant="destructive">
<Trans>Failed</Trans>
</Badge>
);
}
return (
<Badge variant="secondary">
<Trans>Untested</Trans>
</Badge>
);
}
function AIForm() {
const { set, model, apiKey, baseURL, provider, enabled, testStatus } = useAIStore();
function providerLabel(provider: AIProvider) {
return providerOptions.find((option) => option.value === provider)?.label ?? provider;
}
const selectedOption = useMemo(() => {
return providerOptions.find((option) => option.value === provider);
}, [provider]);
function isAiProviderConfigError(error: unknown) {
if (error instanceof ORPCError && error.code === "PRECONDITION_FAILED") return true;
const canTestConnection = model.trim().length > 0 && apiKey.trim().length > 0 && isValidOptionalBaseURL(baseURL);
if (!error || typeof error !== "object") return false;
const status = (error as { status?: unknown; code?: unknown }).status ?? (error as { code?: unknown }).code;
return status === "PRECONDITION_FAILED" || status === 412;
}
const { mutate: testConnection, isPending: isTesting } = useMutation(orpc.ai.testConnection.mutationOptions());
const handleProviderChange = (value: AIProvider | null) => {
if (!value) return;
set((draft) => {
draft.provider = value;
});
};
const handleTestConnection = () => {
if (!canTestConnection) return;
testConnection(
{ provider, model: model.trim(), apiKey: apiKey.trim(), baseURL: baseURL.trim() },
{
onSuccess: (data) => {
set((draft) => {
draft.testStatus = data ? "success" : "failure";
});
},
onError: (error) => {
set((draft) => {
draft.testStatus = "failure";
});
toast.error(
getOrpcErrorMessage(error, {
byCode: {
BAD_REQUEST: t({
comment: "Error shown when AI provider credentials or base URL are invalid in AI settings",
message: "Invalid AI provider configuration. Please check your settings.",
}),
BAD_GATEWAY: t({
comment: "Error shown when the configured AI provider cannot be reached during connection test",
message: "Could not reach the AI provider. Please try again.",
}),
},
fallback: t({
comment: "Fallback toast when testing AI provider connection fails",
message: "Failed to test AI provider connection. Please try again.",
}),
}),
);
},
},
);
};
function ProviderRow({ provider }: { provider: SavedProvider }) {
const queryClient = useQueryClient();
const invalidate = () => queryClient.invalidateQueries({ queryKey: orpc.aiProviders.list.queryKey() });
const { mutate: testProvider, isPending: isTesting } = useMutation(orpc.aiProviders.test.mutationOptions());
const { mutate: updateProvider, isPending: isUpdating } = useMutation(orpc.aiProviders.update.mutationOptions());
const { mutate: deleteProvider, isPending: isDeleting } = useMutation(orpc.aiProviders.delete.mutationOptions());
const isMutating = isTesting || isUpdating || isDeleting;
return (
<div className="grid gap-6 sm:grid-cols-2">
<div className="flex flex-col gap-y-2">
<Label htmlFor="ai-provider">
<Trans>Provider</Trans>
</Label>
<Combobox
id="ai-provider"
value={provider}
disabled={enabled}
options={providerOptions}
onValueChange={handleProviderChange}
/>
</div>
<div className="flex flex-col gap-y-2">
<Label htmlFor="ai-model">
<Trans>Model</Trans>
</Label>
<Input
id="ai-model"
name="ai-model"
type="text"
value={model}
disabled={enabled}
onChange={(e) =>
set((draft) => {
draft.model = e.target.value;
})
}
placeholder={t({
comment: "Example model-name placeholder in AI settings",
message: "e.g., gpt-4, claude-3-opus, gemini-pro",
})}
autoCorrect="off"
autoComplete="off"
spellCheck="false"
autoCapitalize="off"
/>
</div>
<div className="flex flex-col gap-y-2 sm:col-span-2">
<Label htmlFor="ai-api-key">
<Trans>API Key</Trans>
</Label>
<Input
id="ai-api-key"
name="ai-api-key"
type="password"
value={apiKey}
disabled={enabled}
onChange={(e) =>
set((draft) => {
draft.apiKey = e.target.value;
})
}
autoCorrect="off"
autoComplete="off"
spellCheck="false"
autoCapitalize="off"
data-lpignore="true"
data-bwignore="true"
data-1p-ignore="true"
/>
</div>
<div className="flex flex-col gap-y-2 sm:col-span-2">
<Label htmlFor="ai-base-url">
<Trans>Base URL (Optional)</Trans>
</Label>
<Input
id="ai-base-url"
name="ai-base-url"
type="url"
value={baseURL}
disabled={enabled}
placeholder={selectedOption?.defaultBaseURL}
onChange={(e) =>
set((draft) => {
draft.baseURL = e.target.value;
})
}
autoCorrect="off"
autoComplete="off"
spellCheck="false"
autoCapitalize="off"
/>
</div>
<div>
<Button variant="outline" disabled={isTesting || enabled || !canTestConnection} onClick={handleTestConnection}>
{isTesting ? (
<Spinner />
) : testStatus === "success" ? (
<CheckCircleIcon className="text-emerald-500" />
) : testStatus === "failure" ? (
<XCircleIcon className="text-rose-500" />
<div className="grid gap-4 rounded-md border bg-card p-4 md:grid-cols-[1fr_auto]">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate font-semibold">{provider.label}</h3>
{statusBadge(provider)}
{provider.enabled ? (
<Badge variant="outline">
<Trans>Enabled</Trans>
</Badge>
) : null}
<Trans>Test Connection</Trans>
</div>
<div className="grid gap-1 text-muted-foreground text-sm">
<p>
{providerLabel(provider.provider)} · {provider.model}
</p>
<p className="truncate">{provider.baseURL ?? AI_PROVIDER_DEFAULT_BASE_URLS[provider.provider]}</p>
<p>
<Trans>Key</Trans>: {provider.apiKeyPreview}
</p>
{provider.testError ? <p className="text-rose-600">{provider.testError}</p> : null}
</div>
</div>
<div className="flex items-center gap-2 md:justify-end">
<div className="flex items-center gap-2 pe-2">
<Switch
checked={provider.enabled}
disabled={provider.testStatus !== "success" || isMutating}
onCheckedChange={(enabled) =>
updateProvider(
{ id: provider.id, enabled },
{
onSuccess: () => void invalidate(),
onError: (error) =>
toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to update provider.` })),
},
)
}
/>
<span className="text-muted-foreground text-sm">
<Trans>Use</Trans>
</span>
</div>
<Button
variant="outline"
disabled={isMutating}
onClick={() =>
testProvider(
{ id: provider.id },
{
onSuccess: (response) => {
if (response.testStatus === "success") {
toast.success(t`Provider connection verified.`);
} else {
toast.error(response.testError ?? t`Could not verify provider connection.`);
}
void invalidate();
},
onError: (error) => {
toast.error(getOrpcErrorMessage(error, { fallback: t`Could not verify provider connection.` }));
void invalidate();
},
},
)
}
>
{isTesting ? <Spinner /> : provider.testStatus === "success" ? <CheckCircleIcon /> : <WarningCircleIcon />}
<Trans>Test</Trans>
</Button>
<Button
size="icon"
variant="ghost"
disabled={isMutating}
onClick={() =>
deleteProvider(
{ id: provider.id },
{
onSuccess: () => void invalidate(),
onError: (error) =>
toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to delete provider.` })),
},
)
}
>
<TrashIcon />
<span className="sr-only">
<Trans>Delete provider</Trans>
</span>
</Button>
</div>
</div>
);
}
function CreateProviderForm() {
const queryClient = useQueryClient();
const [form, setForm] = useState(emptyForm);
const selectedOption = useMemo(
() => providerOptions.find((option) => option.value === form.provider),
[form.provider],
);
const canCreate = form.label.trim() && form.model.trim() && form.apiKey.trim();
const { mutate: createProvider, isPending } = useMutation(orpc.aiProviders.create.mutationOptions());
return (
<div className="rounded-md border bg-card p-4">
<div className="mb-4 flex items-center gap-2">
<div className="grid size-8 place-items-center rounded-md bg-primary/10 text-primary">
<PlusIcon />
</div>
<h3 className="font-semibold">
<Trans>Add Provider</Trans>
</h3>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="ai-label">
<Trans>Label</Trans>
</Label>
<Input
id="ai-label"
value={form.label}
onChange={(event) => setForm((current) => ({ ...current, label: event.target.value }))}
placeholder={t`Work OpenAI`}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-provider">
<Trans>Provider</Trans>
</Label>
<Combobox
id="ai-provider"
value={form.provider}
showClear={false}
options={providerOptions}
onValueChange={(provider) => {
if (!provider) return;
setForm((current) => ({ ...current, provider }));
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-model">
<Trans>Model</Trans>
</Label>
<Input
id="ai-model"
value={form.model}
onChange={(event) => setForm((current) => ({ ...current, model: event.target.value }))}
placeholder={t`gpt-4.1`}
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-base-url">
<Trans>Base URL</Trans>
</Label>
<Input
id="ai-base-url"
type="url"
value={form.baseURL}
onChange={(event) => setForm((current) => ({ ...current, baseURL: event.target.value }))}
placeholder={selectedOption?.defaultBaseURL || t`https://gateway.example.com/v1`}
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="ai-api-key">
<Trans>API Key</Trans>
</Label>
<Input
id="ai-api-key"
type="password"
value={form.apiKey}
onChange={(event) => setForm((current) => ({ ...current, apiKey: event.target.value }))}
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
data-lpignore="true"
data-bwignore="true"
data-1p-ignore="true"
/>
</div>
</div>
<div className="mt-4 flex justify-end">
<Button
disabled={!canCreate || isPending}
onClick={() =>
createProvider(
{
label: form.label.trim(),
provider: form.provider,
model: form.model.trim(),
baseURL: form.baseURL.trim(),
apiKey: form.apiKey.trim(),
},
{
onSuccess: () => {
setForm(emptyForm);
toast.success(t`AI provider saved. Test it before use.`);
void queryClient.invalidateQueries({ queryKey: orpc.aiProviders.list.queryKey() });
},
onError: (error) =>
toast.error(
getOrpcErrorMessage(error, {
byCode: {
PRECONDITION_FAILED: t`AI providers require REDIS_URL and ENCRYPTION_SECRET to be configured.`,
BAD_REQUEST: t`Invalid AI provider configuration.`,
},
fallback: t`Failed to save AI provider.`,
}),
),
},
)
}
>
{isPending ? <Spinner /> : <KeyIcon />}
<Trans>Save Provider</Trans>
</Button>
</div>
</div>
@@ -251,48 +357,71 @@ function AIForm() {
}
export function AISettingsSection() {
const aiEnabled = useAIStore((state) => state.enabled);
const canEnableAI = useAIStore((state) => state.canEnable());
const setAIEnabled = useAIStore((state) => state.setEnabled);
const { data: providers, isLoading, error } = useQuery(orpc.aiProviders.list.queryOptions());
const hasUsableProvider = providers?.some((provider) => provider.enabled && provider.testStatus === "success");
const isConfigError = isAiProviderConfigError(error);
return (
<section className="grid gap-6">
<h2 className="font-semibold text-lg">
<Trans>Artificial Intelligence</Trans>
</h2>
<div className="flex items-start gap-4 rounded-md border bg-popover p-6">
<div className="rounded-md bg-primary/10 p-2.5">
<InfoIcon className="text-primary" size={24} />
</div>
<div className="flex-1 space-y-2">
<h3 className="font-semibold">
<Trans>Your data is stored locally</Trans>
</h3>
<p className="text-muted-foreground leading-relaxed">
<Trans>
Everything entered here is stored locally on your browser. Your data is only sent to the server when
making a request to the AI provider, and is never stored or logged on our servers.
</Trans>
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="font-semibold text-lg">
<Trans>AI Providers</Trans>
</h2>
<p className="text-muted-foreground text-sm">
<Trans>API keys are encrypted on the server and never shown again after saving.</Trans>
</p>
</div>
<p className="flex items-center gap-2 text-sm">
{hasUsableProvider ? (
<CheckCircleIcon className="text-emerald-600" />
) : (
<XCircleIcon className="text-rose-600" />
)}
<span className={cn(hasUsableProvider ? "text-emerald-700" : "text-muted-foreground")}>
{hasUsableProvider ? <Trans>Agent ready</Trans> : <Trans>No tested provider</Trans>}
</span>
</p>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="enable-ai">
<Trans>Enable AI Features</Trans>
</Label>
<Switch id="enable-ai" checked={aiEnabled} disabled={!canEnableAI} onCheckedChange={setAIEnabled} />
{error ? (
<div
className={cn(
"rounded-md border p-4 text-sm",
isConfigError
? "border-amber-300 bg-amber-50 text-amber-950 dark:bg-amber-950/20 dark:text-amber-200"
: "border-rose-300 bg-rose-50 text-rose-950 dark:bg-rose-950/20 dark:text-rose-200",
)}
>
{isConfigError ? (
<Trans>AI provider management is unavailable until REDIS_URL and ENCRYPTION_SECRET are configured.</Trans>
) : (
<Trans>AI provider management is unavailable. Please try again.</Trans>
)}
</div>
) : null}
{error ? null : <CreateProviderForm />}
<div className="grid gap-3">
{isLoading ? (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Spinner />
<Trans>Loading providers...</Trans>
</div>
) : null}
{providers?.length === 0 ? (
<div className="rounded-md border border-dashed p-6 text-center text-muted-foreground text-sm">
<Trans>Add and test a provider before starting an agent thread.</Trans>
</div>
) : null}
{providers?.map((provider) => (
<ProviderRow key={provider.id} provider={provider} />
))}
</div>
<p className="flex items-center gap-x-2">
{aiEnabled ? <CheckCircleIcon className="text-emerald-500" /> : <XCircleIcon className="text-rose-500" />}
{aiEnabled ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>}
</p>
<AIForm />
</section>
);
}
@@ -26,7 +26,7 @@ function RouteComponent() {
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="grid max-w-xl gap-8 will-change-[transform,opacity]"
className="grid max-w-4xl gap-8 will-change-[transform,opacity]"
>
<AISettingsSection />
</motion.div>
+76 -2
View File
@@ -1,6 +1,64 @@
name: reactive_resume
services:
reactive_resume:
build:
context: .
dockerfile: Dockerfile.dev
restart: unless-stopped
env_file:
- path: .env.local
required: false
environment:
APP_URL: http://localhost:3000
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres
S3_ENDPOINT: http://seaweedfs:8333
REDIS_URL: redis://redis:6379
CHOKIDAR_USEPOLLING: "true"
ports:
- "127.0.0.1:3000:3000"
volumes:
- ./data:/app/data
develop:
watch:
- action: sync
path: .
target: /app
ignore:
- .git/
- .pnpm-store/
- .turbo/
- .vite-hooks/
- data/
- node_modules/
- apps/*/node_modules/
- packages/*/node_modules/
- apps/*/.output/
- apps/*/.tanstack/
- apps/*/.turbo/
- packages/*/.turbo/
- coverage/
- apps/*/coverage/
- packages/*/coverage/
- reports/
- apps/*/reports/
- packages/*/reports/
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
seaweedfs:
condition: service_healthy
seaweedfs_create_bucket:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then((r) => { if (!r.ok) process.exit(1); }).catch(() => process.exit(1));"]
start_period: 30s
interval: 30s
timeout: 10s
retries: 3
postgres:
image: postgres:latest
restart: unless-stopped
@@ -11,7 +69,7 @@ services:
volumes:
- postgres_data:/var/lib/postgresql
ports:
- "5432:5432"
- "127.0.0.1:5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
start_period: 10s
@@ -19,6 +77,21 @@ services:
timeout: 10s
retries: 3
redis:
image: redis:latest
restart: unless-stopped
command: redis-server --appendonly yes
volumes:
- redis_data:/data
ports:
- "127.0.0.1:6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
start_period: 5s
interval: 30s
timeout: 10s
retries: 3
seaweedfs:
image: chrislusf/seaweedfs:latest
restart: unless-stopped
@@ -29,7 +102,7 @@ services:
volumes:
- seaweedfs_data:/data
ports:
- "8333:8333"
- "127.0.0.1:8333:8333"
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8888"]
start_period: 10s
@@ -54,4 +127,5 @@ services:
volumes:
postgres_data:
redis_data:
seaweedfs_data:
+31 -3
View File
@@ -21,6 +21,26 @@ services:
timeout: 10s
retries: 3
redis:
image: redis:latest
restart: unless-stopped
command: redis-server --appendonly yes
networks:
- data_network
# Not exposed on the host by default. Redis can contain agent stream data
# including user chat content; keep it internal to the Docker network.
# Uncomment the ports block below if you need host access for tooling.
# ports:
# - "127.0.0.1:6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
start_period: 5s
interval: 30s
timeout: 10s
retries: 3
seaweedfs:
image: chrislusf/seaweedfs:latest
restart: unless-stopped
@@ -58,9 +78,10 @@ services:
condition: service_healthy
reactive_resume:
build:
context: .
dockerfile: Dockerfile
image: ghcr.io/amruthpillai/reactive-resume:latest
# build:
# context: .
# dockerfile: Dockerfile
networks:
- data_network
- storage_network
@@ -70,6 +91,8 @@ services:
- APP_URL=http://localhost:3000
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres
- AUTH_SECRET=change-me-to-a-secure-secret-key-in-production
- REDIS_URL=redis://redis:6379
- ENCRYPTION_SECRET=change-me-to-a-secure-agent-secret-in-production
- S3_ACCESS_KEY_ID=seaweedfs
- S3_SECRET_ACCESS_KEY=seaweedfs
- S3_ENDPOINT=http://seaweedfs:8333
@@ -80,6 +103,10 @@ services:
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
seaweedfs:
condition: service_healthy
seaweedfs_create_bucket:
condition: service_completed_successfully
healthcheck:
@@ -91,6 +118,7 @@ services:
volumes:
postgres_data:
redis_data:
seaweedfs_data:
networks:
+44
View File
@@ -4,6 +4,50 @@ description: "List of all notable changes and updates to Reactive Resume"
rss: true
---
<Update label="v5.1.4" description="14th May 2026">
<Note>
**Self-hosters using AI features:** saved AI providers now require `ENCRYPTION_SECRET`, and the new AI Agent
workspace also requires `REDIS_URL`. Agent attachments require S3-compatible storage for private objects; local
filesystem storage still works for the rest of the app, but rejects private agent attachments.
</Note>
## Highlights
- **New AI Agent workspace.** Added a dedicated full-screen Agent workspace for working on isolated AI draft copies of resumes. Threads include a sidebar, chat, tool activity, file attachments, and a read-only resume preview with PDF download support. [#3062](https://github.com/amruthpillai/reactive-resume/pull/3062)
<img src="/images/guides/using-ai-agent/screenshot-2.webp" alt="AI Agent workspace showing thread sidebar, chat, and resume preview" />
- **Reviewable and reversible agent edits.** Agent-applied resume patches now appear as expandable tool activity, with raw JSON Patch details, operation summaries, revert support, and clear conflict handling when the resume has changed. [#3062](https://github.com/amruthpillai/reactive-resume/pull/3062)
<img src="/images/guides/ai-agent-tools/screenshot-1.webp" alt="AI Agent chat showing an applied patch with raw JSON details" />
- **Public resumes now render with PDF.js.** Public resume pages now use the PDF.js-based viewer path, improving parity between the public page, builder preview, and exported PDF. [#3061](https://github.com/amruthpillai/reactive-resume/pull/3061)
## AI & Agent Workflows
- Added server-side AI provider management with encrypted credentials, provider testing, and provider/model capability checks. This replaces the old local AI store and keeps AI configuration centralized. [#3062](https://github.com/amruthpillai/reactive-resume/pull/3062)
- Added Agent tools for reading resume drafts, fetching public URLs, reading supported attachments, asking follow-up questions, and applying JSON Patch updates to the AI draft. [#3062](https://github.com/amruthpillai/reactive-resume/pull/3062)
- Added archive and delete actions for Agent threads, including read-only archived states, in-flight run cleanup when archiving, and ownership checks before destructive deletion. [#3062](https://github.com/amruthpillai/reactive-resume/pull/3062)
- Added attachment upload rate limits, private S3 ACLs for Agent attachments, runtime validation for streamed messages, transactional patch/action writes, and a unique message sequence index for safer Agent runs. [#3062](https://github.com/amruthpillai/reactive-resume/pull/3062)
- Added `FLAG_ALLOW_UNSAFE_AI_BASE_URL` for trusted self-hosted deployments that need private or local AI provider URLs, while still restricting provider URLs to `http` or `https`. Thanks to [@SirSKillz](https://github.com/SirSKillz). [#3059](https://github.com/amruthpillai/reactive-resume/pull/3059)
## Resume Rendering & Fonts
- Restored legacy local font names from v5.0.x by mapping them to metric-compatible bundled web fonts, preventing upgraded resumes from unexpectedly changing line breaks, page counts, or overall layout. Thanks to [@JamesGoslings](https://github.com/JamesGoslings). [#3057](https://github.com/amruthpillai/reactive-resume/pull/3057)
- Added metric-compatible aliases for `Times New Roman`, `Cambria`, `Arial`, `Garamond`, and `Calibri`, including Carlito as the Calibri-compatible target. [#3057](https://github.com/amruthpillai/reactive-resume/pull/3057)
- Improved resume preview spacing normalization and added focused coverage for the public PDF viewer and preview helpers. [#3061](https://github.com/amruthpillai/reactive-resume/pull/3061), [#3062](https://github.com/amruthpillai/reactive-resume/pull/3062)
## Self-Hosting, Docs & Maintenance
- Added a development Dockerfile plus improved Compose development services and health checks for running Reactive Resume with local dependencies. [1294d3354](https://github.com/amruthpillai/reactive-resume/commit/1294d3354)
- Updated self-hosting documentation for Redis, encrypted AI provider credentials, optional Cloudflare URL extraction, private Agent attachments, S3 path-style storage, and unsafe AI base URL behavior. [#3062](https://github.com/amruthpillai/reactive-resume/pull/3062), [#3059](https://github.com/amruthpillai/reactive-resume/pull/3059)
- Added new and refreshed guides for the AI Agent workspace, Agent tools, AI setup, builder dock, dashboard management, importing, exporting, public sharing, and private notes. [#3062](https://github.com/amruthpillai/reactive-resume/pull/3062), [affa1d664](https://github.com/amruthpillai/reactive-resume/commit/affa1d664)
- Removed a stale Custom CSS documentation link now that custom CSS is no longer part of the v5.1 renderer flow. [#3056](https://github.com/amruthpillai/reactive-resume/pull/3056)
- Added a Reactive Resume design system reference and updated dependencies across the workspace. [#3062](https://github.com/amruthpillai/reactive-resume/pull/3062)
**Full Changelog**: [v5.1.3...v5.1.4](https://github.com/amruthpillai/reactive-resume/compare/v5.1.3...v5.1.4)
</Update>
<Update label="v5.1.3" description="13th May 2026">
## Highlights
+6
View File
@@ -52,11 +52,16 @@
"group": "Resume Builder",
"pages": [
"guides/creating-your-first-resume",
"guides/managing-resumes-from-the-dashboard",
"guides/importing-resumes",
"guides/choosing-a-template",
"guides/selecting-page-format",
"guides/moving-items-between-sections",
"guides/fitting-content-on-a-page",
"guides/adding-a-cover-letter",
"guides/using-the-builder-dock",
"guides/using-ai-in-the-builder",
"guides/using-ai-agent",
"guides/exporting-your-resume",
"guides/sharing-your-resume-publicly",
"guides/using-private-notes"
@@ -73,6 +78,7 @@
"guides/using-the-patch-api",
"guides/using-the-mcp-server",
"guides/using-ai",
"guides/ai-agent-tools",
"guides/json-resume-schema"
]
},
+21 -2
View File
@@ -96,10 +96,15 @@ S3_SECRET_ACCESS_KEY=seaweedfs
S3_ENDPOINT=http://seaweedfs:8333
S3_BUCKET=reactive-resume
S3_FORCE_PATH_STYLE=true
# AI features (optional; ENCRYPTION_SECRET for saved providers, plus REDIS_URL for the agent)
REDIS_URL=redis://redis:6379
ENCRYPTION_SECRET=your-secure-encryption-secret-here
```
<Warning>
For production deployments, always use strong, unique values for `AUTH_SECRET` and database credentials.
For production deployments, always use strong, unique values for `AUTH_SECRET`, `ENCRYPTION_SECRET`, and
database credentials.
</Warning>
</Step>
@@ -110,6 +115,7 @@ S3_FORCE_PATH_STYLE=true
This starts:
- **PostgreSQL** — Database for storing user data and resumes
- **Redis** — Required for the AI Agent workspace
- **SeaweedFS** — S3-compatible storage for file uploads
- **Reactive Resume** — The main application
</Step>
@@ -131,9 +137,16 @@ Here's what each service in the stack does:
| Service | Port | Description |
| ----------------- | ---- | ---------------------------------------------------- |
| `postgres` | 5432 | PostgreSQL database for storing all application data |
| `redis` | 6379 | Redis instance required by the AI Agent workspace |
| `seaweedfs` | 8333 | S3-compatible object storage for file uploads |
| `reactive_resume` | 3000 | The main Reactive Resume application |
<Note>
Saved AI provider management requires `ENCRYPTION_SECRET`, and the AI Agent workspace requires both `REDIS_URL` and
`ENCRYPTION_SECRET`. Other Reactive Resume features can run without them. Agent attachments and other private objects
require S3-compatible storage; local storage rejects private objects.
</Note>
### Health Checks
All services include built-in health checks. You can verify everything is running correctly:
@@ -180,7 +193,6 @@ Here's a complete list of environment variables you can configure:
| `BETTER_AUTH_API_KEY` | Better Auth dashboard API key | — |
| `BETTER_AUTH_URL` | Better Auth base URL override (advanced) | `APP_URL` |
| `BETTER_AUTH_SECRET` | Better Auth secret override (advanced) | `AUTH_SECRET` |
| `AI_ALLOWED_BASE_URLS` | Allowlist for custom AI provider base URLs | — |
| `SMTP_HOST` | SMTP Server Host (for email features) | — |
| `SMTP_PORT` | SMTP Server Port | `587` |
| `SMTP_USER` | SMTP Username | — |
@@ -193,12 +205,19 @@ Here's a complete list of environment variables you can configure:
| `S3_ENDPOINT` | S3-compatible Endpoint URL | — |
| `S3_BUCKET` | S3 Bucket Name | — |
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 (set `true` for MinIO/SeaweedFS) | `false` |
| `REDIS_URL` | Redis connection string for the AI Agent workspace | — |
| `ENCRYPTION_SECRET` | Encryption secret for saved AI provider credentials | — |
| `CLOUDFLARE_ACCOUNT_ID` | Optional Cloudflare URL extraction fallback account ID | — |
| `CLOUDFLARE_API_TOKEN` | Optional Cloudflare URL extraction fallback API token | — |
| `FLAG_DISABLE_SIGNUPS` | Disables new user signups | `false` |
| `FLAG_DISABLE_EMAIL_AUTH` | Disables email/password login (SSO only) | `false` |
| `FLAG_DISABLE_IMAGE_PROCESSING` | Disables image processing | `false` |
| `FLAG_ALLOW_UNSAFE_AI_BASE_URL` | Allows unsafe/private/non-public AI provider base URLs | `false` |
> **Note:** Some variables are only required for using related features (OAuth, SMTP, S3, etc.) and can be left unset if unused.
> **AI features:** Saved AI provider management requires `ENCRYPTION_SECRET`, and the AI Agent workspace requires both `REDIS_URL` and `ENCRYPTION_SECRET`. Cloudflare variables only enable the optional URL extraction fallback and are not required for normal agent operation. Keep `FLAG_ALLOW_UNSAFE_AI_BASE_URL` disabled unless this is a trusted self-hosted deployment; public HTTPS provider URLs are the safe default.
> **Health check behavior:** `/api/health` reports status for database and storage. A failure in either dependency returns HTTP `503`.
---
+83
View File
@@ -0,0 +1,83 @@
---
title: "AI Agent Tools"
description: "Understand the tools available to the AI Agent workspace and how they affect resume drafts."
---
The AI Agent workspace can use a curated set of tools while it chats with you. You do not call these tools directly; the agent chooses them when your request needs resume data, web context, attachments, questions, or a resume patch.
## Tool activity in chat
Tool activity appears inside the conversation. Some activity is collapsed by default so the chat stays readable.
Applied resume patches are shown as a small inline **Patch applied** item. Open it to inspect the raw JSON payload.
<Frame caption="Patch details and tool activity in the AI Agent chat">
<img
src="/images/guides/ai-agent-tools/screenshot-1.webp"
alt="AI Agent chat showing an applied patch with raw JSON details"
/>
</Frame>
## Available tools
| Tool | What it does | Example request |
| --- | --- | --- |
| `read_resume` | Reads the current AI draft and gives the agent the resume data it can safely edit. | "What are the weakest parts of this resume?" |
| `fetch_url` | Fetches public HTTPS pages, extracts readable content, and returns it as agent input. | "Tailor this resume to this job description: `https://example.com/job`" |
| Provider-native search | Uses the selected provider's native web search when that provider/model supports it. | "Research this company and adjust the summary for its product area." |
| `read_attachment` | Reads extracted text from attached plain text, Markdown, or JSON files. Other supported attachments, such as images or PDFs, are passed to the model when the selected provider can use them. | "Use the attached notes to update the keywords." |
| `ask_user_question` | Shows a question card with answer choices when the agent needs your decision. | "Ask me before changing the career narrative." |
| `apply_resume_patch` | Applies a JSON Patch to the AI draft and stores enough history to revert it later. | "Change the visible name to Amruth Pillai." |
## Resume patches
Resume patches are rooted at the resume data object. For example, the visible resume name is patched at `/basics/name`.
When a patch is applied:
- the AI draft updates immediately;
- the raw JSON Patch is available from the **Patch applied** details;
- an inverse patch is stored for revert;
- the resume preview refreshes to show the updated draft.
If the resume changed after the patch was generated, revert or apply can fail with a version conflict. In that case, ask the agent to retry from the latest draft.
## Web access
The agent can fetch public HTTPS URLs so it can read job descriptions, company pages, or other public context you provide.
For self-hosted deployments:
- private, loopback, and non-HTTPS URLs are blocked by default;
- Cloudflare URL extraction is optional and is used only as a fallback when local readability extraction fails;
- unsafe/private AI provider base URLs require `FLAG_ALLOW_UNSAFE_AI_BASE_URL=true`, which should only be used on trusted self-hosted deployments.
## Attachments
Attach files from the chat composer when the agent needs extra context, such as:
- a job description PDF;
- a portfolio brief;
- a screenshot;
- a plain text note with constraints.
Self-hosted deployments need S3-compatible storage for private agent attachments. Local filesystem storage rejects private objects.
## Good prompts for tool use
Use direct prompts that tell the agent what context to use and how cautious to be:
- "Fetch this role URL, identify the most important keywords, and apply a conservative patch."
- "Read the attached job description and ask me before changing anything outside the summary."
- "Compare my current projects against this company page and suggest only truthful wording."
- "Apply a patch for the visible resume name, then show me what changed."
## When tools are unavailable
Tool use can be limited by the selected provider, deployment configuration, or thread state.
- A deleted provider makes the thread read-only; a disabled or untested provider blocks new agent runs until it is enabled and tested again.
- A deleted working resume makes the thread read-only.
- Archived threads cannot receive new messages.
- URL fetching may fail for private URLs, blocked hosts, non-HTML pages, or pages that cannot be extracted.
- Attachments may fail if private object storage is not configured.
-2
View File
@@ -165,5 +165,3 @@ After selecting a template, you can customize it further:
| **Typography** | Choose from various Google Fonts for headings and body text |
| **Layout** | Adjust sidebar width, section order, and page margins |
| **Spacing** | Fine-tune gaps between sections and elements |
<Tip>For more advanced customization options, check out [Using Custom CSS](/guides/using-custom-css).</Tip>

Some files were not shown because too many files have changed in this diff Show More