mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-01 09:14:29 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9df2a5287d | ||
|
|
6d8d8f6e55 | ||
|
|
22c60c64b6 | ||
|
|
affa1d6646 | ||
|
|
c71f3b0b92 | ||
|
|
6c4a4b2aa5 | ||
|
|
1294d3354a | ||
|
|
42fc78dca1 |
@@ -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
@@ -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";
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`).
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+472
-203
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+473
-205
File diff suppressed because it is too large
Load Diff
+449
-180
File diff suppressed because it is too large
Load Diff
+15
-13
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
@@ -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
Reference in New Issue
Block a user