feat: add AI agent workspace (#3062)

* chore(ai): remove local AI store now that providers live server-side

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

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

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

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

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

Adds focused tests for both behaviors.

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

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

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

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

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

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

* chore(agent): configure runtime dependencies

* feat(db): add agent workspace schema

* feat(api): add agent backend services

* feat(web): add agent workspace UI

* chore(agent): remove legacy builder assistant

* test(agent): make agent stream mocks constructible

* chore(web): remove unused resume replacement hook

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

* chore(dev): expose local services in compose

* fix(web): normalize resume preview gaps

* feat(api): improve agent tool handling

* feat(web): polish agent workspace UI

* chore: update dependencies

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

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

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

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

* fix: address ai agent review feedback

* fix: preserve mobile agent chat state

* docs: add ai agent workspace guides

* feat: introduce design system for Reactive Resume
This commit is contained in:
Amruth Pillai
2026-05-14 15:00:04 +02:00
committed by GitHub
parent 22c60c64b6
commit 6d8d8f6e55
115 changed files with 15623 additions and 2123 deletions
+9
View File
@@ -77,6 +77,15 @@ S3_ENDPOINT="http://localhost:8333"
S3_BUCKET="reactive-resume"
S3_FORCE_PATH_STYLE="true"
# --- AI Agent Workspace (optional) ---
# Required only for the authenticated /agent workspace and saved AI providers.
REDIS_URL="redis://localhost:6379"
ENCRYPTION_SECRET="change-me-to-a-secure-agent-secret-in-production"
# Optional Cloudflare Browser Run credentials used as a URL extraction fallback for agent web access.
CLOUDFLARE_ACCOUNT_ID=""
CLOUDFLARE_API_TOKEN=""
# --- Feature Flags ---
# This flag disables new signups, both on the web app and the server.
FLAG_DISABLE_SIGNUPS="false"
+347
View File
@@ -0,0 +1,347 @@
---
version: alpha
name: Reactive Resume
description: A monochrome, content-first design system for a free and open-source resume builder. Dark-by-default with light mode support.
colors:
primary: "#343434"
primary-foreground: "#FBFBFB"
secondary: "#F7F7F7"
secondary-foreground: "#343434"
background: "#FFFFFF"
foreground: "#252525"
muted: "#F7F7F7"
muted-foreground: "#8E8E8E"
card: "#FFFFFF"
card-foreground: "#252525"
border: "#EBEBEB"
input: "#EBEBEB"
ring: "#B5B5B5"
destructive: "#DC2626"
on-destructive: "#FFFFFF"
typography:
heading:
fontFamily: IBM Plex Sans Variable
fontSize: 1rem
fontWeight: 500
body:
fontFamily: IBM Plex Sans Variable
fontSize: 0.875rem
fontWeight: 400
body-sm:
fontFamily: IBM Plex Sans Variable
fontSize: 0.75rem
fontWeight: 400
label:
fontFamily: IBM Plex Sans Variable
fontSize: 0.8rem
fontWeight: 500
hero-heading:
fontFamily: IBM Plex Sans Variable
fontSize: 3.75rem
fontWeight: 700
letterSpacing: -0.025em
rounded:
sm: 0.18rem
md: 0.24rem
lg: 0.3rem
xl: 0.42rem
2xl: 0.54rem
3xl: 0.66rem
4xl: 0.78rem
spacing:
xs: 4px
sm: 8px
md: 16px
lg: 24px
xl: 32px
2xl: 48px
components:
button-default:
backgroundColor: "{colors.primary}"
textColor: "{colors.primary-foreground}"
rounded: "{rounded.lg}"
padding: 10px
height: 36px
button-outline:
backgroundColor: "{colors.background}"
textColor: "{colors.foreground}"
rounded: "{rounded.lg}"
padding: 10px
height: 36px
button-secondary:
backgroundColor: "{colors.secondary}"
textColor: "{colors.secondary-foreground}"
rounded: "{rounded.lg}"
padding: 10px
height: 36px
button-ghost:
backgroundColor: "{colors.background}"
textColor: "{colors.foreground}"
rounded: "{rounded.lg}"
padding: 10px
height: 36px
button-destructive:
backgroundColor: "{colors.destructive}"
textColor: "{colors.on-destructive}"
rounded: "{rounded.lg}"
padding: 10px
height: 36px
card:
backgroundColor: "{colors.card}"
textColor: "{colors.card-foreground}"
rounded: "{rounded.lg}"
padding: 16px
input:
backgroundColor: "{colors.background}"
textColor: "{colors.foreground}"
rounded: "{rounded.lg}"
height: 36px
padding: 10px
input-focus:
backgroundColor: "{colors.background}"
textColor: "{colors.foreground}"
rounded: "{rounded.lg}"
height: 36px
padding: 10px
badge:
backgroundColor: "{colors.primary}"
textColor: "{colors.primary-foreground}"
rounded: "{rounded.md}"
padding: 4px
popover:
backgroundColor: "{colors.card}"
textColor: "{colors.card-foreground}"
rounded: "{rounded.xl}"
padding: 4px
sidebar:
backgroundColor: "{colors.muted}"
textColor: "{colors.foreground}"
padding: 8px
sidebar-item:
backgroundColor: "{colors.muted}"
textColor: "{colors.muted-foreground}"
rounded: "{rounded.lg}"
padding: 8px
sidebar-item-active:
backgroundColor: "{colors.primary}"
textColor: "{colors.primary-foreground}"
rounded: "{rounded.lg}"
padding: 8px
tooltip:
backgroundColor: "{colors.primary}"
textColor: "{colors.primary-foreground}"
rounded: "{rounded.md}"
padding: 6px
separator:
backgroundColor: "{colors.border}"
height: 1px
dialog:
backgroundColor: "{colors.card}"
textColor: "{colors.card-foreground}"
rounded: "{rounded.xl}"
padding: 24px
input-invalid:
backgroundColor: "{colors.background}"
textColor: "{colors.destructive}"
rounded: "{rounded.lg}"
height: 36px
padding: 10px
---
## Overview
Reactive Resume is a monochrome, content-first design system built for a resume builder used by tens of thousands of people worldwide. The visual identity prioritizes readability and unobtrusiveness — the user's resume content is always the hero, never the chrome around it.
The system defaults to dark mode with a warm near-black backdrop that makes the resume preview "float" as the visual anchor. Light mode is supported as a full alternative. The authenticated app shell (dashboard, builder, settings) uses an entirely achromatic grayscale palette — the sole chromatic exception is destructive red for dangerous actions. The landing page introduces subtle chromatic accents: blue-tinted spotlight gradients on the hero, a multicolor text-mask animation on hover, and social auth provider brand colors (Google blue, LinkedIn blue) on the login page.
The overall aesthetic is a professional tool UI: clean grid lines, subtle borders, generous whitespace, and typography that steps back to let the content shine. Think "VS Code meets Figma" — a productivity workspace, not a marketing site.
One deliberate counterpoint to the serious UI: all resume templates are named after Pokemon (Azurill, Bronzor, Chikorita, Ditgar, Gengar, Pikachu, etc.). This is an intentional brand choice — playful naming for templates injects personality into an otherwise utilitarian interface, making templates feel collectible and memorable rather than generic ("Template 1", "Modern", "Classic").
## Colors
The palette is rooted in achromatic OKLch values (chroma = 0), producing a pure grayscale scale without warm or cool casts. Colors are defined as CSS custom properties using `oklch()` and consumed through Tailwind CSS 4 theme tokens. Always prefer CSS variables (e.g., `var(--primary)`) or Tailwind tokens (e.g., `bg-primary`) over raw color values. The hex values in this document's YAML front matter are agent-friendly approximations of the canonical OKLch definitions in `packages/ui/src/styles/globals.css` — use hex only where OKLch is unavailable.
- **Primary (#343434 light / #EBEBEB dark):** Used for high-emphasis interactive surfaces — default buttons, selected states, and text selection. In dark mode this inverts to near-white so buttons remain prominent.
- **Foreground (#252525 light / #FBFBFB dark):** Body text and headings. High contrast against the background in both themes.
- **Background (#FFFFFF light / #252525 dark):** The canvas. Pure white in light mode, warm near-black in dark mode.
- **Card (#FFFFFF light / #343434 dark):** Elevated surface for cards, panels, and the builder sidebar. In dark mode, one step lighter than the background to create subtle depth.
- **Muted (#F7F7F7 light / #454545 dark):** De-emphasized backgrounds for secondary UI regions, hover states, and inactive tabs.
- **Muted Foreground (#8E8E8E light / #B5B5B5 dark):** Captions, helper text, timestamps, and metadata. Deliberately low-contrast against the background to recede visually.
- **Border (#EBEBEB light / white at 10% opacity dark):** Thin separator lines. In dark mode, uses transparent white rather than a solid gray to blend naturally with any underlying surface color.
- **Input (#EBEBEB light / white at 15% opacity dark):** Form field borders, slightly more prominent than general borders to make input areas discoverable.
- **Destructive (#DC2626 light / #EF4444 dark):** The only chromatic color in the palette. Reserved exclusively for delete actions, error states, and danger-zone operations. Used at 10% opacity as a background tint with full saturation for text, creating a soft but unmistakable warning.
- **Ring (#B5B5B5 light / #8E8E8E dark):** Focus ring indicator at 50% opacity, surrounding focused interactive elements.
- **Sidebar Primary (dark only, #6366F1):** An indigo value inherited from the shadcn/ui defaults. Not actively used in the current UI — sidebar active states use the standard grayscale primary token instead. Retained in the CSS custom properties for potential future customization.
Resume templates have their own independent color system — users pick primary, text, and background colors per resume through a color picker in the builder's Design panel. These template colors are completely separate from the app shell palette.
## Typography
The entire application uses a single typeface: **IBM Plex Sans Variable**. This is a humanist sans-serif with an extensive weight range (100900) 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.35s0.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.1s0.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 3050% 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`).
+1 -1
View File
@@ -39,4 +39,4 @@ COPY . .
EXPOSE 3000/tcp
CMD ["pnpm", "dev"]
CMD ["pnpm", "run", "dev:web"]
+11 -9
View File
@@ -16,7 +16,7 @@
"lingui:extract": "lingui extract --clean --overwrite"
},
"dependencies": {
"@ai-sdk/react": "^3.0.182",
"@ai-sdk/react": "^3.0.184",
"@base-ui/react": "^1.4.1",
"@better-auth/api-key": "^1.6.11",
"@better-auth/infra": "^0.2.8",
@@ -54,17 +54,18 @@
"@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-rc.2",
@@ -78,6 +79,7 @@
"qrcode.react": "^4.2.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.11.1",
"react-window": "^2.2.7",
"react-zoom-pan-pinch": "^4.0.3",
@@ -1,6 +1,7 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
ChatCircleDotsIcon,
GearIcon,
HouseSimpleIcon,
KeyIcon,
@@ -44,6 +45,16 @@ export function NavigationCommandGroup() {
<Trans>Resumes</Trans>
</CommandItem>
<CommandItem
disabled={!session}
keywords={[t`Agent`, t`Artificial Intelligence`]}
value="navigation.agent"
onSelect={() => onNavigate("/agent")}
>
<ChatCircleDotsIcon />
<Trans>Agent</Trans>
</CommandItem>
<CommandItem
disabled={!session}
keywords={[t`Settings`]}
@@ -280,20 +280,6 @@ export function usePatchResume() {
return useResumeStore((state) => state.patchResume);
}
export function useReplaceResumeFromServer() {
const queryClient = useQueryClient();
const replaceResumeFromServer = useResumeStore((state) => state.replaceResumeFromServer);
return useCallback(
(resume: Resume) => {
bindRuntimeQueryClient(resume.id, queryClient);
queryClient.setQueryData(getResumeQueryKey(resume.id), resume);
replaceResumeFromServer(resume);
},
[queryClient, replaceResumeFromServer],
);
}
function useBuilderResumeSelector<T>(selector: (resume: Resume) => T): T | undefined {
const params = useParams({ strict: false }) as { resumeId?: string };
const resumeId = params.resumeId;
@@ -93,20 +93,14 @@ describe("ResumePreviewClient", () => {
previewMock.builderResumeData = resumeDataWithPageCount(3);
previewMock.toBlob.mockImplementation(() => new Promise<Blob>(() => {}));
render(<ResumePreviewClient pageGap="1rem" pageLayout="vertical" pageScale={1.25} showPageNumbers={false} />);
render(<ResumePreviewClient pageGap={16} pageLayout="vertical" pageScale={1.25} showPageNumbers={false} />);
expect(screen.getAllByRole("img", { name: /Loading resume page/ })).toHaveLength(3);
});
it("renders from explicit resume data when no builder resume is active", async () => {
render(
<ResumePreviewClient
data={sampleResumeData}
pageGap="1rem"
pageLayout="vertical"
pageScale={1.25}
showPageNumbers={false}
/>,
<ResumePreviewClient data={sampleResumeData} pageLayout="vertical" pageScale={1.25} showPageNumbers={false} />,
);
expect(await screen.findByRole("img", { name: "Resume page 1 of 1" })).toBeTruthy();
@@ -1,3 +1,4 @@
import type { CSSProperties } from "react";
import type { PreviewPageSize, ResolvedResumePreviewProps } from "./preview.shared";
import { pdf } from "@react-pdf/renderer";
import { AnimatePresence, motion } from "motion/react";
@@ -6,7 +7,7 @@ import { cn } from "@reactive-resume/utils/style";
import { useLocalizedResumeDocument } from "@/libs/resume/pdf-document";
import { useResumeData } from "./builder-resume-draft";
import { PdfCanvasDocument, PdfCanvasPage } from "./pdf-canvas";
import { getResumePreviewPageCount, ResumePreviewLoader } from "./preview.shared";
import { getResumePreviewGapValue, getResumePreviewPageCount, ResumePreviewLoader } from "./preview.shared";
type PreviewPdf = {
file: Blob;
@@ -84,7 +85,7 @@ const removePreviewLayer = (layers: PreviewPdf[], layerId: number) => layers.fil
export function ResumePreviewClient({
className,
data,
pageGap,
pageGap = 16,
pageLayout,
pageScale,
pageClassName,
@@ -133,6 +134,7 @@ export function ResumePreviewClient({
if (!resumeData) return null;
const visiblePdf = getActivePreviewLayer(previewLayers);
const resolvedPageGap = getResumePreviewGapValue(pageGap);
if (!visiblePdf) {
return (
@@ -154,8 +156,8 @@ export function ResumePreviewClient({
<motion.div
key={visiblePdf.id}
aria-hidden={visiblePdf.phase !== "active"}
style={{ "--resume-preview-page-gap": resolvedPageGap } as CSSProperties}
className={cn("col-start-1 row-start-1", visiblePdf.phase !== "active" && "pointer-events-none")}
style={{ "--resume-preview-page-gap": pageGap } as React.CSSProperties}
initial={{ opacity: visiblePdf.phase === "active" ? 1 : 0 }}
animate={{ opacity: visiblePdf.phase === "active" ? 1 : 0 }}
exit={{ opacity: 0 }}
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it } from "vitest";
import {
DEFAULT_PDF_PAGE_SIZE,
getPreviewCanvasScale,
getResumePreviewGapValue,
getScaledPreviewPageSize,
normalizeResumePreviewProps,
} from "./preview.shared";
@@ -12,7 +13,7 @@ describe("normalizeResumePreviewProps", () => {
it("applies the documented defaults when fields are omitted", () => {
const result = normalizeResumePreviewProps({});
expect(result).toMatchObject({
pageGap: 40,
pageGap: 16,
pageLayout: "horizontal",
pageScale: 1,
showPageNumbers: false,
@@ -52,6 +53,20 @@ describe("getScaledPreviewPageSize", () => {
});
});
describe("getResumePreviewGapValue", () => {
it("adds px units for numeric custom-property gap values", () => {
expect(getResumePreviewGapValue(96)).toBe("96px");
});
it("preserves explicit zero gap", () => {
expect(getResumePreviewGapValue(0)).toBe(0);
});
it("preserves string gap values", () => {
expect(getResumePreviewGapValue("1rem")).toBe("1rem");
});
});
const setDevicePixelRatio = (value: number) => {
Object.defineProperty(window, "devicePixelRatio", {
writable: true,
@@ -29,6 +29,14 @@ describe("ResumePreviewLoader", () => {
expect(screen.getAllByRole("img", { name: /Loading resume page/ })).toHaveLength(pageCount);
});
it("writes numeric page gaps as valid CSS custom-property lengths", () => {
const { container } = render(<ResumePreviewLoader pageGap={96} />);
expect((container.firstElementChild as HTMLElement).style.getPropertyValue("--resume-preview-page-gap")).toBe(
"96px",
);
});
});
describe("getResumePreviewPageCount", () => {
@@ -1,11 +1,12 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { CSSProperties } from "react";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { cn } from "@reactive-resume/utils/style";
export type ResumePreviewProps = {
className?: string;
data?: ResumeData;
pageGap?: React.CSSProperties["gap"];
pageGap?: CSSProperties["gap"];
pageLayout?: "horizontal" | "vertical";
pageScale?: number;
pageClassName?: string;
@@ -13,7 +14,6 @@ export type ResumePreviewProps = {
};
export type ResolvedResumePreviewProps = ResumePreviewProps & {
pageGap: React.CSSProperties["gap"];
pageLayout: "horizontal" | "vertical";
pageScale: number;
showPageNumbers: boolean;
@@ -26,7 +26,7 @@ export type PreviewPageSize = {
type ResumePreviewLoaderProps = Pick<ResumePreviewProps, "pageClassName" | "showPageNumbers"> & {
pageCount?: number;
pageGap?: React.CSSProperties["gap"];
pageGap?: CSSProperties["gap"];
pageLayout?: "horizontal" | "vertical";
pageScale?: number;
};
@@ -39,7 +39,7 @@ export const DEFAULT_PDF_PAGE_SIZE: PreviewPageSize = {
};
export const normalizeResumePreviewProps = ({
pageGap = 40,
pageGap = 16,
pageLayout = "horizontal",
pageScale = 1,
showPageNumbers = false,
@@ -67,25 +67,29 @@ export const getScaledPreviewPageSize = (pageSize: PreviewPageSize, pageScale: n
width: pageSize.width * pageScale,
});
export const getResumePreviewGapValue = (pageGap: CSSProperties["gap"]) =>
typeof pageGap === "number" && pageGap !== 0 ? `${pageGap}px` : pageGap;
export const getResumePreviewPageCount = (data?: ResumeData) => Math.max(1, data?.metadata.layout.pages.length ?? 1);
export function ResumePreviewLoader({
pageCount = 1,
pageClassName,
pageGap = 40,
pageGap = 16,
pageLayout = "horizontal",
pageScale = 1,
showPageNumbers = false,
}: ResumePreviewLoaderProps) {
const pageSize = getScaledPreviewPageSize(DEFAULT_PDF_PAGE_SIZE, pageScale);
const resolvedPageGap = getResumePreviewGapValue(pageGap);
return (
<div
style={{ "--resume-preview-page-gap": resolvedPageGap } as CSSProperties}
className={cn(
"flex justify-start gap-(--resume-preview-page-gap)",
pageLayout === "horizontal" ? "flex-row items-start" : "flex-col items-center",
)}
style={{ "--resume-preview-page-gap": pageGap } as React.CSSProperties}
>
{Array.from({ length: pageCount }, (_, index) => {
const pageNumber = index + 1;
+9 -15
View File
@@ -4,12 +4,11 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { DownloadSimpleIcon, FileIcon, UploadSimpleIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import z from "zod";
import { useAIStore } from "@reactive-resume/ai/store";
import { JSONResumeImporter } from "@reactive-resume/import/json-resume";
import { ReactiveResumeJSONImporter } from "@reactive-resume/import/reactive-resume-json";
import { ReactiveResumeV4JSONImporter } from "@reactive-resume/import/reactive-resume-v4-json";
@@ -91,7 +90,6 @@ function fileToBase64(file: File): Promise<string> {
export function ImportResumeDialog(_: DialogProps<"resume.import">) {
const navigate = useNavigate();
const { enabled: isAIEnabled, provider, model, apiKey, baseURL } = useAIStore();
const closeDialog = useDialogStore((state) => state.closeDialog);
const prevTypeRef = useRef<string>("");
@@ -99,6 +97,8 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
const [isLoading, setIsLoading] = useState<boolean>(false);
const { mutateAsync: importResume } = useMutation(orpc.resume.import.mutationOptions());
const { data: aiProviders, isLoading: isLoadingAiProviders } = useQuery(orpc.aiProviders.list.queryOptions());
const hasAIProvider = aiProviders?.some((provider) => provider.enabled && provider.testStatus === "success") ?? false;
const form = useAppForm({
defaultValues: {
@@ -137,23 +137,21 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
}
if (value.type === "pdf") {
if (!isAIEnabled)
throw new Error(t`This feature requires AI Integration to be enabled. Please enable it in the settings.`);
if (isLoadingAiProviders) throw new Error(t`Loading AI providers. Please try again in a moment.`);
if (!hasAIProvider)
throw new Error(t`This feature requires a tested AI provider. Please add one in the settings.`);
const base64 = await fileToBase64(value.file);
data = await client.ai.parsePdf({
provider,
model,
apiKey,
baseURL,
file: { name: value.file.name, data: base64 },
});
}
if (value.type === "docx") {
if (!isAIEnabled)
throw new Error(t`This feature requires AI Integration to be enabled. Please enable it in the settings.`);
if (isLoadingAiProviders) throw new Error(t`Loading AI providers. Please try again in a moment.`);
if (!hasAIProvider)
throw new Error(t`This feature requires a tested AI provider. Please add one in the settings.`);
const base64 = await fileToBase64(value.file);
@@ -163,10 +161,6 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
: ("application/vnd.openxmlformats-officedocument.wordprocessingml.document" as const);
data = await client.ai.parseDocx({
provider,
model,
apiKey,
baseURL,
mediaType,
file: { name: value.file.name, data: base64 },
});
+92
View File
@@ -12,10 +12,12 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as SchemaDotjsonRouteImport } from './routes/schema[.]json'
import { Route as DashboardRouteRouteImport } from './routes/dashboard/route'
import { Route as AuthRouteRouteImport } from './routes/auth/route'
import { Route as AgentRouteRouteImport } from './routes/agent/route'
import { Route as HomeRouteRouteImport } from './routes/_home/route'
import { Route as McpIndexRouteImport } from './routes/mcp/index'
import { Route as DashboardIndexRouteImport } from './routes/dashboard/index'
import { Route as AuthIndexRouteImport } from './routes/auth/index'
import { Route as AgentIndexRouteImport } from './routes/agent/index'
import { Route as HomeIndexRouteImport } from './routes/_home/index'
import { Route as TemplatesSplatRouteImport } from './routes/templates/$'
import { Route as AuthVerify2faBackupRouteImport } from './routes/auth/verify-2fa-backup'
@@ -27,6 +29,8 @@ import { Route as AuthOauthRouteImport } from './routes/auth/oauth'
import { Route as AuthLoginRouteImport } from './routes/auth/login'
import { Route as AuthForgotPasswordRouteImport } from './routes/auth/forgot-password'
import { Route as ApiHealthRouteImport } from './routes/api/health'
import { Route as AgentNewRouteImport } from './routes/agent/new'
import { Route as AgentThreadIdRouteImport } from './routes/agent/$threadId'
import { Route as DotwellKnownOpenidConfigurationRouteImport } from './routes/[.]well-known/openid-configuration'
import { Route as DotwellKnownOauthProtectedResourceRouteImport } from './routes/[.]well-known/oauth-protected-resource'
import { Route as DotwellKnownOauthAuthorizationServerRouteImport } from './routes/[.]well-known/oauth-authorization-server'
@@ -66,6 +70,11 @@ const AuthRouteRoute = AuthRouteRouteImport.update({
path: '/auth',
getParentRoute: () => rootRouteImport,
} as any)
const AgentRouteRoute = AgentRouteRouteImport.update({
id: '/agent',
path: '/agent',
getParentRoute: () => rootRouteImport,
} as any)
const HomeRouteRoute = HomeRouteRouteImport.update({
id: '/_home',
getParentRoute: () => rootRouteImport,
@@ -85,6 +94,11 @@ const AuthIndexRoute = AuthIndexRouteImport.update({
path: '/',
getParentRoute: () => AuthRouteRoute,
} as any)
const AgentIndexRoute = AgentIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AgentRouteRoute,
} as any)
const HomeIndexRoute = HomeIndexRouteImport.update({
id: '/',
path: '/',
@@ -140,6 +154,16 @@ const ApiHealthRoute = ApiHealthRouteImport.update({
path: '/api/health',
getParentRoute: () => rootRouteImport,
} as any)
const AgentNewRoute = AgentNewRouteImport.update({
id: '/new',
path: '/new',
getParentRoute: () => AgentRouteRoute,
} as any)
const AgentThreadIdRoute = AgentThreadIdRouteImport.update({
id: '/$threadId',
path: '/$threadId',
getParentRoute: () => AgentRouteRoute,
} as any)
const DotwellKnownOpenidConfigurationRoute =
DotwellKnownOpenidConfigurationRouteImport.update({
id: '/.well-known/openid-configuration',
@@ -271,6 +295,7 @@ const ApiUploadsUserIdSplatRoute = ApiUploadsUserIdSplatRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof HomeIndexRoute
'/agent': typeof AgentRouteRouteWithChildren
'/auth': typeof AuthRouteRouteWithChildren
'/dashboard': typeof DashboardRouteRouteWithChildren
'/schema.json': typeof SchemaDotjsonRoute
@@ -280,6 +305,8 @@ export interface FileRoutesByFullPath {
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRouteWithChildren
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
'/agent/$threadId': typeof AgentThreadIdRoute
'/agent/new': typeof AgentNewRoute
'/api/health': typeof ApiHealthRoute
'/auth/forgot-password': typeof AuthForgotPasswordRoute
'/auth/login': typeof AuthLoginRoute
@@ -290,6 +317,7 @@ export interface FileRoutesByFullPath {
'/auth/verify-2fa': typeof AuthVerify2faRoute
'/auth/verify-2fa-backup': typeof AuthVerify2faBackupRoute
'/templates/$': typeof TemplatesSplatRoute
'/agent/': typeof AgentIndexRoute
'/auth/': typeof AuthIndexRoute
'/dashboard/': typeof DashboardIndexRoute
'/mcp/': typeof McpIndexRoute
@@ -318,6 +346,8 @@ export interface FileRoutesByTo {
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRouteWithChildren
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
'/agent/$threadId': typeof AgentThreadIdRoute
'/agent/new': typeof AgentNewRoute
'/api/health': typeof ApiHealthRoute
'/auth/forgot-password': typeof AuthForgotPasswordRoute
'/auth/login': typeof AuthLoginRoute
@@ -329,6 +359,7 @@ export interface FileRoutesByTo {
'/auth/verify-2fa-backup': typeof AuthVerify2faBackupRoute
'/templates/$': typeof TemplatesSplatRoute
'/': typeof HomeIndexRoute
'/agent': typeof AgentIndexRoute
'/auth': typeof AuthIndexRoute
'/dashboard': typeof DashboardIndexRoute
'/mcp': typeof McpIndexRoute
@@ -353,6 +384,7 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_home': typeof HomeRouteRouteWithChildren
'/agent': typeof AgentRouteRouteWithChildren
'/auth': typeof AuthRouteRouteWithChildren
'/dashboard': typeof DashboardRouteRouteWithChildren
'/schema.json': typeof SchemaDotjsonRoute
@@ -362,6 +394,8 @@ export interface FileRoutesById {
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRouteWithChildren
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
'/agent/$threadId': typeof AgentThreadIdRoute
'/agent/new': typeof AgentNewRoute
'/api/health': typeof ApiHealthRoute
'/auth/forgot-password': typeof AuthForgotPasswordRoute
'/auth/login': typeof AuthLoginRoute
@@ -373,6 +407,7 @@ export interface FileRoutesById {
'/auth/verify-2fa-backup': typeof AuthVerify2faBackupRoute
'/templates/$': typeof TemplatesSplatRoute
'/_home/': typeof HomeIndexRoute
'/agent/': typeof AgentIndexRoute
'/auth/': typeof AuthIndexRoute
'/dashboard/': typeof DashboardIndexRoute
'/mcp/': typeof McpIndexRoute
@@ -398,6 +433,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/agent'
| '/auth'
| '/dashboard'
| '/schema.json'
@@ -407,6 +443,8 @@ export interface FileRouteTypes {
| '/.well-known/oauth-authorization-server'
| '/.well-known/oauth-protected-resource'
| '/.well-known/openid-configuration'
| '/agent/$threadId'
| '/agent/new'
| '/api/health'
| '/auth/forgot-password'
| '/auth/login'
@@ -417,6 +455,7 @@ export interface FileRouteTypes {
| '/auth/verify-2fa'
| '/auth/verify-2fa-backup'
| '/templates/$'
| '/agent/'
| '/auth/'
| '/dashboard/'
| '/mcp/'
@@ -445,6 +484,8 @@ export interface FileRouteTypes {
| '/.well-known/oauth-authorization-server'
| '/.well-known/oauth-protected-resource'
| '/.well-known/openid-configuration'
| '/agent/$threadId'
| '/agent/new'
| '/api/health'
| '/auth/forgot-password'
| '/auth/login'
@@ -456,6 +497,7 @@ export interface FileRouteTypes {
| '/auth/verify-2fa-backup'
| '/templates/$'
| '/'
| '/agent'
| '/auth'
| '/dashboard'
| '/mcp'
@@ -479,6 +521,7 @@ export interface FileRouteTypes {
id:
| '__root__'
| '/_home'
| '/agent'
| '/auth'
| '/dashboard'
| '/schema.json'
@@ -488,6 +531,8 @@ export interface FileRouteTypes {
| '/.well-known/oauth-authorization-server'
| '/.well-known/oauth-protected-resource'
| '/.well-known/openid-configuration'
| '/agent/$threadId'
| '/agent/new'
| '/api/health'
| '/auth/forgot-password'
| '/auth/login'
@@ -499,6 +544,7 @@ export interface FileRouteTypes {
| '/auth/verify-2fa-backup'
| '/templates/$'
| '/_home/'
| '/agent/'
| '/auth/'
| '/dashboard/'
| '/mcp/'
@@ -523,6 +569,7 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
HomeRouteRoute: typeof HomeRouteRouteWithChildren
AgentRouteRoute: typeof AgentRouteRouteWithChildren
AuthRouteRoute: typeof AuthRouteRouteWithChildren
DashboardRouteRoute: typeof DashboardRouteRouteWithChildren
SchemaDotjsonRoute: typeof SchemaDotjsonRoute
@@ -566,6 +613,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/agent': {
id: '/agent'
path: '/agent'
fullPath: '/agent'
preLoaderRoute: typeof AgentRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/_home': {
id: '/_home'
path: ''
@@ -594,6 +648,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthIndexRouteImport
parentRoute: typeof AuthRouteRoute
}
'/agent/': {
id: '/agent/'
path: '/'
fullPath: '/agent/'
preLoaderRoute: typeof AgentIndexRouteImport
parentRoute: typeof AgentRouteRoute
}
'/_home/': {
id: '/_home/'
path: '/'
@@ -671,6 +732,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiHealthRouteImport
parentRoute: typeof rootRouteImport
}
'/agent/new': {
id: '/agent/new'
path: '/new'
fullPath: '/agent/new'
preLoaderRoute: typeof AgentNewRouteImport
parentRoute: typeof AgentRouteRoute
}
'/agent/$threadId': {
id: '/agent/$threadId'
path: '/$threadId'
fullPath: '/agent/$threadId'
preLoaderRoute: typeof AgentThreadIdRouteImport
parentRoute: typeof AgentRouteRoute
}
'/.well-known/openid-configuration': {
id: '/.well-known/openid-configuration'
path: '/.well-known/openid-configuration'
@@ -847,6 +922,22 @@ const HomeRouteRouteWithChildren = HomeRouteRoute._addFileChildren(
HomeRouteRouteChildren,
)
interface AgentRouteRouteChildren {
AgentThreadIdRoute: typeof AgentThreadIdRoute
AgentNewRoute: typeof AgentNewRoute
AgentIndexRoute: typeof AgentIndexRoute
}
const AgentRouteRouteChildren: AgentRouteRouteChildren = {
AgentThreadIdRoute: AgentThreadIdRoute,
AgentNewRoute: AgentNewRoute,
AgentIndexRoute: AgentIndexRoute,
}
const AgentRouteRouteWithChildren = AgentRouteRoute._addFileChildren(
AgentRouteRouteChildren,
)
interface AuthRouteRouteChildren {
AuthForgotPasswordRoute: typeof AuthForgotPasswordRoute
AuthLoginRoute: typeof AuthLoginRoute
@@ -948,6 +1039,7 @@ const DotwellKnownOauthProtectedResourceRouteWithChildren =
const rootRouteChildren: RootRouteChildren = {
HomeRouteRoute: HomeRouteRouteWithChildren,
AgentRouteRoute: AgentRouteRouteWithChildren,
AuthRouteRoute: AuthRouteRouteWithChildren,
DashboardRouteRoute: DashboardRouteRouteWithChildren,
SchemaDotjsonRoute: SchemaDotjsonRoute,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import {
attachmentIdsFromTransportBody,
attachmentToFilePart,
buildAgentChatSubmission,
} from "./-helpers/chat-attachments";
describe("agent chat attachment helpers", () => {
it("builds safe UI file parts without embedding file bytes", () => {
expect(attachmentToFilePart({ id: "attachment-1", filename: "resume.pdf", mediaType: "application/pdf" })).toEqual({
type: "file",
url: "agent-attachment:attachment-1",
mediaType: "application/pdf",
filename: "resume.pdf",
});
});
it("extracts only string attachment IDs from transport body metadata", () => {
expect(attachmentIdsFromTransportBody({ attachmentIds: ["a", 1, "b", null] })).toEqual(["a", "b"]);
expect(attachmentIdsFromTransportBody({ attachmentIds: "a" })).toBeUndefined();
expect(attachmentIdsFromTransportBody(undefined)).toBeUndefined();
});
it("keeps attachment IDs in transport metadata while sending file parts in the UI message", () => {
const submission = buildAgentChatSubmission(" Tailor this ", [
{ id: "attachment-1", filename: "job.txt", mediaType: "text/plain" },
{ id: "attachment-2", filename: "portfolio.png", mediaType: "image/png" },
]);
expect(submission).toEqual({
message: {
text: "Tailor this",
files: [
{
type: "file",
url: "agent-attachment:attachment-1",
mediaType: "text/plain",
filename: "job.txt",
},
{
type: "file",
url: "agent-attachment:attachment-2",
mediaType: "image/png",
filename: "portfolio.png",
},
],
},
options: { body: { attachmentIds: ["attachment-1", "attachment-2"] } },
});
});
it("supports attachment-only submissions", () => {
const submission = buildAgentChatSubmission(" ", [
{ id: "attachment-1", filename: "job.txt", mediaType: "text/plain" },
]);
expect(submission.message).toEqual({
files: [
{
type: "file",
url: "agent-attachment:attachment-1",
mediaType: "text/plain",
filename: "job.txt",
},
],
});
expect(submission.options).toEqual({ body: { attachmentIds: ["attachment-1"] } });
});
});
@@ -0,0 +1,209 @@
import type { AIProvider } from "@reactive-resume/ai/types";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon, ChatCircleDotsIcon, FilePlusIcon, GearSixIcon } from "@phosphor-icons/react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { useIsClient } from "usehooks-ts";
import { Badge } from "@reactive-resume/ui/components/badge";
import { Button } from "@reactive-resume/ui/components/button";
import { Label } from "@reactive-resume/ui/components/label";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { Combobox } from "@/components/ui/combobox";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
function providerLabel(provider: { label: string; provider: AIProvider; model: string }) {
return `${provider.label} · ${provider.provider} · ${provider.model}`;
}
function isAgentConfigError(error: unknown) {
if (!error || typeof error !== "object") return false;
const message = (error as { message?: unknown }).message;
const status = (error as { status?: unknown; code?: unknown }).status ?? (error as { code?: unknown }).code;
if (status === "PRECONDITION_FAILED" || status === 412) return true;
return typeof message === "string" && /REDIS_URL|ENCRYPTION_SECRET/.test(message);
}
export function NewThreadSetup({ resumeId }: { resumeId?: string }) {
const isClient = useIsClient();
const navigate = useNavigate();
const {
data: providers,
isLoading: isLoadingProviders,
error: providersError,
} = useQuery(orpc.aiProviders.list.queryOptions());
const { data: resumes, isLoading: isLoadingResumes } = useQuery(
orpc.resume.list.queryOptions({ input: { sort: "lastUpdatedAt", tags: [] } }),
);
const { mutate: createThread, isPending } = useMutation(orpc.agent.threads.create.mutationOptions());
const usableProviders = useMemo(
() => providers?.filter((provider) => provider.enabled && provider.testStatus === "success") ?? [],
[providers],
);
const [aiProviderId, setAiProviderId] = useState<string | null>(null);
const [sourceResumeId, setSourceResumeId] = useState<string | null>(resumeId ?? null);
useEffect(() => {
if (aiProviderId || usableProviders.length === 0) return;
setAiProviderId(usableProviders[0]?.id ?? null);
}, [aiProviderId, usableProviders]);
useEffect(() => {
setSourceResumeId(resumeId ?? null);
}, [resumeId]);
const providerOptions = usableProviders.map((provider) => ({
value: provider.id,
label: providerLabel(provider),
keywords: [provider.label, provider.provider, provider.model],
}));
const resumeOptions = [
{ value: "__scratch__", label: t`Create from scratch` },
...(resumes?.map((resume) => ({
value: resume.id,
label: resume.name,
keywords: [resume.name, resume.slug, ...resume.tags],
})) ?? []),
];
const selectedResumeValue = sourceResumeId ?? "__scratch__";
const canCreate = !!aiProviderId && usableProviders.length > 0;
if (!isClient) return null;
return (
<div className="mx-auto grid w-full max-w-4xl gap-6 self-center p-4 lg:p-6">
<div className="flex items-start gap-4">
<div className="grid size-12 shrink-0 place-items-center rounded-md border bg-card shadow-sm lg:size-14">
<ChatCircleDotsIcon className="size-6 text-foreground" weight="fill" />
</div>
<div className="min-w-0">
<h1 className="font-semibold text-3xl tracking-tight lg:text-4xl">
<Trans>Start a thread</Trans>
</h1>
<p className="mt-1 text-muted-foreground">
<Trans>Choose a model and resume draft.</Trans>
</p>
</div>
</div>
{providersError ? (
<div className="rounded-md border border-amber-300 bg-amber-50 p-4 text-amber-950 text-sm dark:bg-amber-950/20 dark:text-amber-200">
{isAgentConfigError(providersError) ? (
<Trans>AI agent setup is unavailable until REDIS_URL and ENCRYPTION_SECRET are configured.</Trans>
) : (
<Trans>AI agent setup is unavailable right now. Please try again in a moment.</Trans>
)}
</div>
) : null}
<div className="rounded-md border bg-card p-4 shadow-sm lg:p-6">
<div className="grid gap-x-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="relative isolate min-h-32 overflow-hidden rounded-md p-1 lg:p-2">
<span
aria-hidden="true"
className="pointer-events-none absolute -top-7 right-1 -z-10 select-none font-black text-8xl text-foreground/[0.045] leading-none lg:-top-10 lg:right-3 lg:text-[8rem]"
>
1
</span>
<div className="space-y-3">
<Label>
<Trans>Select an agent model</Trans>
</Label>
<Combobox
value={aiProviderId}
options={providerOptions}
disabled={isLoadingProviders || providerOptions.length === 0}
placeholder={isLoadingProviders ? t`Loading providers...` : t`Select a tested provider`}
onValueChange={setAiProviderId}
/>
{providerOptions.length === 0 && !isLoadingProviders ? (
<div className="flex flex-col gap-3 rounded-md border border-dashed p-3 text-sm lg:flex-row lg:items-center lg:justify-between">
<span className="text-muted-foreground">
<Trans>Add and test a provider before starting a thread.</Trans>
</span>
<Button
size="sm"
variant="outline"
nativeButton={false}
render={<Link to="/dashboard/settings/integrations" />}
>
<GearSixIcon />
<Trans>Settings</Trans>
</Button>
</div>
) : null}
</div>
</div>
<div className="relative isolate min-h-32 overflow-hidden rounded-md p-1 lg:p-2">
<span
aria-hidden="true"
className="pointer-events-none absolute -top-7 right-1 -z-10 select-none font-black text-8xl text-foreground/[0.045] leading-none lg:-top-10 lg:right-3 lg:text-[8rem]"
>
2
</span>
<div className="space-y-3">
<Label>
<Trans>Select a resume</Trans>
</Label>
<Combobox
value={selectedResumeValue}
showClear={false}
options={resumeOptions}
disabled={isLoadingResumes}
placeholder={isLoadingResumes ? t`Loading resumes...` : t`Choose a resume`}
onValueChange={(value) => setSourceResumeId(value && value !== "__scratch__" ? value : null)}
/>
<div className="flex flex-wrap items-center gap-2 text-muted-foreground text-sm">
<Badge variant="secondary" className="h-7 gap-1.5 rounded-md px-2">
<FilePlusIcon />
{sourceResumeId ? <Trans>Duplicate as AI Draft</Trans> : <Trans>Blank draft</Trans>}
</Badge>
</div>
</div>
</div>
</div>
<div className="mt-2 flex border-t pt-5 lg:justify-end">
<Button
size="lg"
className="h-11 w-full gap-2 px-5 lg:w-auto"
disabled={!canCreate || isPending}
onClick={() =>
createThread(
{
...(aiProviderId ? { aiProviderId } : {}),
...(sourceResumeId ? { sourceResumeId } : {}),
},
{
onSuccess: (thread) => {
void navigate({ to: "/agent/$threadId", params: { threadId: thread.id } });
},
onError: (error) =>
toast.error(
getOrpcErrorMessage(error, {
byCode: {
PRECONDITION_FAILED: t`AI agent setup is unavailable until REDIS_URL and ENCRYPTION_SECRET are configured.`,
BAD_REQUEST: t`Select a tested provider before starting a thread.`,
},
fallback: t`Failed to start agent thread.`,
}),
),
},
)
}
>
<Trans>Start Thread</Trans>
{isPending ? <Spinner /> : <ArrowRightIcon />}
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,217 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import {
ArchiveIcon,
ArrowLeftIcon,
ChatCircleDotsIcon,
DotsThreeVerticalIcon,
PlusIcon,
TrashIcon,
} from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
import { Button } from "@reactive-resume/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
import { cn } from "@reactive-resume/utils/style";
import { useConfirm } from "@/hooks/use-confirm";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
type AgentThreadSummary = RouterOutput["agent"]["threads"]["list"][number];
function formatRelativeTime(value: Date | string, locale: string) {
const date = value instanceof Date ? value : new Date(value);
const diffMs = date.getTime() - Date.now();
const absMs = Math.abs(diffMs);
const divisions: Array<{ amount: number; unit: Intl.RelativeTimeFormatUnit }> = [
{ amount: 31_536_000_000, unit: "year" },
{ amount: 2_592_000_000, unit: "month" },
{ amount: 604_800_000, unit: "week" },
{ amount: 86_400_000, unit: "day" },
{ amount: 3_600_000, unit: "hour" },
{ amount: 60_000, unit: "minute" },
];
if (absMs < 60_000) return new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(0, "second");
const division = divisions.find((candidate) => absMs >= candidate.amount);
if (!division) return "";
return new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(
Math.round(diffMs / division.amount),
division.unit,
);
}
function ThreadActions({ thread, activeThreadId }: { thread: AgentThreadSummary; activeThreadId: string | null }) {
const navigate = useNavigate();
const confirm = useConfirm();
const queryClient = useQueryClient();
const archiveMutation = useMutation(orpc.agent.threads.archive.mutationOptions());
const deleteMutation = useMutation(orpc.agent.threads.delete.mutationOptions());
const isArchived = thread.status === "archived";
const handleArchive = () => {
archiveMutation.mutate(
{ id: thread.id },
{
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: orpc.agent.threads.list.queryKey() });
if (activeThreadId === thread.id) {
await queryClient.invalidateQueries({
queryKey: orpc.agent.threads.get.queryKey({ input: { id: thread.id } }),
});
}
},
onError: (error) => toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to archive thread.` })),
},
);
};
const handleDelete = async () => {
const confirmed = await confirm(t`Delete this agent thread?`, {
description: t`This action cannot be undone. Messages and thread attachments will be removed.`,
});
if (!confirmed) return;
deleteMutation.mutate(
{ id: thread.id },
{
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: orpc.agent.threads.list.queryKey() });
if (activeThreadId === thread.id) void navigate({ to: "/agent" });
},
onError: (error) => toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to delete thread.` })),
},
);
};
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
size="icon-sm"
variant="ghost"
className="absolute end-1.5 top-2 opacity-60 transition-opacity hover:opacity-100 focus-visible:opacity-100 group-hover/thread:opacity-100 aria-expanded:opacity-100"
>
<DotsThreeVerticalIcon />
<span className="sr-only">
<Trans>Thread actions</Trans>
</span>
</Button>
}
/>
<DropdownMenuContent align="end">
{!isArchived ? (
<DropdownMenuItem disabled={archiveMutation.isPending} onClick={handleArchive}>
<ArchiveIcon />
<Trans>Archive</Trans>
</DropdownMenuItem>
) : null}
<DropdownMenuItem variant="destructive" disabled={deleteMutation.isPending} onClick={() => void handleDelete()}>
<TrashIcon />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function ThreadRow({ thread, activeThreadId }: { thread: AgentThreadSummary; activeThreadId: string | null }) {
const { i18n } = useLingui();
const isActive = thread.id === activeThreadId;
const isArchived = thread.status === "archived";
const title = thread.title === thread.resumeName ? t`New thread` : thread.title;
return (
<div
className={cn(
"group/thread relative rounded-md transition-colors hover:bg-accent",
isActive && "bg-accent",
isArchived && "opacity-60",
)}
>
<Link
to="/agent/$threadId"
params={{ threadId: thread.id }}
className="block min-w-0 rounded-md px-3 py-2 pe-10 text-sm outline-hidden ring-ring focus-visible:ring-2"
>
<div className="truncate font-medium">{title}</div>
<div className="truncate text-muted-foreground text-xs">
{formatRelativeTime(thread.lastMessageAt, i18n.locale)}
</div>
</Link>
<ThreadActions thread={thread} activeThreadId={activeThreadId} />
</div>
);
}
export function AgentThreadSidebar({
activeThreadId = null,
className,
}: {
activeThreadId?: string | null;
className?: string;
}) {
const { data: threads, isLoading } = useQuery(orpc.agent.threads.list.queryOptions());
return (
<aside className={cn("flex h-full min-h-0 flex-col border-e bg-muted/30", className)}>
<div className="flex h-14 shrink-0 items-center justify-between gap-3 border-b px-3">
<div className="flex min-w-0 items-center gap-2">
<ChatCircleDotsIcon className="shrink-0" />
<div className="min-w-0 truncate font-semibold">
<Trans>Threads</Trans>
</div>
</div>
<Button size="icon-sm" variant="ghost" nativeButton={false} render={<Link to="/dashboard/resumes" />}>
<ArrowLeftIcon />
<span className="sr-only">
<Trans>Back to resumes</Trans>
</span>
</Button>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-1 p-2">
<Button
variant="ghost"
className="mb-2 w-full justify-start border border-dashed bg-background/40 text-muted-foreground hover:text-foreground"
nativeButton={false}
render={<Link to="/agent/new" />}
>
<PlusIcon />
<Trans>New thread</Trans>
</Button>
{isLoading ? (
<div className="px-3 py-2 text-muted-foreground text-sm">
<Trans>Loading threads...</Trans>
</div>
) : null}
{threads?.length === 0 ? (
<div className="rounded-md border border-dashed p-3 text-muted-foreground text-sm">
<Trans>No threads yet.</Trans>
</div>
) : null}
{threads?.map((thread) => (
<ThreadRow key={thread.id} thread={thread} activeThreadId={activeThreadId} />
))}
</div>
</ScrollArea>
</aside>
);
}
@@ -0,0 +1,38 @@
import type { FileUIPart } from "ai";
export type ChatAttachment = {
id: string;
filename: string;
mediaType: string;
};
export function attachmentToFilePart(attachment: ChatAttachment): FileUIPart {
return {
type: "file",
url: `agent-attachment:${attachment.id}`,
mediaType: attachment.mediaType,
filename: attachment.filename,
};
}
export function attachmentIdsFromTransportBody(body: object | undefined) {
return body && "attachmentIds" in body && Array.isArray(body.attachmentIds)
? body.attachmentIds.filter((id): id is string => typeof id === "string")
: undefined;
}
export function buildAgentChatSubmission(text: string, pendingAttachments: ChatAttachment[]) {
const trimmedText = text.trim();
const files = pendingAttachments.map(attachmentToFilePart);
const attachmentIds = pendingAttachments.map((attachment) => attachment.id);
const options = { body: { attachmentIds } };
if (trimmedText) {
return {
message: files.length > 0 ? { text: trimmedText, files } : { text: trimmedText },
options,
};
}
return { message: { files }, options };
}
+44
View File
@@ -0,0 +1,44 @@
import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon, ChatCircleDotsIcon } from "@phosphor-icons/react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { Button } from "@reactive-resume/ui/components/button";
import { AgentThreadSidebar } from "./-components/thread-sidebar";
export const Route = createFileRoute("/agent/")({
component: RouteComponent,
});
function RouteComponent() {
return (
<div className="flex h-svh bg-background">
<div className="w-72 shrink-0">
<AgentThreadSidebar />
</div>
<main className="grid min-w-0 flex-1 place-items-center p-6">
<div className="w-full max-w-xl rounded-md border bg-card p-6 shadow-sm">
<div className="flex items-start gap-4">
<div className="grid size-11 shrink-0 place-items-center rounded-md border bg-background">
<ChatCircleDotsIcon className="size-5" weight="fill" />
</div>
<div className="min-w-0 space-y-2">
<h1 className="font-semibold text-2xl tracking-tight">
<Trans>Select a thread</Trans>
</h1>
<p className="text-muted-foreground text-sm">
<Trans>Choose an existing conversation from the sidebar, or start a new draft-focused thread.</Trans>
</p>
</div>
</div>
<div className="mt-6 flex justify-end border-t pt-4">
<Button nativeButton={false} render={<Link to="/agent/new" />}>
<ArrowRightIcon />
<Trans>Start new thread</Trans>
</Button>
</div>
</div>
</main>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { createFileRoute } from "@tanstack/react-router";
import z from "zod";
import { NewThreadSetup } from "./-components/new-thread-setup";
import { AgentThreadSidebar } from "./-components/thread-sidebar";
const searchSchema = z.object({ resumeId: z.string().optional() });
export const Route = createFileRoute("/agent/new")({
component: RouteComponent,
validateSearch: searchSchema,
});
function RouteComponent() {
const { resumeId } = Route.useSearch();
return (
<div className="flex h-svh bg-background">
<div className="w-72 shrink-0">
<AgentThreadSidebar />
</div>
<main className="grid min-w-0 flex-1 overflow-auto">
<NewThreadSetup resumeId={resumeId} />
</main>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/agent")({
component: RouteComponent,
beforeLoad: async ({ context }) => {
if (!context.session) throw redirect({ to: "/auth/login", replace: true });
return { session: context.session };
},
});
function RouteComponent() {
return <Outlet />;
}
@@ -1,28 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { useBuilderAssistantStore } from "./assistant-store";
afterEach(() => useBuilderAssistantStore.setState({ isOpen: false }));
describe("useBuilderAssistantStore", () => {
it("starts closed", () => {
expect(useBuilderAssistantStore.getState().isOpen).toBe(false);
});
it("setOpen overrides the open state directly", () => {
useBuilderAssistantStore.getState().setOpen(true);
expect(useBuilderAssistantStore.getState().isOpen).toBe(true);
useBuilderAssistantStore.getState().setOpen(false);
expect(useBuilderAssistantStore.getState().isOpen).toBe(false);
});
it("toggleOpen flips the state", () => {
const { toggleOpen } = useBuilderAssistantStore.getState();
toggleOpen();
expect(useBuilderAssistantStore.getState().isOpen).toBe(true);
toggleOpen();
expect(useBuilderAssistantStore.getState().isOpen).toBe(false);
});
});
@@ -1,13 +0,0 @@
import { create } from "zustand/react";
type BuilderAssistantStore = {
isOpen: boolean;
setOpen: (isOpen: boolean) => void;
toggleOpen: () => void;
};
export const useBuilderAssistantStore = create<BuilderAssistantStore>((set) => ({
isOpen: false,
setOpen: (isOpen) => set({ isOpen }),
toggleOpen: () => set((state) => ({ isOpen: !state.isOpen })),
}));
File diff suppressed because it is too large Load Diff
@@ -14,6 +14,7 @@ import {
MagnifyingGlassMinusIcon,
MagnifyingGlassPlusIcon,
} from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router";
import { motion } from "motion/react";
import { useCallback, useMemo, useState } from "react";
import { useControls } from "react-zoom-pan-pinch";
@@ -27,7 +28,6 @@ import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume } from "@/components/resume/builder-resume-draft";
import { authClient } from "@/libs/auth/client";
import { createResumePdfBlob } from "@/libs/resume/pdf-document";
import { useBuilderAssistantStore } from "./assistant-store";
type BuilderDockProps = {
pageLayout: BuilderPreviewPageLayout;
@@ -37,13 +37,12 @@ type BuilderDockProps = {
export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps) {
const { data: session } = authClient.useSession();
const resume = useCurrentResume();
const navigate = useNavigate();
const [_, copyToClipboard] = useCopyToClipboard();
const { zoomIn, zoomOut, centerView } = useControls();
const [isPrinting, setIsPrinting] = useState(false);
const isAssistantOpen = useBuilderAssistantStore((state) => state.isOpen);
const toggleAssistant = useBuilderAssistantStore((state) => state.toggleOpen);
const publicUrl = useMemo(() => {
if (!session?.user.username || !resume?.slug) return "";
@@ -114,9 +113,11 @@ export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps
/>
<DockIcon
icon={ChatCircleDotsIcon}
title={isAssistantOpen ? t`Close AI assistant` : t`Open AI assistant`}
onClick={toggleAssistant}
active={isAssistantOpen}
title={t`Open AI agent`}
onClick={() => {
if (!resume) return;
void navigate({ to: "/agent/new", search: { resumeId: resume.id } });
}}
/>
<div className="mx-1 h-8 w-px bg-border" />
<DockIcon icon={LinkSimpleIcon} title={t`Copy URL`} onClick={() => onCopyUrl()} />
@@ -28,7 +28,7 @@ export function PreviewPage() {
wheel={{ step: 0.001 }}
>
<TransformComponent wrapperClass="h-full! w-full!">
<ResumePreview pageGap="2rem" pageLayout={pageLayout} showPageNumbers />
<ResumePreview showPageNumbers pageLayout={pageLayout} />
</TransformComponent>
<BuilderDock
@@ -6,7 +6,6 @@ import { Link } from "@tanstack/react-router";
import { useMemo } from "react";
import { toast } from "sonner";
import { match } from "ts-pattern";
import { useAIStore } from "@reactive-resume/ai/store";
import { Alert, AlertDescription } from "@reactive-resume/ui/components/alert";
import { Badge } from "@reactive-resume/ui/components/badge";
import { Button } from "@reactive-resume/ui/components/button";
@@ -50,13 +49,11 @@ export function ResumeAnalysisSectionBuilder() {
const queryClient = useQueryClient();
const resume = useResume();
const aiEnabled = useAIStore((state) => state.enabled);
const aiProvider = useAIStore((state) => state.provider);
const aiModel = useAIStore((state) => state.model);
const aiApiKey = useAIStore((state) => state.apiKey);
const aiBaseURL = useAIStore((state) => state.baseURL);
const resumeId = resume?.id ?? "";
const providersQuery = useQuery(orpc.aiProviders.list.queryOptions());
const aiEnabled =
providersQuery.data?.some((provider) => provider.enabled && provider.testStatus === "success") ?? false;
const analysisQuery = useQuery({
...orpc.resume.analysis.getById.queryOptions({ input: { id: resumeId } }),
@@ -106,12 +103,7 @@ export function ResumeAnalysisSectionBuilder() {
if (!resume) return;
analyzeResume({
provider: aiProvider,
model: aiModel,
apiKey: aiApiKey,
baseURL: aiBaseURL,
resumeId: resume.id,
resumeData: resume.data,
});
};
@@ -17,7 +17,6 @@ import {
} from "@/components/resume/builder-resume-draft";
import { useIsMobile } from "@/hooks/use-mobile";
import { orpc } from "@/libs/orpc/client";
import { BuilderAssistant } from "./-components/assistant";
import { BuilderHeader } from "./-components/header";
import { BuilderSidebarLeft } from "./-sidebar/left";
import { BuilderSidebarRight } from "./-sidebar/right";
@@ -168,8 +167,6 @@ function BuilderLayoutShell({ initialLayout }: BuilderLayoutShellProps) {
<BuilderSidebarRight />
</ResizablePanel>
</ResizableGroup>
<BuilderAssistant />
</div>
);
}
@@ -4,6 +4,7 @@ import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import {
BrainIcon,
ChatCircleDotsIcon,
GearSixIcon,
KeyIcon,
ReadCvLogoIcon,
@@ -46,6 +47,11 @@ const appSidebarItems = [
label: msg`Resumes`,
href: "/dashboard/resumes",
},
{
icon: <ChatCircleDotsIcon />,
label: msg`Agents`,
href: "/agent",
},
] as const satisfies SidebarItem[];
const settingsSidebarItems = [
@@ -1,238 +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 AIForm() {
const { set, model, apiKey, baseURL, provider, enabled, testStatus } = useAIStore();
const selectedOption = useMemo(() => {
return providerOptions.find((option) => option.value === provider);
}, [provider]);
const canTestConnection = model.trim().length > 0 && apiKey.trim().length > 0;
const { mutate: testConnection, isPending: isTesting } = useMutation(orpc.ai.testConnection.mutationOptions());
const handleProviderChange = (value: AIProvider | null) => {
if (!value) return;
set((draft) => {
draft.provider = value;
});
const emptyForm = {
label: "",
provider: "openai" as AIProvider,
model: "",
baseURL: "",
apiKey: "",
};
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 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 providerLabel(provider: AIProvider) {
return providerOptions.find((option) => option.value === provider)?.label ?? provider;
}
function isAiProviderConfigError(error: unknown) {
if (error instanceof ORPCError && error.code === "PRECONDITION_FAILED") return true;
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;
}
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">
<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}
</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={provider}
disabled={enabled}
value={form.provider}
showClear={false}
options={providerOptions}
onValueChange={handleProviderChange}
onValueChange={(provider) => {
if (!provider) return;
setForm((current) => ({ ...current, provider }));
}}
/>
</div>
<div className="flex flex-col gap-y-2">
<div className="space-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",
})}
value={form.model}
onChange={(event) => setForm((current) => ({ ...current, model: event.target.value }))}
placeholder={t`gpt-4.1`}
autoCorrect="off"
autoComplete="off"
spellCheck="false"
autoCapitalize="off"
spellCheck="false"
/>
</div>
<div className="flex flex-col gap-y-2 sm:col-span-2">
<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"
name="ai-api-key"
type="password"
value={apiKey}
disabled={enabled}
onChange={(e) =>
set((draft) => {
draft.apiKey = e.target.value;
})
}
value={form.apiKey}
onChange={(event) => setForm((current) => ({ ...current, apiKey: event.target.value }))}
autoCorrect="off"
autoComplete="off"
spellCheck="false"
autoCapitalize="off"
spellCheck="false"
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" />
) : null}
<Trans>Test Connection</Trans>
<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>
@@ -240,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">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="font-semibold text-lg">
<Trans>Artificial Intelligence</Trans>
<Trans>AI Providers</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>
<p className="text-muted-foreground text-sm">
<Trans>API keys are encrypted on the server and never shown again after saving.</Trans>
</p>
</div>
</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} />
</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 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>
<AIForm />
{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>
</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>
+26 -11
View File
@@ -5,18 +5,15 @@ services:
build:
context: .
dockerfile: Dockerfile.dev
init: true
restart: unless-stopped
env_file:
- path: .env.local
required: false
environment:
PORT: "3000"
APP_URL: http://localhost:3000
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres
AUTH_SECRET: change-me-to-a-secure-secret-key-in-production
S3_ACCESS_KEY_ID: seaweedfs
S3_SECRET_ACCESS_KEY: seaweedfs
S3_ENDPOINT: http://seaweedfs:8333
S3_BUCKET: reactive-resume
S3_FORCE_PATH_STYLE: "true"
REDIS_URL: redis://redis:6379
CHOKIDAR_USEPOLLING: "true"
ports:
- "127.0.0.1:3000:3000"
@@ -49,6 +46,8 @@ services:
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
seaweedfs:
condition: service_healthy
seaweedfs_create_bucket:
@@ -69,8 +68,8 @@ services:
POSTGRES_PASSWORD: postgres
volumes:
- postgres_data:/var/lib/postgresql
expose:
- "5432"
ports:
- "127.0.0.1:5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
start_period: 10s
@@ -78,6 +77,21 @@ services:
timeout: 10s
retries: 3
redis:
image: redis:7-alpine
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
@@ -87,8 +101,8 @@ services:
AWS_SECRET_ACCESS_KEY: seaweedfs
volumes:
- seaweedfs_data:/data
expose:
- "8333"
ports:
- "127.0.0.1:8333:8333"
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8888"]
start_period: 10s
@@ -113,4 +127,5 @@ services:
volumes:
postgres_data:
redis_data:
seaweedfs_data:
+25
View File
@@ -21,6 +21,26 @@ services:
timeout: 10s
retries: 3
redis:
image: redis:7-alpine
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
@@ -71,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
@@ -81,6 +103,8 @@ services:
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
seaweedfs:
condition: service_healthy
seaweedfs_create_bucket:
@@ -94,6 +118,7 @@ services:
volumes:
postgres_data:
redis_data:
seaweedfs_data:
networks:
+2
View File
@@ -61,6 +61,7 @@
"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"
@@ -77,6 +78,7 @@
"guides/using-the-patch-api",
"guides/using-the-mcp-server",
"guides/using-ai",
"guides/ai-agent-tools",
"guides/json-resume-schema"
]
},
+21 -2
View File
@@ -96,10 +96,15 @@ S3_SECRET_ACCESS_KEY=seaweedfs
S3_ENDPOINT=http://seaweedfs:8333
S3_BUCKET=reactive-resume
S3_FORCE_PATH_STYLE=true
# AI features (optional; ENCRYPTION_SECRET for saved providers, plus REDIS_URL for the agent)
REDIS_URL=redis://redis:6379
ENCRYPTION_SECRET=your-secure-encryption-secret-here
```
<Warning>
For production deployments, always use strong, unique values for `AUTH_SECRET` and database credentials.
For production deployments, always use strong, unique values for `AUTH_SECRET`, `ENCRYPTION_SECRET`, and
database credentials.
</Warning>
</Step>
@@ -110,6 +115,7 @@ S3_FORCE_PATH_STYLE=true
This starts:
- **PostgreSQL** — Database for storing user data and resumes
- **Redis** — Required for the AI Agent workspace
- **SeaweedFS** — S3-compatible storage for file uploads
- **Reactive Resume** — The main application
</Step>
@@ -131,9 +137,16 @@ Here's what each service in the stack does:
| Service | Port | Description |
| ----------------- | ---- | ---------------------------------------------------- |
| `postgres` | 5432 | PostgreSQL database for storing all application data |
| `redis` | 6379 | Redis instance required by the AI Agent workspace |
| `seaweedfs` | 8333 | S3-compatible object storage for file uploads |
| `reactive_resume` | 3000 | The main Reactive Resume application |
<Note>
Saved AI provider management requires `ENCRYPTION_SECRET`, and the AI Agent workspace requires both `REDIS_URL` and
`ENCRYPTION_SECRET`. Other Reactive Resume features can run without them. Agent attachments and other private objects
require S3-compatible storage; local storage rejects private objects.
</Note>
### Health Checks
All services include built-in health checks. You can verify everything is running correctly:
@@ -192,13 +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` | Enables local-network AI providers (e.g. Ollama) | `false` |
| `FLAG_ALLOW_UNSAFE_AI_BASE_URL` | Allows unsafe/private/non-public AI provider base URLs | `false` |
> **Note:** Some variables are only required for using related features (OAuth, SMTP, S3, etc.) and can be left unset if unused.
> **AI features:** Saved AI provider management requires `ENCRYPTION_SECRET`, and the AI Agent workspace requires both `REDIS_URL` and `ENCRYPTION_SECRET`. Cloudflare variables only enable the optional URL extraction fallback and are not required for normal agent operation. Keep `FLAG_ALLOW_UNSAFE_AI_BASE_URL` disabled unless this is a trusted self-hosted deployment; public HTTPS provider URLs are the safe default.
> **Health check behavior:** `/api/health` reports status for database and storage. A failure in either dependency returns HTTP `503`.
---
+83
View File
@@ -0,0 +1,83 @@
---
title: "AI Agent Tools"
description: "Understand the tools available to the AI Agent workspace and how they affect resume drafts."
---
The AI Agent workspace can use a curated set of tools while it chats with you. You do not call these tools directly; the agent chooses them when your request needs resume data, web context, attachments, questions, or a resume patch.
## Tool activity in chat
Tool activity appears inside the conversation. Some activity is collapsed by default so the chat stays readable.
Applied resume patches are shown as a small inline **Patch applied** item. Open it to inspect the raw JSON payload.
<Frame caption="Patch details and tool activity in the AI Agent chat">
<img
src="/images/guides/ai-agent-tools/screenshot-1.webp"
alt="AI Agent chat showing an applied patch with raw JSON details"
/>
</Frame>
## Available tools
| Tool | What it does | Example request |
| --- | --- | --- |
| `read_resume` | Reads the current AI draft and gives the agent the resume data it can safely edit. | "What are the weakest parts of this resume?" |
| `fetch_url` | Fetches public HTTPS pages, extracts readable content, and returns it as agent input. | "Tailor this resume to this job description: `https://example.com/job`" |
| Provider-native search | Uses the selected provider's native web search when that provider/model supports it. | "Research this company and adjust the summary for its product area." |
| `read_attachment` | Reads extracted text from attached plain text, Markdown, or JSON files. Other supported attachments, such as images or PDFs, are passed to the model when the selected provider can use them. | "Use the attached notes to update the keywords." |
| `ask_user_question` | Shows a question card with answer choices when the agent needs your decision. | "Ask me before changing the career narrative." |
| `apply_resume_patch` | Applies a JSON Patch to the AI draft and stores enough history to revert it later. | "Change the visible name to Amruth Pillai." |
## Resume patches
Resume patches are rooted at the resume data object. For example, the visible resume name is patched at `/basics/name`.
When a patch is applied:
- the AI draft updates immediately;
- the raw JSON Patch is available from the **Patch applied** details;
- an inverse patch is stored for revert;
- the resume preview refreshes to show the updated draft.
If the resume changed after the patch was generated, revert or apply can fail with a version conflict. In that case, ask the agent to retry from the latest draft.
## Web access
The agent can fetch public HTTPS URLs so it can read job descriptions, company pages, or other public context you provide.
For self-hosted deployments:
- private, loopback, and non-HTTPS URLs are blocked by default;
- Cloudflare URL extraction is optional and is used only as a fallback when local readability extraction fails;
- unsafe/private AI provider base URLs require `FLAG_ALLOW_UNSAFE_AI_BASE_URL=true`, which should only be used on trusted self-hosted deployments.
## Attachments
Attach files from the chat composer when the agent needs extra context, such as:
- a job description PDF;
- a portfolio brief;
- a screenshot;
- a plain text note with constraints.
Self-hosted deployments need S3-compatible storage for private agent attachments. Local filesystem storage rejects private objects.
## Good prompts for tool use
Use direct prompts that tell the agent what context to use and how cautious to be:
- "Fetch this role URL, identify the most important keywords, and apply a conservative patch."
- "Read the attached job description and ask me before changing anything outside the summary."
- "Compare my current projects against this company page and suggest only truthful wording."
- "Apply a patch for the visible resume name, then show me what changed."
## When tools are unavailable
Tool use can be limited by the selected provider, deployment configuration, or thread state.
- A deleted provider makes the thread read-only; a disabled or untested provider blocks new agent runs until it is enabled and tested again.
- A deleted working resume makes the thread read-only.
- Archived threads cannot receive new messages.
- URL fetching may fail for private URLs, blocked hosts, non-HTML pages, or pages that cannot be extracted.
- Attachments may fail if private object storage is not configured.
+1 -1
View File
@@ -27,7 +27,7 @@ description: "Learn how to create your first resume in Reactive Resume, set a na
<Frame caption="Create a new resume dialog">
<img
src="/images/guides/creating-your-first-resume/screenshot-1.png"
src="/images/guides/creating-your-first-resume/screenshot-1.webp"
alt="Create a new resume dialog with name, slug, and tags fields"
/>
</Frame>
+1 -1
View File
@@ -23,7 +23,7 @@ Reactive Resume can export your resume in three formats:
<Frame caption="Export section in the resume builder">
<img
src="/images/guides/exporting-your-resume/screenshot-1.png"
src="/images/guides/exporting-your-resume/screenshot-1.webp"
alt="Export section showing JSON, DOCX, and PDF download buttons"
/>
</Frame>
+1 -1
View File
@@ -32,7 +32,7 @@ Reactive Resume can create a new resume from several existing file formats. Use
<Frame caption="Import an existing resume dialog">
<img
src="/images/guides/importing-resumes/screenshot-1.png"
src="/images/guides/importing-resumes/screenshot-1.webp"
alt="Import dialog showing supported resume import types"
/>
</Frame>
@@ -7,7 +7,7 @@ The **Resumes** dashboard is where you manage every resume in your account. Use
<Frame caption="Resumes dashboard in grid view">
<img
src="/images/guides/managing-resumes-from-the-dashboard/screenshot-1.png"
src="/images/guides/managing-resumes-from-the-dashboard/screenshot-1.webp"
alt="Resumes dashboard showing sort controls, grid view, create and import cards, and a sample resume"
/>
</Frame>
@@ -23,7 +23,7 @@ Use the **Grid** and **List** tabs in the top-right of the dashboard to switch b
<Frame caption="Resumes dashboard in list view">
<img
src="/images/guides/managing-resumes-from-the-dashboard/screenshot-2.png"
src="/images/guides/managing-resumes-from-the-dashboard/screenshot-2.webp"
alt="Resumes dashboard showing the same resume in list view"
/>
</Frame>
+1 -1
View File
@@ -36,7 +36,7 @@ Reactive Resume lets you share your resume via a **public URL** that anyone can
<Frame caption="Sharing section in the resume builder">
<img
src="/images/guides/sharing-your-resume-publicly/screenshot-1.png"
src="/images/guides/sharing-your-resume-publicly/screenshot-1.webp"
alt="Sharing section showing the public access control"
/>
</Frame>
+115
View File
@@ -0,0 +1,115 @@
---
title: "Using the AI Agent Workspace"
description: "Learn how to start an isolated AI draft, chat with an agent, review resume patches, and continue existing agent threads."
---
The AI Agent workspace is a dedicated place to work with an AI assistant on a resume draft. It keeps the conversation, tool activity, and read-only resume preview in one full-screen view.
<Info>
Agent threads edit an AI draft copy of your resume. Your original resume is not changed when you start from an existing resume.
</Info>
## Before you start
You need at least one AI provider that is tested and enabled in **Dashboard -> Settings -> Integrations**. For setup, see [Using Artificial Intelligence](/guides/using-ai).
If you self-host Reactive Resume, the agent workspace also requires the server-side agent configuration described in [Self-hosting with Docker](/self-hosting/docker).
## Open the agent workspace
From the dashboard sidebar, click **Agents**.
The agent page always shows your thread sidebar. Use it to continue an existing thread, or click **New thread** to start another one.
You can also open the agent from the builder dock. When you do this, the current resume is preselected in the new thread setup screen.
## Start a new thread
<Steps>
<Step title="Choose an agent model">
Pick the provider/model combo the agent should use. This choice is locked once the thread starts.
</Step>
<Step title="Choose a resume draft">
Select an existing resume to duplicate as an AI draft, or choose **Create from scratch** for a blank draft.
</Step>
<Step title="Start the thread">
Click **Start Thread** to create the draft and open the workspace.
<Frame caption="Starting an AI Agent thread">
<img
src="/images/guides/using-ai-agent/screenshot-1.webp"
alt="AI Agent new thread setup with model and resume selectors"
/>
</Frame>
</Step>
</Steps>
## Use the three-pane workspace
The desktop workspace is split into three panes:
- **Threads** on the left: continue, archive, delete, or start agent threads.
- **Chat** in the center: send prompts, upload files, answer agent questions, and review tool activity.
- **Resume** on the right: read the current AI draft, adjust zoom, open it in the builder, or download a PDF.
<Frame caption="AI Agent workspace with chat and resume preview">
<img
src="/images/guides/using-ai-agent/screenshot-2.webp"
alt="AI Agent workspace showing thread sidebar, chat, and resume preview"
/>
</Frame>
On smaller screens, the workspace uses tabs/sheets so you can switch between threads, chat, and preview without losing the active conversation.
## Ask for resume changes
The agent works best with concrete instructions:
- "Tailor this resume to this job description: `https://example.com/job`"
- "Find weak bullets and rewrite them with stronger outcomes."
- "Compare this draft against a product manager role and update the keywords."
- "Ask me before changing anything that looks uncertain."
You can attach files or images from the composer. The agent can read uploaded attachments when they are relevant to your request.
<Note>
Text input is supported. Voice input is not supported in the agent workspace yet.
</Note>
## Review patches
When the agent edits the resume, the patch is applied immediately to the AI draft. The chat shows a small **Patch applied** line.
Open the line to inspect the raw JSON Patch and use **Revert** if you want to undo that patch.
<Warning>
AI-generated changes can still be inaccurate. Review the draft in the preview or builder before exporting or sharing it.
</Warning>
## Answer agent questions
If the agent needs a decision, it may show a question card with recommended answers. Click the answer you want to send it back to the agent.
This is useful when the agent finds ambiguous instructions, missing job context, or a change that depends on your preference.
## Use the resume preview
The resume pane is read-only. Use the toolbar to:
- decrease or increase zoom;
- set an exact zoom percentage;
- open the AI draft in the builder;
- download the draft as a PDF.
Zoom settings are remembered across refreshes.
## Manage threads
Threads are ordered by the newest message. Use the thread menu to archive or delete a thread.
- **Archive** keeps the conversation but makes the thread read-only.
- **Delete** removes the thread conversation and its attachments. The generated resume draft remains in your dashboard.
Threads can also become read-only if the working resume is deleted, the selected provider is deleted, or the thread is archived. If a provider is disabled or no longer tested, re-enable and test it before sending new messages.
+1 -1
View File
@@ -27,7 +27,7 @@ Use resume analysis when you want a broad review before making changes.
<Frame caption="Resume Analysis section in the builder">
<img
src="/images/guides/using-ai-in-the-builder/screenshot-1.png"
src="/images/guides/using-ai-in-the-builder/screenshot-1.webp"
alt="Resume Analysis section showing the AI setup prompt in the builder sidebar"
/>
</Frame>
+11 -4
View File
@@ -17,7 +17,7 @@ Reactive Resume uses AI providers for features such as resume analysis, AI-assis
<Frame caption="AI provider settings in Integrations">
<img
src="/images/guides/using-ai/screenshot-1.png"
src="/images/guides/using-ai/screenshot-1.webp"
alt="Integrations settings showing AI provider configuration"
/>
</Frame>
@@ -38,6 +38,13 @@ In the **AI Providers** section, fill out the **Add Provider** form.
Click **Save Provider** when the form is complete.
<Frame caption="Saved AI provider in Integrations">
<img
src="/images/guides/using-ai/screenshot-2.webp"
alt="Integrations settings showing a saved and tested AI provider"
/>
</Frame>
<Warning>
Treat API keys like passwords. Anyone with a key can use the connected provider account and may incur costs.
</Warning>
@@ -71,8 +78,8 @@ AI provider credentials are encrypted on the server and are never shown again af
If provider management is unavailable, your self-hosted deployment may be missing required server configuration.
<Warning>
AI provider management requires the server-side services used to encrypt credentials and run AI work. If the page says
provider management is unavailable, check your deployment configuration before using AI features.
AI provider management requires the server-side services used to encrypt credentials. If the page says provider
management is unavailable, check your deployment configuration before using AI features.
</Warning>
## Where AI is used
@@ -84,4 +91,4 @@ After a provider is tested and enabled, you can use AI in:
- **Agent** workflows that create isolated AI drafts;
- **PDF and Microsoft Word imports** from the dashboard import dialog.
For the builder workflow, see [Using AI in the builder](/guides/using-ai-in-the-builder). For AI-assisted imports, see [Importing resumes](/guides/importing-resumes).
For the builder workflow, see [Using AI in the builder](/guides/using-ai-in-the-builder). For the dedicated agent workspace, see [Using the AI Agent Workspace](/guides/using-ai-agent) and [AI Agent Tools](/guides/ai-agent-tools). For AI-assisted imports, see [Importing resumes](/guides/importing-resumes).
+1 -1
View File
@@ -7,7 +7,7 @@ The builder dock is the floating toolbar at the bottom of the resume builder. It
<Frame caption="Resume builder with the floating dock">
<img
src="/images/guides/using-the-builder-dock/screenshot-1.png"
src="/images/guides/using-the-builder-dock/screenshot-1.webp"
alt="Resume builder showing the floating dock with preview controls and download shortcuts"
/>
</Frame>
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

+26 -2
View File
@@ -90,7 +90,6 @@ OAUTH_SCOPES=""
# BETTER_AUTH_URL="https://auth.example.com"
# BETTER_AUTH_SECRET=""
# --- Email (optional) ---
# If all keys are disabled, the app logs the email to be sent to the console instead.
SMTP_HOST=""
@@ -112,10 +111,21 @@ S3_BUCKET=""
# Set to "false" for virtual-hosted-style URLs (https://bucket.endpoint), common with AWS S3, Cloudflare R2, etc.
S3_FORCE_PATH_STYLE="false"
# --- AI features (optional) ---
# ENCRYPTION_SECRET is required for saved AI providers. REDIS_URL is also required for the AI Agent workspace.
# The rest of Reactive Resume can run without these.
REDIS_URL=""
# Generated using `openssl rand -hex 32`
ENCRYPTION_SECRET=""
# Optional fallback for URL extraction. Not required for normal agent operation.
CLOUDFLARE_ACCOUNT_ID=""
CLOUDFLARE_API_TOKEN=""
# --- Feature Flags ---
FLAG_DISABLE_SIGNUPS="false"
FLAG_DISABLE_EMAIL_AUTH="false"
FLAG_DISABLE_IMAGE_PROCESSING="false"
# Allows unsafe/private/non-public AI provider base URLs. Keep false unless this is a trusted self-hosted deployment.
FLAG_ALLOW_UNSAFE_AI_BASE_URL="false"
```
@@ -251,6 +261,9 @@ docker compose logs -f reactive-resume
<li>
S3 storage (<code>S3_&#42;</code>)
</li>
<li>
AI providers and AI Agent workspace (<code>ENCRYPTION_SECRET</code>, <code>REDIS_URL</code>)
</li>
<li>
Feature flags (<code>FLAG_&#42;</code>)
</li>
@@ -326,16 +339,27 @@ openssl rand -hex 32
- **`LOCAL_STORAGE_PATH`** (optional): Overrides the local data directory. Defaults to `/app/data` in the official Docker image and `<workspace>/data` in development. The container validates this path is writable at startup and refuses to start otherwise.
- **Rootless Docker**: `/app/data` remains the container path. Prefer the named volume from the example Compose file, or make sure a bind-mounted host directory is writable by the container's `node` user mapping.
- **S3/S3-compatible**: Configure `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_REGION`, `S3_ENDPOINT`, and `S3_BUCKET`.
- **Agent attachments/private objects**: The AI Agent workspace requires S3-compatible storage for private objects. Local storage rejects private objects.
- **`S3_FORCE_PATH_STYLE`** controls bucket addressing (defaults to `"false"`):
- `"true"` for path-style URLs (`https://endpoint/bucket`) common with MinIO/SeaweedFS.
- `"false"` for virtual-hosted-style URLs (`https://bucket.endpoint`) common with AWS S3 / Cloudflare R2.
</Accordion>
<Accordion title="AI features (optional)">
Saved AI provider management is usable only when **`ENCRYPTION_SECRET`** is configured. The AI Agent workspace also requires **`REDIS_URL`**. The rest of Reactive Resume can run without them.
- **`REDIS_URL`**: Redis connection string used by the AI Agent workspace.
- **`ENCRYPTION_SECRET`**: Secret used to encrypt saved AI provider credentials. Generate with `openssl rand -hex 32`.
- **`CLOUDFLARE_ACCOUNT_ID`** / **`CLOUDFLARE_API_TOKEN`** (optional): Enables the Cloudflare URL extraction fallback. Cloudflare is not required for normal agent operation.
If you use the Postgres-only Compose example above and want the AI Agent workspace, add a Redis service or use managed Redis, then set `REDIS_URL`.
</Accordion>
<Accordion title="Feature Flags">
- **`FLAG_DISABLE_SIGNUPS`**: Disables new signups (web app and server). Useful for private instances.
- **`FLAG_DISABLE_EMAIL_AUTH`**: Disables email/password login entirely. Also disables email verification, forgot password, and reset password flows. Users can still sign up via social auth (Google/GitHub/LinkedIn/Custom OAuth), unless FLAG_DISABLE_SIGNUPS is also set to true. Useful when only SSO is required.
- **`FLAG_DISABLE_IMAGE_PROCESSING`**: Disables image processing. This is useful if you are using a machine with limited resources, like a Raspberry Pi.
- **`FLAG_ALLOW_UNSAFE_AI_BASE_URL`**: Allows AI providers to be configured with `http://` URLs and private/loopback addresses (e.g. a local Ollama instance at `http://192.168.1.10:11434`). **Warning: enabling this on a multi-tenant deployment is an SSRF risk.** Only enable on a trusted, single-tenant self-hosted instance.
- **`FLAG_ALLOW_UNSAFE_AI_BASE_URL`**: Allows AI providers to be configured with unsafe, private, or non-public base URLs, including `http://` and private/loopback addresses (for example, a local Ollama instance at `http://192.168.1.10:11434`). Public HTTPS provider URLs remain the safe default. **Warning: enabling this on a multi-tenant deployment is an SSRF risk.** Only enable on trusted, self-hosted deployments.
</Accordion>
</AccordionGroup>
+3
View File
@@ -14,6 +14,9 @@
"config": []
}
},
"packages/api": {
"ignoreDependencies": ["ioredis"]
},
"packages/runtime-externals": {
"ignoreDependencies": ["@aws-sdk/client-s3", "bcrypt", "sharp"]
}
@@ -0,0 +1,111 @@
CREATE TABLE "agent_actions" (
"id" text PRIMARY KEY,
"user_id" text NOT NULL,
"thread_id" text NOT NULL,
"message_id" text,
"resume_id" text,
"kind" text NOT NULL,
"status" text DEFAULT 'applied' NOT NULL,
"title" text NOT NULL,
"summary" text,
"operations" jsonb NOT NULL,
"inverse_operations" jsonb NOT NULL,
"base_updated_at" timestamp with time zone NOT NULL,
"applied_updated_at" timestamp with time zone NOT NULL,
"reverted_at" timestamp with time zone,
"revert_message" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "agent_attachments" (
"id" text PRIMARY KEY,
"user_id" text NOT NULL,
"thread_id" text NOT NULL,
"message_id" text,
"storage_key" text NOT NULL,
"filename" text NOT NULL,
"media_type" text NOT NULL,
"size" integer NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "agent_messages" (
"id" text PRIMARY KEY,
"user_id" text NOT NULL,
"thread_id" text NOT NULL,
"role" text NOT NULL,
"status" text DEFAULT 'completed' NOT NULL,
"sequence" integer NOT NULL,
"ui_message" jsonb NOT NULL,
"error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "agent_threads" (
"id" text PRIMARY KEY,
"user_id" text NOT NULL,
"ai_provider_id" text,
"source_resume_id" text,
"working_resume_id" text,
"title" text NOT NULL,
"status" text DEFAULT 'active' NOT NULL,
"active_run_id" text,
"active_stream_id" text,
"active_run_started_at" timestamp with time zone,
"last_message_at" timestamp with time zone DEFAULT now() NOT NULL,
"archived_at" timestamp with time zone,
"deleted_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "ai_providers" (
"id" text PRIMARY KEY,
"user_id" text NOT NULL,
"label" text NOT NULL,
"provider" text NOT NULL,
"model" text NOT NULL,
"base_url" text,
"encrypted_api_key" text NOT NULL,
"api_key_salt" text NOT NULL,
"api_key_hash" text NOT NULL,
"api_key_preview" text NOT NULL,
"test_status" text DEFAULT 'untested' NOT NULL,
"test_error" text,
"last_tested_at" timestamp with time zone,
"last_used_at" timestamp with time zone,
"enabled" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX "agent_actions_thread_id_created_at_index" ON "agent_actions" ("thread_id","created_at" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "agent_actions_resume_id_index" ON "agent_actions" ("resume_id");--> statement-breakpoint
CREATE INDEX "agent_actions_message_id_index" ON "agent_actions" ("message_id");--> statement-breakpoint
CREATE INDEX "agent_attachments_thread_id_index" ON "agent_attachments" ("thread_id");--> statement-breakpoint
CREATE INDEX "agent_attachments_message_id_index" ON "agent_attachments" ("message_id");--> statement-breakpoint
CREATE INDEX "agent_attachments_user_id_index" ON "agent_attachments" ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "agent_messages_thread_id_sequence_index" ON "agent_messages" ("thread_id","sequence");--> statement-breakpoint
CREATE INDEX "agent_messages_user_id_created_at_index" ON "agent_messages" ("user_id","created_at" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "agent_threads_user_id_status_last_message_at_index" ON "agent_threads" ("user_id","status","last_message_at" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "agent_threads_working_resume_id_index" ON "agent_threads" ("working_resume_id");--> statement-breakpoint
CREATE INDEX "agent_threads_ai_provider_id_index" ON "agent_threads" ("ai_provider_id");--> statement-breakpoint
CREATE INDEX "ai_providers_user_id_enabled_index" ON "ai_providers" ("user_id","enabled");--> statement-breakpoint
CREATE INDEX "ai_providers_user_id_last_used_at_index" ON "ai_providers" ("user_id","last_used_at" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "ai_providers_user_id_created_at_index" ON "ai_providers" ("user_id","created_at");--> statement-breakpoint
ALTER TABLE "agent_actions" ADD CONSTRAINT "agent_actions_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "agent_actions" ADD CONSTRAINT "agent_actions_thread_id_agent_threads_id_fkey" FOREIGN KEY ("thread_id") REFERENCES "agent_threads"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "agent_actions" ADD CONSTRAINT "agent_actions_message_id_agent_messages_id_fkey" FOREIGN KEY ("message_id") REFERENCES "agent_messages"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "agent_actions" ADD CONSTRAINT "agent_actions_resume_id_resume_id_fkey" FOREIGN KEY ("resume_id") REFERENCES "resume"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "agent_attachments" ADD CONSTRAINT "agent_attachments_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "agent_attachments" ADD CONSTRAINT "agent_attachments_thread_id_agent_threads_id_fkey" FOREIGN KEY ("thread_id") REFERENCES "agent_threads"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "agent_attachments" ADD CONSTRAINT "agent_attachments_message_id_agent_messages_id_fkey" FOREIGN KEY ("message_id") REFERENCES "agent_messages"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "agent_messages" ADD CONSTRAINT "agent_messages_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "agent_messages" ADD CONSTRAINT "agent_messages_thread_id_agent_threads_id_fkey" FOREIGN KEY ("thread_id") REFERENCES "agent_threads"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "agent_threads" ADD CONSTRAINT "agent_threads_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "agent_threads" ADD CONSTRAINT "agent_threads_ai_provider_id_ai_providers_id_fkey" FOREIGN KEY ("ai_provider_id") REFERENCES "ai_providers"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "agent_threads" ADD CONSTRAINT "agent_threads_source_resume_id_resume_id_fkey" FOREIGN KEY ("source_resume_id") REFERENCES "resume"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "agent_threads" ADD CONSTRAINT "agent_threads_working_resume_id_resume_id_fkey" FOREIGN KEY ("working_resume_id") REFERENCES "resume"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "ai_providers" ADD CONSTRAINT "ai_providers_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -4,7 +4,6 @@
"type": "module",
"private": true,
"exports": {
"./store": "./src/store.ts",
"./types": "./src/types.ts",
"./prompts": "./src/prompts.ts",
"./tools/patch-resume": "./src/tools/patch-resume.ts",
@@ -24,10 +23,8 @@
"@reactive-resume/utils": "workspace:*",
"deepmerge-ts": "^7.1.5",
"fast-json-patch": "^3.1.1",
"immer": "^11.1.8",
"jsonrepair": "^3.14.0",
"zod": "^4.4.3",
"zustand": "^5.0.13"
"zod": "^4.4.3"
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
-123
View File
@@ -1,123 +0,0 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from "vitest";
import { useAIStore } from "./store";
const reset = () => {
useAIStore.setState({
enabled: false,
provider: "openai",
model: "",
apiKey: "",
baseURL: "",
testStatus: "unverified",
});
};
afterEach(reset);
describe("useAIStore", () => {
it("starts with provider=openai and disabled state", () => {
const state = useAIStore.getState();
expect(state.enabled).toBe(false);
expect(state.provider).toBe("openai");
expect(state.testStatus).toBe("unverified");
});
it("set() updates fields and resets verification when credential fields change", () => {
useAIStore.setState({ testStatus: "success", enabled: true });
useAIStore.getState().set((draft) => {
draft.apiKey = "new-key";
});
const state = useAIStore.getState();
expect(state.apiKey).toBe("new-key");
expect(state.testStatus).toBe("unverified");
expect(state.enabled).toBe(false);
});
it("set() does NOT reset testStatus when changing non-credential fields", () => {
useAIStore.setState({ testStatus: "success", enabled: true });
useAIStore.getState().set((draft) => {
draft.testStatus = "success"; // explicit no-op
});
const state = useAIStore.getState();
expect(state.testStatus).toBe("success");
expect(state.enabled).toBe(true);
});
it("canEnable() is true only when testStatus is success", () => {
expect(useAIStore.getState().canEnable()).toBe(false);
useAIStore.setState({ testStatus: "success" });
expect(useAIStore.getState().canEnable()).toBe(true);
useAIStore.setState({ testStatus: "failure" });
expect(useAIStore.getState().canEnable()).toBe(false);
});
it("setEnabled(true) refuses to enable when testStatus is not success", () => {
useAIStore.getState().setEnabled(true);
expect(useAIStore.getState().enabled).toBe(false);
});
it("setEnabled(true) succeeds when testStatus is success", () => {
useAIStore.setState({ testStatus: "success" });
useAIStore.getState().setEnabled(true);
expect(useAIStore.getState().enabled).toBe(true);
});
it("setEnabled(false) always succeeds (regardless of testStatus)", () => {
useAIStore.setState({ testStatus: "success", enabled: true });
useAIStore.getState().setEnabled(false);
expect(useAIStore.getState().enabled).toBe(false);
});
it("reset() clears every field back to initial state", () => {
useAIStore.setState({
enabled: true,
provider: "anthropic",
model: "claude-3",
apiKey: "key",
baseURL: "https://api.anthropic.com",
testStatus: "success",
});
useAIStore.getState().reset();
const state = useAIStore.getState();
expect(state).toMatchObject({
enabled: false,
provider: "openai",
model: "",
apiKey: "",
baseURL: "",
testStatus: "unverified",
});
});
it("resets when only the provider changes", () => {
useAIStore.setState({ testStatus: "success", enabled: true });
useAIStore.getState().set((draft) => {
draft.provider = "gemini";
});
expect(useAIStore.getState().testStatus).toBe("unverified");
expect(useAIStore.getState().enabled).toBe(false);
});
it("resets when only the baseURL changes", () => {
useAIStore.setState({ testStatus: "success", enabled: true });
useAIStore.getState().set((draft) => {
draft.baseURL = "https://custom.example";
});
expect(useAIStore.getState().testStatus).toBe("unverified");
expect(useAIStore.getState().enabled).toBe(false);
});
});
-88
View File
@@ -1,88 +0,0 @@
import type { WritableDraft } from "immer";
import type { AIProvider } from "./types";
import { createJSONStorage, persist } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
import { create } from "zustand/react";
type TestStatus = "unverified" | "success" | "failure";
type AIStoreState = {
enabled: boolean;
provider: AIProvider;
model: string;
apiKey: string;
baseURL: string;
testStatus: TestStatus;
};
type AIStoreActions = {
canEnable: () => boolean;
setEnabled: (value: boolean) => void;
set: (fn: (draft: WritableDraft<AIStoreState>) => void) => void;
reset: () => void;
};
type AIStore = AIStoreState & AIStoreActions;
const initialState: AIStoreState = {
enabled: false,
provider: "openai",
model: "",
apiKey: "",
baseURL: "",
testStatus: "unverified",
};
export const useAIStore = create<AIStore>()(
persist(
immer((set, get) => ({
...initialState,
set: (fn) => {
set((draft) => {
const prev = {
provider: draft.provider,
model: draft.model,
apiKey: draft.apiKey,
baseURL: draft.baseURL,
};
fn(draft);
if (
draft.provider !== prev.provider ||
draft.model !== prev.model ||
draft.apiKey !== prev.apiKey ||
draft.baseURL !== prev.baseURL
) {
draft.testStatus = "unverified";
draft.enabled = false;
}
});
},
reset: () => set(() => initialState),
canEnable: () => {
const { testStatus } = get();
return testStatus === "success";
},
setEnabled: (value: boolean) => {
const canEnable = get().canEnable();
if (value && !canEnable) return;
set((draft) => {
draft.enabled = value;
});
},
})),
{
name: "ai-store",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
enabled: state.enabled,
provider: state.provider,
model: state.model,
apiKey: state.apiKey,
baseURL: state.baseURL,
testStatus: state.testStatus,
}),
},
),
);
+10 -1
View File
@@ -1,6 +1,14 @@
import { z } from "zod";
const AI_PROVIDERS = ["openai", "anthropic", "gemini", "vercel-ai-gateway", "openrouter", "ollama"] as const;
const AI_PROVIDERS = [
"openai",
"anthropic",
"gemini",
"vercel-ai-gateway",
"openrouter",
"ollama",
"openai-compatible",
] as const;
export type AIProvider = (typeof AI_PROVIDERS)[number];
@@ -13,4 +21,5 @@ export const AI_PROVIDER_DEFAULT_BASE_URLS: Record<AIProvider, string> = {
"vercel-ai-gateway": "https://ai-gateway.vercel.sh/v3/ai",
openrouter: "https://openrouter.ai/api/v1",
ollama: "https://ollama.com/api",
"openai-compatible": "",
};
+7 -1
View File
@@ -23,6 +23,7 @@
"@ai-sdk/openai": "^3.0.63",
"@ai-sdk/openai-compatible": "^2.0.47",
"@aws-sdk/client-s3": "^3.1045.0",
"@mozilla/readability": "^0.6.0",
"@orpc/client": "^1.14.3",
"@orpc/experimental-ratelimit": "^1.14.3",
"@orpc/server": "^1.14.3",
@@ -33,21 +34,26 @@
"@reactive-resume/schema": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"@tanstack/react-start": "^1.167.65",
"ai": "^6.0.180",
"ai": "^6.0.182",
"bcrypt": "^6.0.0",
"better-auth": "1.6.11",
"drizzle-orm": "1.0.0-rc.2",
"drizzle-zod": "1.0.0-beta.14-a36c63d",
"es-toolkit": "^1.46.1",
"ioredis": "^5.10.1",
"jsdom": "^29.1.1",
"ollama-ai-provider-v2": "^3.5.1",
"react": "^19.2.6",
"resumable-stream": "^2.2.12",
"sharp": "^0.34.5",
"ts-pattern": "^5.9.0",
"undici": "^8.2.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/bcrypt": "^6.0.0",
"@types/jsdom": "^28.0.3",
"@typescript/native-preview": "7.0.0-dev.20260513.1",
"typescript": "^6.0.3"
}
@@ -44,9 +44,15 @@ function getInputKeyPart(input: unknown): string {
if (!input || typeof input !== "object") return "no-input";
const inputRecord = input as Record<string, unknown>;
const id = inputRecord.id;
if (typeof id === "string" && id.trim()) return id;
const fields = ["resumeId", "threadId", "conversationId", "messageId", "fileId", "id"] as const;
for (const field of fields) {
const value = inputRecord[field];
if (typeof value !== "string") continue;
const trimmedValue = value.trim();
if (trimmedValue) return `${field}:${trimmedValue}`;
}
const username = inputRecord.username;
const slug = inputRecord.slug;
@@ -87,9 +93,9 @@ export const pdfExportRateLimit = createRatelimitMiddleware<ContextWithHeaders,
key: ({ context }, input) => `pdf-export:${getUserKey(context)}:${input.id}`,
});
export const aiRequestRateLimit = createRatelimitMiddleware<ContextWithHeaders, { provider: string }>({
export const aiRequestRateLimit = createRatelimitMiddleware<ContextWithHeaders, unknown>({
limiter: productionLimiter(aiLimiter),
key: ({ context }, input) => `ai-request:${getUserKey(context)}:${input.provider}`,
key: ({ context }, input) => `ai-request:${getUserKey(context)}:${getInputKeyPart(input)}`,
});
export const jobsSearchRateLimit = createRatelimitMiddleware<ContextWithHeaders, { params: { query: string } }>({
+287
View File
@@ -0,0 +1,287 @@
import type { UIMessage } from "ai";
import { ORPCError } from "@orpc/client";
import z from "zod";
import { protectedProcedure } from "../context";
import { aiRequestRateLimit, storageUploadRateLimit } from "../middleware/rate-limit";
import { agentService } from "../services/agent";
function isAgentEnvironmentUnavailable(error: unknown) {
return error instanceof Error && error.message === "AGENT_ENVIRONMENT_UNAVAILABLE";
}
function throwUnavailable(): never {
throw new ORPCError("PRECONDITION_FAILED", {
message: "AI agent workspace is unavailable because REDIS_URL or ENCRYPTION_SECRET is not configured.",
});
}
function base64ToUint8Array(value: string) {
return Uint8Array.from(Buffer.from(value, "base64"));
}
function isUiMessage(value: unknown): value is UIMessage {
if (!value || typeof value !== "object") return false;
const message = value as Partial<UIMessage>;
return (
typeof message.id === "string" &&
(message.role === "system" || message.role === "user" || message.role === "assistant") &&
Array.isArray(message.parts)
);
}
const threadsRouter = {
list: protectedProcedure
.route({
method: "GET",
path: "/agent/threads",
tags: ["Agent"],
operationId: "listAgentThreads",
summary: "List agent threads",
})
.handler(async ({ context }) => {
try {
return await agentService.threads.list({ userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
create: protectedProcedure
.route({
method: "POST",
path: "/agent/threads",
tags: ["Agent"],
operationId: "createAgentThread",
summary: "Create agent thread",
})
.input(z.object({ aiProviderId: z.string().optional(), sourceResumeId: z.string().optional() }))
.handler(async ({ context, input }) => {
try {
return await agentService.threads.create({
userId: context.user.id,
locale: context.locale,
...(input.aiProviderId ? { aiProviderId: input.aiProviderId } : {}),
...(input.sourceResumeId ? { sourceResumeId: input.sourceResumeId } : {}),
});
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
get: protectedProcedure
.route({
method: "GET",
path: "/agent/threads/{id}",
tags: ["Agent"],
operationId: "getAgentThread",
summary: "Get agent thread",
})
.input(z.object({ id: z.string() }))
.handler(async ({ context, input }) => {
try {
return await agentService.threads.get({ id: input.id, userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
archive: protectedProcedure
.route({
method: "POST",
path: "/agent/threads/{id}/archive",
tags: ["Agent"],
operationId: "archiveAgentThread",
summary: "Archive agent thread",
})
.input(z.object({ id: z.string() }))
.output(z.void())
.handler(async ({ context, input }) => {
try {
await agentService.threads.archive({ id: input.id, userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
delete: protectedProcedure
.route({
method: "DELETE",
path: "/agent/threads/{id}",
tags: ["Agent"],
operationId: "deleteAgentThread",
summary: "Delete agent thread",
})
.input(z.object({ id: z.string() }))
.output(z.void())
.handler(async ({ context, input }) => {
try {
await agentService.threads.delete({ id: input.id, userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
};
const messagesRouter = {
send: protectedProcedure
.route({
method: "POST",
path: "/agent/messages/send",
tags: ["Agent"],
operationId: "sendAgentMessage",
summary: "Send agent message",
})
.input(
z.object({
threadId: z.string(),
message: z.custom<UIMessage>(isUiMessage, { message: "Invalid UI message." }),
attachmentIds: z.array(z.string().trim().min(1)).max(10).optional(),
}),
)
.use(aiRequestRateLimit)
.handler(async ({ context, input }) => {
try {
return await agentService.messages.send({
userId: context.user.id,
threadId: input.threadId,
message: input.message,
...(input.attachmentIds ? { attachmentIds: input.attachmentIds } : {}),
});
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
stop: protectedProcedure
.route({
method: "POST",
path: "/agent/messages/stop",
tags: ["Agent"],
operationId: "stopAgentMessage",
summary: "Stop active agent run",
})
.input(
z.object({
threadId: z.string(),
partialMessage: z.custom<UIMessage>(isUiMessage, { message: "Invalid UI message." }).optional(),
}),
)
.output(z.void())
.handler(async ({ context, input }) => {
try {
await agentService.messages.stop({
userId: context.user.id,
threadId: input.threadId,
...(input.partialMessage ? { partialMessage: input.partialMessage } : {}),
});
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
resume: protectedProcedure
.route({
method: "GET",
path: "/agent/messages/resume",
tags: ["Agent"],
operationId: "resumeAgentMessages",
summary: "Resume agent message stream",
})
.input(z.object({ threadId: z.string() }))
.handler(async ({ context, input }) => {
try {
return await agentService.messages.resume({ userId: context.user.id, threadId: input.threadId });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
};
const attachmentsRouter = {
create: protectedProcedure
.route({
method: "POST",
path: "/agent/attachments",
tags: ["Agent"],
operationId: "createAgentAttachment",
summary: "Create agent attachment",
})
.input(
z.object({
threadId: z.string(),
filename: z.string().trim().min(1),
mediaType: z.string().trim().min(1),
data: z.string().min(1),
}),
)
.use(storageUploadRateLimit)
.handler(async ({ context, input }) => {
try {
return await agentService.attachments.create({
userId: context.user.id,
threadId: input.threadId,
filename: input.filename,
mediaType: input.mediaType,
data: base64ToUint8Array(input.data),
});
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
delete: protectedProcedure
.route({
method: "DELETE",
path: "/agent/attachments/{id}",
tags: ["Agent"],
operationId: "deleteAgentAttachment",
summary: "Delete agent attachment",
})
.input(z.object({ id: z.string() }))
.output(z.void())
.handler(async ({ context, input }) => {
try {
await agentService.attachments.delete({ id: input.id, userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
};
const actionsRouter = {
revert: protectedProcedure
.route({
method: "POST",
path: "/agent/actions/{id}/revert",
tags: ["Agent"],
operationId: "revertAgentAction",
summary: "Revert agent action",
})
.input(z.object({ id: z.string() }))
.handler(async ({ context, input }) => {
try {
return await agentService.actions.revert({ id: input.id, userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
};
export const agentRouter = {
threads: threadsRouter,
messages: messagesRouter,
attachments: attachmentsRouter,
actions: actionsRouter,
};
+185
View File
@@ -0,0 +1,185 @@
import type { AiProviderResponse } from "../services/ai-providers";
import { ORPCError } from "@orpc/client";
import { type } from "@orpc/server";
import z from "zod";
import { aiProviderSchema } from "@reactive-resume/ai/types";
import { protectedProcedure } from "../context";
import { aiRequestRateLimit } from "../middleware/rate-limit";
import { aiProvidersService } from "../services/ai-providers";
const providerInput = z.object({
label: z.string().trim().min(1),
provider: aiProviderSchema,
model: z.string().trim().min(1),
baseURL: z.string().trim().optional().default(""),
apiKey: z.string().trim().min(1),
});
const updateProviderInput = providerInput
.partial()
.extend({ id: z.string(), enabled: z.boolean().optional() })
.refine((input) => Object.keys(input).some((key) => key !== "id"), {
message: "At least one field must be provided.",
});
function isAgentEnvironmentUnavailable(error: unknown) {
return error instanceof Error && error.message === "AGENT_ENVIRONMENT_UNAVAILABLE";
}
function throwUnavailable(): never {
throw new ORPCError("PRECONDITION_FAILED", {
message: "AI agent workspace is unavailable because REDIS_URL or ENCRYPTION_SECRET is not configured.",
});
}
function isInvalidAiBaseUrl(error: unknown) {
return error instanceof Error && error.message === "INVALID_AI_BASE_URL";
}
function throwInvalidProviderConfig(): never {
throw new ORPCError("BAD_REQUEST", { message: "Invalid AI provider configuration." });
}
export const aiProvidersRouter = {
list: protectedProcedure
.route({
method: "GET",
path: "/ai-providers",
tags: ["AI Providers"],
operationId: "listAiProviders",
summary: "List saved AI providers",
description: "Lists saved provider/model/API key combinations for the authenticated user. API keys are redacted.",
})
.output(type<AiProviderResponse[]>())
.errors({
PRECONDITION_FAILED: { message: "AI agent workspace is not configured.", status: 412 },
})
.handler(async ({ context }) => {
try {
return await aiProvidersService.list({ userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
create: protectedProcedure
.route({
method: "POST",
path: "/ai-providers",
tags: ["AI Providers"],
operationId: "createAiProvider",
summary: "Create saved AI provider",
description: "Stores an encrypted provider/model/API key combination. The key is never returned.",
})
.input(providerInput)
.output(type<AiProviderResponse>())
.errors({
BAD_REQUEST: { message: "Invalid AI provider configuration.", status: 400 },
PRECONDITION_FAILED: { message: "AI agent workspace is not configured.", status: 412 },
})
.handler(async ({ context, input }) => {
try {
return await aiProvidersService.create({
userId: context.user.id,
label: input.label,
provider: input.provider,
model: input.model,
baseURL: input.baseURL,
apiKey: input.apiKey,
});
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
if (isInvalidAiBaseUrl(error)) throwInvalidProviderConfig();
throw error;
}
}),
update: protectedProcedure
.route({
method: "PATCH",
path: "/ai-providers/{id}",
tags: ["AI Providers"],
operationId: "updateAiProvider",
summary: "Update saved AI provider",
description:
"Updates a saved provider/model/API key combination. Updating the key requires retesting before use.",
})
.input(updateProviderInput)
.output(type<AiProviderResponse>())
.errors({
BAD_REQUEST: { message: "Invalid AI provider configuration.", status: 400 },
NOT_FOUND: { message: "AI provider was not found.", status: 404 },
PRECONDITION_FAILED: { message: "AI agent workspace is not configured.", status: 412 },
})
.handler(async ({ context, input }) => {
try {
return await aiProvidersService.update({
id: input.id,
userId: context.user.id,
...(input.label !== undefined ? { label: input.label } : {}),
...(input.provider !== undefined ? { provider: input.provider } : {}),
...(input.model !== undefined ? { model: input.model } : {}),
...(input.baseURL !== undefined ? { baseURL: input.baseURL } : {}),
...(input.apiKey !== undefined ? { apiKey: input.apiKey } : {}),
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
});
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
if (isInvalidAiBaseUrl(error)) throwInvalidProviderConfig();
throw error;
}
}),
delete: protectedProcedure
.route({
method: "DELETE",
path: "/ai-providers/{id}",
tags: ["AI Providers"],
operationId: "deleteAiProvider",
summary: "Delete saved AI provider",
description: "Deletes a saved provider/model/API key combination.",
})
.input(z.object({ id: z.string() }))
.output(z.void())
.errors({
PRECONDITION_FAILED: { message: "AI agent workspace is not configured.", status: 412 },
})
.handler(async ({ context, input }) => {
try {
await aiProvidersService.delete({ id: input.id, userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
test: protectedProcedure
.route({
method: "POST",
path: "/ai-providers/{id}/test",
tags: ["AI Providers"],
operationId: "testAiProvider",
summary: "Test saved AI provider",
description: "Decrypts the saved API key server-side and validates the provider/model connection.",
})
.input(z.object({ id: z.string() }))
.output(type<AiProviderResponse>())
.use(aiRequestRateLimit)
.errors({
BAD_REQUEST: { message: "Invalid AI provider configuration.", status: 400 },
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
NOT_FOUND: { message: "AI provider was not found.", status: 404 },
PRECONDITION_FAILED: { message: "AI agent workspace is not configured.", status: 412 },
})
.handler(async ({ context, input }) => {
try {
return await aiProvidersService.test({ id: input.id, userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
if (isInvalidAiBaseUrl(error)) throwInvalidProviderConfig();
if (error instanceof ORPCError) throw error;
throw new ORPCError("BAD_GATEWAY", { message: "Could not reach the AI provider." });
}
}),
};
+75 -54
View File
@@ -5,14 +5,12 @@ import { type } from "@orpc/server";
import { AISDKError } from "ai";
import { flattenError, ZodError, z } from "zod";
import { storedResumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
import { protectedProcedure } from "../context";
import { aiRequestRateLimit } from "../middleware/rate-limit";
import { aiCredentialsSchema, aiService, fileInputSchema } from "../services/ai";
import { aiService, fileInputSchema } from "../services/ai";
import { aiProvidersService } from "../services/ai-providers";
import { resumeService } from "../services/resume";
type AIProvider = z.infer<typeof aiCredentialsSchema.shape.provider>;
function isInvalidAiBaseUrlError(error: unknown): boolean {
return error instanceof Error && error.message === "INVALID_AI_BASE_URL";
}
@@ -21,6 +19,10 @@ function isAiProviderGatewayError(error: unknown): boolean {
return error instanceof AISDKError;
}
function isCredentialEncryptionUnavailable(error: unknown): boolean {
return error instanceof Error && error.message === "AI_CREDENTIAL_ENCRYPTION_UNAVAILABLE";
}
function throwAiProviderGatewayError(): never {
throw new ORPCError("BAD_GATEWAY", { message: "Could not reach the AI provider." });
}
@@ -29,6 +31,12 @@ function throwAiProviderConfigError(): never {
throw new ORPCError("BAD_REQUEST", { message: "Invalid AI provider configuration." });
}
function throwCredentialEncryptionUnavailable(): never {
throw new ORPCError("PRECONDITION_FAILED", {
message: "AI providers are unavailable because ENCRYPTION_SECRET is not configured.",
});
}
function throwResumeStructureError(error: ZodError): never {
throw new ORPCError("BAD_REQUEST", {
message: "Invalid resume data structure",
@@ -36,35 +44,17 @@ function throwResumeStructureError(error: ZodError): never {
});
}
export const aiRouter = {
testConnection: protectedProcedure
.route({
method: "POST",
path: "/ai/test-connection",
tags: ["AI"],
operationId: "testAiConnection",
summary: "Test AI provider connection",
description:
"Validates the connection to an AI provider by sending a simple test prompt. Requires the provider type, model name, API key, and an optional base URL. Supported providers: OpenAI, Anthropic, Google Gemini, Ollama, OpenRouter, and Vercel AI Gateway. Requires authentication.",
successDescription: "The AI provider connection was successful.",
})
.input(z.object({ ...aiCredentialsSchema.shape }))
.use(aiRequestRateLimit)
.errors({
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
BAD_REQUEST: { message: "Invalid AI provider configuration.", status: 400 },
})
.handler(async ({ input }) => {
try {
return await aiService.testConnection(input);
} catch (error) {
console.error(error);
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
throw error;
}
}),
async function getRunnableProvider(userId: string, aiProviderId?: string) {
const provider = aiProviderId
? await aiProvidersService.getRunnableById({ id: aiProviderId, userId })
: await aiProvidersService.getDefaultRunnable({ userId });
if (!provider) throw new ORPCError("BAD_REQUEST", { message: "No tested AI provider is available." });
return provider;
}
export const aiRouter = {
parsePdf: protectedProcedure
.route({
method: "POST",
@@ -76,16 +66,24 @@ export const aiRouter = {
"Extracts structured resume data from a PDF file using the specified AI provider. The file should be sent as a base64-encoded string along with AI provider credentials. Returns a complete ResumeData object. Requires authentication.",
successDescription: "The PDF was successfully parsed into structured resume data.",
})
.input(z.object({ ...aiCredentialsSchema.shape, file: fileInputSchema }))
.input(z.object({ aiProviderId: z.string().optional(), file: fileInputSchema }))
.use(aiRequestRateLimit)
.errors({
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 },
})
.handler(async ({ input }): Promise<ResumeData> => {
.handler(async ({ context, input }): Promise<ResumeData> => {
try {
return await aiService.parsePdf(input);
const provider = await getRunnableProvider(context.user.id, input.aiProviderId);
return await aiService.parsePdf({
provider: provider.provider,
model: provider.model,
apiKey: provider.apiKey,
baseURL: provider.baseURL ?? "",
file: input.file,
});
} catch (error) {
if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable();
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
if (error instanceof ZodError) throwResumeStructureError(error);
@@ -106,7 +104,7 @@ export const aiRouter = {
})
.input(
z.object({
...aiCredentialsSchema.shape,
aiProviderId: z.string().optional(),
file: fileInputSchema,
mediaType: z.enum([
"application/msword",
@@ -119,10 +117,19 @@ export const aiRouter = {
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 },
})
.handler(async ({ input }) => {
.handler(async ({ context, input }) => {
try {
return await aiService.parseDocx(input);
const provider = await getRunnableProvider(context.user.id, input.aiProviderId);
return await aiService.parseDocx({
provider: provider.provider,
model: provider.model,
apiKey: provider.apiKey,
baseURL: provider.baseURL ?? "",
mediaType: input.mediaType,
file: input.file,
});
} catch (error) {
if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable();
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
if (error instanceof ZodError) throwResumeStructureError(error);
@@ -142,20 +149,30 @@ export const aiRouter = {
})
.input(
type<{
provider: AIProvider;
model: string;
apiKey: string;
baseURL: string;
aiProviderId?: string;
messages: UIMessage[];
resumeData: ResumeData;
resumeUpdatedAt: Date;
resumeId: string;
}>(),
)
.use(aiRequestRateLimit)
.handler(async ({ input }) => {
.handler(async ({ context, input }) => {
try {
return await aiService.chat(input);
const [provider, resume] = await Promise.all([
getRunnableProvider(context.user.id, input.aiProviderId),
resumeService.getById({ id: input.resumeId, userId: context.user.id }),
]);
return await aiService.chat({
provider: provider.provider,
model: provider.model,
apiKey: provider.apiKey,
baseURL: provider.baseURL ?? "",
messages: input.messages,
resumeData: resume.data,
resumeUpdatedAt: resume.updatedAt,
});
} catch (error) {
if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable();
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
throw error;
@@ -175,9 +192,8 @@ export const aiRouter = {
})
.input(
z.object({
...aiCredentialsSchema.shape,
aiProviderId: z.string().optional(),
resumeId: z.string(),
resumeData: resumeDataSchema,
}),
)
.use(aiRequestRateLimit)
@@ -188,12 +204,16 @@ export const aiRouter = {
})
.handler(async ({ context, input }) => {
try {
const [provider, resume] = await Promise.all([
getRunnableProvider(context.user.id, input.aiProviderId),
resumeService.getById({ id: input.resumeId, userId: context.user.id }),
]);
const analysis = await aiService.analyzeResume({
provider: input.provider,
model: input.model,
apiKey: input.apiKey,
baseURL: input.baseURL,
resumeData: input.resumeData,
provider: provider.provider,
model: provider.model,
apiKey: provider.apiKey,
baseURL: provider.baseURL ?? "",
resumeData: resume.data,
});
return await resumeService.analysis.upsert({
@@ -202,10 +222,11 @@ export const aiRouter = {
analysis: {
...analysis,
updatedAt: new Date(),
modelMeta: { provider: input.provider, model: input.model },
modelMeta: { provider: provider.provider, model: provider.model },
},
});
} catch (error) {
if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable();
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
if (error instanceof ZodError) {
+4
View File
@@ -1,4 +1,6 @@
import { agentRouter } from "./agent";
import { aiRouter } from "./ai";
import { aiProvidersRouter } from "./ai-providers";
import { authRouter } from "./auth";
import { flagsRouter } from "./flags";
import { resumeRouter } from "./resume";
@@ -7,6 +9,8 @@ import { storageRouter } from "./storage";
export default {
ai: aiRouter,
aiProviders: aiProvidersRouter,
agent: agentRouter,
auth: authRouter,
flags: flagsRouter,
resume: resumeRouter,
@@ -0,0 +1,155 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { JsonPatchOperation } from "@reactive-resume/utils/resume/patch";
import { describe, expect, it } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { createInverseResumePatches } from "./agent-patches";
function buildFixture(): ResumeData {
const clone = JSON.parse(JSON.stringify(defaultResumeData)) as ResumeData;
clone.basics.name = "Alice";
clone.basics.email = "alice@example.com";
clone.basics.customFields = [
{ id: "field-1", icon: "phosphor", text: "first", link: "" },
{ id: "field-2", icon: "phosphor", text: "second", link: "" },
];
return clone;
}
describe("createInverseResumePatches", () => {
it("inverts a single replace into a replace back to the original value", () => {
const data = buildFixture();
const operations: JsonPatchOperation[] = [{ op: "replace", path: "/basics/name", value: "Bob" }];
const inverse = createInverseResumePatches(data, operations);
expect(inverse).toEqual([{ op: "replace", path: "/basics/name", value: "Alice" }]);
});
it("inverts a single remove into an add carrying the original value", () => {
const data = buildFixture();
const operations: JsonPatchOperation[] = [{ op: "remove", path: "/basics/customFields/0" }];
const inverse = createInverseResumePatches(data, operations);
expect(inverse).toEqual([
{
op: "add",
path: "/basics/customFields/0",
value: { id: "field-1", icon: "phosphor", text: "first", link: "" },
},
]);
});
it("inverts a single add into a remove at the same path", () => {
const data = buildFixture();
const operations: JsonPatchOperation[] = [
{
op: "add",
path: "/basics/customFields/2",
value: { id: "field-3", icon: "phosphor", text: "third", link: "" },
},
];
const inverse = createInverseResumePatches(data, operations);
expect(inverse).toEqual([{ op: "remove", path: "/basics/customFields/2" }]);
});
it("inverts an array insert at an existing index into a remove at the same path", () => {
const data = buildFixture();
const operations: JsonPatchOperation[] = [
{
op: "add",
path: "/basics/customFields/1",
value: { id: "field-inserted", icon: "phosphor", text: "inserted", link: "" },
},
];
const inverse = createInverseResumePatches(data, operations);
expect(inverse).toEqual([{ op: "remove", path: "/basics/customFields/1" }]);
});
it("composes inverses in reverse order with each original value", () => {
const data = buildFixture();
const operations: JsonPatchOperation[] = [
{ op: "replace", path: "/basics/name", value: "Bob" },
{ op: "replace", path: "/basics/email", value: "bob@example.com" },
];
const inverse = createInverseResumePatches(data, operations);
expect(inverse).toEqual([
{ op: "replace", path: "/basics/email", value: "alice@example.com" },
{ op: "replace", path: "/basics/name", value: "Alice" },
]);
});
it("reads downstream pointers against the working copy after upstream removals", () => {
// Removing /basics/customFields/1 does not affect /basics/name.
// The inverse builder reads /basics/name from the working copy after the
// removal has been applied; that value must still be the original "Alice".
const data = buildFixture();
const operations: JsonPatchOperation[] = [
{ op: "remove", path: "/basics/customFields/1" },
{ op: "replace", path: "/basics/name", value: "Bob" },
];
const inverse = createInverseResumePatches(data, operations);
expect(inverse).toEqual([
{ op: "replace", path: "/basics/name", value: "Alice" },
{
op: "add",
path: "/basics/customFields/1",
value: { id: "field-2", icon: "phosphor", text: "second", link: "" },
},
]);
});
it("throws INVERTIBLE_PATCH_REQUIRED when a path ends with /- (array append)", () => {
const data = buildFixture();
const operations: JsonPatchOperation[] = [
{ op: "add", path: "/basics/customFields/-", value: { id: "x", icon: "phosphor", text: "x", link: "" } },
];
expect(() => createInverseResumePatches(data, operations)).toThrow("INVERTIBLE_PATCH_REQUIRED");
});
it("throws INVERTIBLE_PATCH_REQUIRED for move operations", () => {
const data = buildFixture();
const operations: JsonPatchOperation[] = [{ op: "move", path: "/basics/email", from: "/basics/name" }];
expect(() => createInverseResumePatches(data, operations)).toThrow("INVERTIBLE_PATCH_REQUIRED");
});
it("throws INVERTIBLE_PATCH_REQUIRED for copy operations", () => {
const data = buildFixture();
const operations: JsonPatchOperation[] = [{ op: "copy", path: "/basics/email", from: "/basics/name" }];
expect(() => createInverseResumePatches(data, operations)).toThrow("INVERTIBLE_PATCH_REQUIRED");
});
it("throws INVERTIBLE_PATCH_REQUIRED for test operations", () => {
const data = buildFixture();
const operations: JsonPatchOperation[] = [{ op: "test", path: "/basics/name", value: "Alice" }];
expect(() => createInverseResumePatches(data, operations)).toThrow("INVERTIBLE_PATCH_REQUIRED");
});
it("throws INVALID_PATCH_OPERATIONS when reading a non-existent path", () => {
const data = buildFixture();
const operations: JsonPatchOperation[] = [{ op: "replace", path: "/does/not/exist", value: "x" }];
expect(() => createInverseResumePatches(data, operations)).toThrow("INVALID_PATCH_OPERATIONS");
});
it("inverts an add at an existing object member into a replace with the prior value", () => {
const data = buildFixture();
const operations: JsonPatchOperation[] = [{ op: "add", path: "/basics/name", value: "Bob" }];
const inverse = createInverseResumePatches(data, operations);
expect(inverse).toEqual([{ op: "replace", path: "/basics/name", value: "Alice" }]);
});
});
@@ -0,0 +1,83 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { JsonPatchOperation } from "@reactive-resume/utils/resume/patch";
import { applyResumePatches } from "@reactive-resume/utils/resume/patch";
function decodePointerSegment(segment: string) {
return segment.replaceAll("~1", "/").replaceAll("~0", "~");
}
function readPointer(document: unknown, pointer: string): unknown {
if (pointer === "") return document;
if (!pointer.startsWith("/")) throw new Error("INVALID_PATCH_OPERATIONS");
return pointer
.slice(1)
.split("/")
.map(decodePointerSegment)
.reduce<unknown>((current, segment) => {
if (current == null || typeof current !== "object") throw new Error("INVALID_PATCH_OPERATIONS");
return (current as Record<string, unknown>)[segment];
}, document);
}
function pointerExists(document: unknown, pointer: string): boolean {
if (pointer === "") return true;
if (!pointer.startsWith("/")) return false;
const segments = pointer.slice(1).split("/").map(decodePointerSegment);
let current: unknown = document;
for (const segment of segments) {
if (current == null || typeof current !== "object") return false;
const record = current as Record<string, unknown>;
if (!Object.hasOwn(record, segment)) return false;
current = record[segment];
}
return true;
}
function getParentPointer(pointer: string) {
const lastSlashIndex = pointer.lastIndexOf("/");
return lastSlashIndex <= 0 ? "" : pointer.slice(0, lastSlashIndex);
}
function cloneJson<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
export function createInverseResumePatches(data: ResumeData, operations: JsonPatchOperation[]): JsonPatchOperation[] {
const working = cloneJson(data);
const inverse: JsonPatchOperation[] = [];
for (const operation of operations) {
if (operation.path.endsWith("/-")) throw new Error("INVERTIBLE_PATCH_REQUIRED");
if (operation.op === "replace") {
inverse.unshift({ op: "replace", path: operation.path, value: cloneJson(readPointer(working, operation.path)) });
} else if (operation.op === "remove") {
inverse.unshift({ op: "add", path: operation.path, value: cloneJson(readPointer(working, operation.path)) });
} else if (operation.op === "add") {
const parent = readPointer(working, getParentPointer(operation.path));
// JSON Patch "add" inserts into arrays, but overwrites existing object members.
// Array inserts must be reverted with remove; object overwrites need replace.
if (Array.isArray(parent)) {
inverse.unshift({ op: "remove", path: operation.path });
} else if (pointerExists(working, operation.path)) {
inverse.unshift({
op: "replace",
path: operation.path,
value: cloneJson(readPointer(working, operation.path)),
});
} else {
inverse.unshift({ op: "remove", path: operation.path });
}
} else {
throw new Error("INVERTIBLE_PATCH_REQUIRED");
}
applyResumePatches(working, [operation]);
}
return inverse;
}
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { buildAgentDraftResumeName, buildUniqueAgentDraftSlug } from "./agent-resume";
describe("agent resume setup helpers", () => {
it("names duplicated resumes as AI drafts", () => {
expect(buildAgentDraftResumeName("Senior Product Designer")).toBe("Senior Product Designer - AI Draft");
expect(buildAgentDraftResumeName("Senior Product Designer - AI Draft")).toBe("Senior Product Designer - AI Draft");
});
it("generates unique AI draft slugs", () => {
expect(buildUniqueAgentDraftSlug("Senior Product Designer", new Set())).toBe("senior-product-designer-ai-draft");
expect(buildUniqueAgentDraftSlug("Senior Product Designer", new Set(["senior-product-designer-ai-draft"]))).toBe(
"senior-product-designer-ai-draft-2",
);
});
});
+25
View File
@@ -0,0 +1,25 @@
import { slugify } from "@reactive-resume/utils/string";
const AI_DRAFT_SUFFIX = " - AI Draft";
export function buildAgentDraftResumeName(sourceName: string) {
const normalized = sourceName.trim() || "Resume";
if (normalized.endsWith(AI_DRAFT_SUFFIX)) return normalized;
return `${normalized}${AI_DRAFT_SUFFIX}`;
}
export function buildUniqueAgentDraftSlug(sourceName: string, existingSlugs: Set<string>) {
const base = slugify(buildAgentDraftResumeName(sourceName));
if (!existingSlugs.has(base)) return base;
let index = 2;
let candidate = `${base}-${index}`;
while (existingSlugs.has(candidate)) {
index += 1;
candidate = `${base}-${index}`;
}
return candidate;
}
@@ -0,0 +1,43 @@
import { and, eq, isNull } from "drizzle-orm";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
type AgentRunStateDb = Pick<typeof db, "update">;
export async function claimActiveAgentRun(
input: { threadId: string; userId: string; runId: string; streamId: string },
database: AgentRunStateDb = db,
) {
const claimed = await database
.update(schema.agentThread)
.set({ activeRunId: input.runId, activeStreamId: input.streamId, activeRunStartedAt: new Date() })
.where(
and(
eq(schema.agentThread.id, input.threadId),
eq(schema.agentThread.userId, input.userId),
isNull(schema.agentThread.activeRunId),
),
)
.returning({ id: schema.agentThread.id });
return claimed.length === 1;
}
export async function clearActiveAgentRunIfCurrent(
input: { threadId: string; userId: string; runId: string; streamId: string | null },
database: AgentRunStateDb = db,
) {
await database
.update(schema.agentThread)
.set({ activeRunId: null, activeStreamId: null, activeRunStartedAt: null })
.where(
and(
eq(schema.agentThread.id, input.threadId),
eq(schema.agentThread.userId, input.userId),
eq(schema.agentThread.activeRunId, input.runId),
input.streamId === null
? isNull(schema.agentThread.activeStreamId)
: eq(schema.agentThread.activeStreamId, input.streamId),
),
);
}
@@ -0,0 +1,179 @@
import { describe, expect, it, vi } from "vitest";
import { claimActiveAgentRun, clearActiveAgentRunIfCurrent } from "./agent-run-state";
import { createAgentStreamLifecycle, emptyAgentStream } from "./agent-streams";
vi.mock("@reactive-resume/db/client", () => ({ db: { update: vi.fn() } }));
vi.mock("@reactive-resume/db/schema", () => ({
agentThread: {
id: "agent_threads.id",
userId: "agent_threads.user_id",
activeRunId: "agent_threads.active_run_id",
activeStreamId: "agent_threads.active_stream_id",
},
}));
vi.mock("drizzle-orm", () => ({
and: (...conditions: unknown[]) => ({ type: "and", conditions }),
eq: (left: unknown, right: unknown) => ({ type: "eq", left, right }),
isNull: (value: unknown) => ({ type: "isNull", value }),
}));
async function readStream(stream: ReadableStream<string>) {
const reader = stream.getReader();
const chunks: string[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
return chunks;
}
function createRunStateDb(returningRows: unknown[] = []) {
const returning = vi.fn(async () => returningRows);
const where = vi.fn(() => ({ returning }));
const set = vi.fn(() => ({ where }));
const update = vi.fn(() => ({ set }));
return {
database: { update },
returning,
set,
update,
where,
};
}
describe("agent run state", () => {
it("claims an active run only when the thread still has no active run", async () => {
const db = createRunStateDb([{ id: "thread-1" }]);
await expect(
claimActiveAgentRun(
{ threadId: "thread-1", userId: "user-1", runId: "run-1", streamId: "stream-1" },
db.database as never,
),
).resolves.toBe(true);
expect(db.update).toHaveBeenCalledWith(expect.objectContaining({ id: "agent_threads.id" }));
expect(db.set).toHaveBeenCalledWith({
activeRunId: "run-1",
activeStreamId: "stream-1",
activeRunStartedAt: expect.any(Date),
});
expect(db.where).toHaveBeenCalledWith({
type: "and",
conditions: [
{ type: "eq", left: "agent_threads.id", right: "thread-1" },
{ type: "eq", left: "agent_threads.user_id", right: "user-1" },
{ type: "isNull", value: "agent_threads.active_run_id" },
],
});
});
it("reports a failed claim when the guarded update claims no rows", async () => {
const db = createRunStateDb([]);
await expect(
claimActiveAgentRun(
{ threadId: "thread-1", userId: "user-1", runId: "run-1", streamId: "stream-1" },
db.database as never,
),
).resolves.toBe(false);
});
it("clears active run state only for the matching run and stream", async () => {
const db = createRunStateDb();
await clearActiveAgentRunIfCurrent(
{ threadId: "thread-1", userId: "user-1", runId: "run-1", streamId: "stream-1" },
db.database as never,
);
expect(db.set).toHaveBeenCalledWith({ activeRunId: null, activeStreamId: null, activeRunStartedAt: null });
expect(db.where).toHaveBeenCalledWith({
type: "and",
conditions: [
{ type: "eq", left: "agent_threads.id", right: "thread-1" },
{ type: "eq", left: "agent_threads.user_id", right: "user-1" },
{ type: "eq", left: "agent_threads.active_run_id", right: "run-1" },
{ type: "eq", left: "agent_threads.active_stream_id", right: "stream-1" },
],
});
});
it("clears active run state with a null stream guard when no stream id was recorded", async () => {
const db = createRunStateDb();
await clearActiveAgentRunIfCurrent(
{ threadId: "thread-1", userId: "user-1", runId: "run-1", streamId: null },
db.database as never,
);
expect(db.where).toHaveBeenCalledWith({
type: "and",
conditions: [
{ type: "eq", left: "agent_threads.id", right: "thread-1" },
{ type: "eq", left: "agent_threads.user_id", right: "user-1" },
{ type: "eq", left: "agent_threads.active_run_id", right: "run-1" },
{ type: "isNull", value: "agent_threads.active_stream_id" },
],
});
});
});
describe("agent stream lifecycle", () => {
it("returns a closed stream when no active stream id exists", async () => {
const lifecycle = createAgentStreamLifecycle({
getContext: () => {
throw new Error("context should not be used");
},
});
await expect(readStream(await lifecycle.resume(null))).resolves.toEqual([]);
});
it("creates a resumable stream from UI message SSE chunks", async () => {
const createNewResumableStream = vi.fn(async (_streamId: string, makeStream: () => ReadableStream<string>) =>
makeStream(),
);
const lifecycle = createAgentStreamLifecycle({
getContext: () => ({
createNewResumableStream,
resumeExistingStream: vi.fn(),
}),
});
const stream = await lifecycle.create(
"stream-1",
() =>
new ReadableStream({
start(controller) {
controller.enqueue({ type: "text-start", id: "text-1" });
controller.enqueue({ type: "text-delta", id: "text-1", delta: "Hello" });
controller.close();
},
}),
);
await expect(readStream(stream)).resolves.toEqual([
'data: {"type":"text-start","id":"text-1"}\n\n',
'data: {"type":"text-delta","id":"text-1","delta":"Hello"}\n\n',
"data: [DONE]\n\n",
]);
expect(createNewResumableStream).toHaveBeenCalledWith("stream-1", expect.any(Function));
});
it("returns a closed stream when the active stream is missing or already done", async () => {
const lifecycle = createAgentStreamLifecycle({
getContext: () => ({
createNewResumableStream: vi.fn(),
resumeExistingStream: vi.fn(async () => null),
}),
});
await expect(readStream(await lifecycle.resume("stream-1"))).resolves.toEqual([]);
await expect(readStream(emptyAgentStream())).resolves.toEqual([]);
});
});
@@ -0,0 +1,50 @@
import type { UIMessageChunk } from "ai";
import type { ResumableStreamContext } from "resumable-stream/ioredis";
import { JsonToSseTransformStream } from "ai";
import { createResumableStreamContext } from "resumable-stream/ioredis";
type AgentStreamContext = Pick<ResumableStreamContext, "createNewResumableStream" | "resumeExistingStream">;
type AgentStreamLifecycleOptions = {
getContext: () => AgentStreamContext;
};
let streamContext: AgentStreamContext | null = null;
export function emptyAgentStream() {
return new ReadableStream<string>({
start(controller) {
controller.close();
},
});
}
function getAgentStreamContext() {
streamContext ??= createResumableStreamContext({
keyPrefix: "reactive-resume:agent-stream",
waitUntil: null,
});
return streamContext;
}
export function createAgentStreamLifecycle(options: AgentStreamLifecycleOptions) {
return {
async create(streamId: string, makeStream: () => ReadableStream<UIMessageChunk>) {
const stream = await options
.getContext()
.createNewResumableStream(streamId, () => makeStream().pipeThrough(new JsonToSseTransformStream()));
return stream ?? emptyAgentStream();
},
async resume(streamId: string | null | undefined) {
if (!streamId) return emptyAgentStream();
const stream = await options.getContext().resumeExistingStream(streamId);
return stream ?? emptyAgentStream();
},
};
}
export const agentStreamLifecycle = createAgentStreamLifecycle({ getContext: getAgentStreamContext });
@@ -0,0 +1,100 @@
import type { AIProvider } from "@reactive-resume/ai/types";
import { describe, expect, it } from "vitest";
import { buildAgentInstructions, buildAgentTools } from "./agent-tools";
const handlers = {
fetchUrl: async (url: string) => ({ url, title: null, content: "Fetched content" }),
readResume: async () => ({
id: "resume-1",
name: "Resume",
updatedAt: "2026-05-13T00:00:00.000Z",
data: {},
}),
readAttachment: async () => ({
id: "attachment-1",
filename: "job.md",
mediaType: "text/markdown",
size: 128,
content: "Job description",
}),
applyResumePatch: async () => ({
actionId: "action-1",
resumeId: "resume-1",
title: "Update resume",
summary: null,
operations: [],
appliedUpdatedAt: "2026-05-13T00:00:00.000Z",
}),
};
function buildTools(provider: AIProvider, options?: { model?: string; baseURL?: string }) {
return buildAgentTools({
provider: { provider, model: options?.model ?? "gpt-5-mini", apiKey: "test-key", baseURL: options?.baseURL ?? "" },
handlers,
});
}
describe("agent tools", () => {
it("adds provider-native web search for direct OpenAI providers", () => {
const tools = buildTools("openai");
expect(tools).toHaveProperty("web_search");
expect(tools).toHaveProperty("fetch_url");
});
it("adds provider-native web search for OpenAI providers using the explicit default base URL", () => {
const tools = buildTools("openai", { baseURL: "https://api.openai.com/v1" });
expect(tools).toHaveProperty("web_search");
expect(tools).toHaveProperty("fetch_url");
});
it("does not add provider-native web search for OpenAI providers with a custom base URL", () => {
const tools = buildTools("openai", { baseURL: "https://openai-compatible.example.com/v1" });
expect(tools).not.toHaveProperty("web_search");
expect(tools).toHaveProperty("fetch_url");
});
it.each([
"https://api.openai.com/v1?proxy=1",
"https://api.openai.com/v1#fragment",
])("does not add provider-native web search for OpenAI providers with non-exact base URL %s", (baseURL) => {
const tools = buildTools("openai", { baseURL });
expect(tools).not.toHaveProperty("web_search");
expect(tools).toHaveProperty("fetch_url");
});
it("does not add provider-native web search for unsupported OpenAI models", () => {
const tools = buildTools("openai", { model: "custom-model" });
expect(tools).not.toHaveProperty("web_search");
expect(tools).toHaveProperty("fetch_url");
});
it.each<AIProvider>([
"anthropic",
"gemini",
"vercel-ai-gateway",
"openrouter",
"ollama",
"openai-compatible",
])("does not add provider-native web search for %s", (provider) => {
const tools = buildTools(provider);
expect(tools).not.toHaveProperty("web_search");
expect(tools).toHaveProperty("fetch_url");
});
it("keeps instructions explicit about native search versus exact URL fetching", () => {
expect(buildAgentInstructions({ hasProviderNativeSearch: true })).toContain("Use web_search");
expect(buildAgentInstructions({ hasProviderNativeSearch: true })).toContain("Use fetch_url");
expect(buildAgentInstructions({ hasProviderNativeSearch: false })).not.toContain("Use web_search");
expect(buildAgentInstructions({ hasProviderNativeSearch: false })).toContain("Use fetch_url");
expect(buildAgentInstructions({ hasProviderNativeSearch: false })).toContain("Batch related JSON Patch operations");
expect(buildAgentInstructions({ hasProviderNativeSearch: false })).toContain("/basics/name");
expect(buildAgentInstructions({ hasProviderNativeSearch: false })).toContain("never /data/basics/name or /name");
expect(buildAgentInstructions({ hasProviderNativeSearch: false })).toContain("clean Markdown");
});
});
+101
View File
@@ -0,0 +1,101 @@
import type { AIProvider } from "@reactive-resume/ai/types";
import type { ToolSet } from "ai";
import { createOpenAI } from "@ai-sdk/openai";
import { tool } from "ai";
import z from "zod";
import { jsonPatchOperationSchema } from "@reactive-resume/utils/resume/patch";
import { supportsProviderNativeWebSearch } from "./ai-capabilities";
type AgentProviderConfig = {
provider: AIProvider;
model: string;
apiKey: string;
baseURL?: string | null;
};
export const applyResumePatchToolInputSchema = z.object({
title: z.string().trim().min(1),
summary: z.string().trim().optional(),
operations: z.array(jsonPatchOperationSchema).min(1),
});
type ApplyResumePatchToolInput = z.infer<typeof applyResumePatchToolInputSchema>;
type BuildAgentToolsInput = {
provider: AgentProviderConfig;
handlers: {
fetchUrl: (url: string) => Promise<unknown>;
readResume: () => Promise<unknown>;
readAttachment: (attachmentId: string) => Promise<unknown>;
applyResumePatch: (input: ApplyResumePatchToolInput) => Promise<unknown>;
};
};
export function buildProviderNativeAgentTools(provider: AgentProviderConfig): ToolSet {
if (!supportsProviderNativeWebSearch(provider)) return {};
const openai = createOpenAI({
apiKey: provider.apiKey,
...(provider.baseURL ? { baseURL: provider.baseURL } : {}),
});
// Defensive runtime check: older `@ai-sdk/openai` versions and some OpenAI-compatible
// gateways don't expose tools.webSearch. supportsProviderNativeWebSearch() filters out
// non-OpenAI providers, but this guards against SDK-shape drift on the OpenAI path.
if (typeof openai.tools.webSearch !== "function") return {};
return {
web_search: openai.tools.webSearch({
searchContextSize: "low",
}),
};
}
export function buildAgentInstructions({ hasProviderNativeSearch }: { hasProviderNativeSearch: boolean }) {
const baseInstructions =
"You are an expert resume-writing agent inside Reactive Resume. Help the user improve the working resume for a target role. Read the resume before editing. Respond to the user in clean Markdown with concise paragraphs, bullets, and bold text when it improves scanability. Apply concise, valid JSON Patch operations when changes are useful. Patch paths are evaluated against the resume data object returned by read_resume, so use paths like /basics/name for the visible name and never /data/basics/name or /name. apply_resume_patch cannot rename the resume file/title metadata. Batch related JSON Patch operations into one apply_resume_patch call for each coherent edit instead of making repeated patch calls for the same request. Ask the user a question when a missing preference blocks a high-confidence edit.";
if (!hasProviderNativeSearch) {
return `${baseInstructions} Use fetch_url for user-provided public HTTPS URLs, exact pages, public job descriptions, or company pages.`;
}
return `${baseInstructions} Use web_search for open-ended or current web research, such as finding recent company, industry, or role context. Use fetch_url for user-provided public HTTPS URLs, exact pages, public job descriptions, or company pages.`;
}
export function buildAgentTools(input: BuildAgentToolsInput): ToolSet {
return {
...buildProviderNativeAgentTools(input.provider),
ask_user_question: tool({
description:
"Ask the user a short question when you need a preference, missing fact, or choice before continuing. Provide 2-4 recommended answer choices when possible.",
inputSchema: z.object({
question: z.string().trim().min(1),
choices: z.array(z.string().trim().min(1)).min(1).max(4).optional(),
recommendedChoice: z.string().trim().optional(),
}),
}),
fetch_url: tool({
description:
"Fetch readable text from a public HTTPS URL, such as a job description. Private, local, and non-HTTPS URLs are blocked.",
inputSchema: z.object({ url: z.string().url() }),
execute: ({ url }) => input.handlers.fetchUrl(url),
}),
read_resume: tool({
description: "Read the current working resume JSON and metadata.",
inputSchema: z.object({}),
execute: input.handlers.readResume,
}),
read_attachment: tool({
description:
"Read a message attachment by id. Text, Markdown, and JSON attachments include content; images and supported files may already be provided directly to the model.",
inputSchema: z.object({ attachmentId: z.string().trim().min(1) }),
execute: ({ attachmentId }) => input.handlers.readAttachment(attachmentId),
}),
apply_resume_patch: tool({
description:
"Apply one cohesive batch of JSON Patch operations to the working resume data immediately. Paths are rooted at resume data; use /basics/name for the visible resume name, not /data/basics/name or /name. This tool cannot rename the resume file/title metadata. The user can revert the action later.",
inputSchema: applyResumePatchToolInputSchema,
execute: (toolInput) => input.handlers.applyResumePatch(toolInput),
}),
};
}
+725
View File
@@ -0,0 +1,725 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const envMock = vi.hoisted(() => ({
CLOUDFLARE_ACCOUNT_ID: "",
CLOUDFLARE_API_TOKEN: "",
FLAG_ALLOW_UNSAFE_AI_BASE_URL: false,
}));
const dnsMock = vi.hoisted(() => ({
lookup: vi.fn(),
}));
const undiciMock = vi.hoisted(() => {
class MockAgent {
static instances: MockAgent[] = [];
close = vi.fn().mockResolvedValue(undefined);
constructor(readonly options: Record<string, unknown>) {
MockAgent.instances.push(this);
}
}
return {
Agent: MockAgent,
fetch: vi.fn((input: RequestInfo | URL, init?: RequestInit) => globalThis.fetch(input, init)),
};
});
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
vi.mock("node:dns/promises", () => dnsMock);
vi.mock("undici", () => ({
Agent: undiciMock.Agent,
fetch: undiciMock.fetch,
}));
const { fetchUrlForAgent } = await import("./agent-url");
function textResponse(body: string, options: { contentType: string; url?: string; status?: number }) {
return new Response(body, {
status: options.status ?? 200,
headers: { "content-type": options.contentType },
}) as Response & { url: string };
}
function responseWithUrl(body: string, options: { contentType: string; url?: string; status?: number }) {
const response = textResponse(body, options);
Object.defineProperty(response, "url", { value: options.url ?? "https://example.com/article" });
return response;
}
describe("fetchUrlForAgent", () => {
beforeEach(() => {
envMock.CLOUDFLARE_ACCOUNT_ID = "";
envMock.CLOUDFLARE_API_TOKEN = "";
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = false;
vi.restoreAllMocks();
dnsMock.lookup.mockReset();
dnsMock.lookup.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
undiciMock.Agent.instances.length = 0;
undiciMock.fetch.mockReset();
undiciMock.fetch.mockImplementation((input: RequestInfo | URL, init?: RequestInit) =>
globalThis.fetch(input, init),
);
vi.useRealTimers();
});
it("extracts local HTML article text with Readability and strips navigation/script/style noise", async () => {
const articleText =
"This is the actual article body with enough useful detail for the AI agent to summarize accurately. ".repeat(4);
const fetchMock = vi.fn().mockResolvedValue(
responseWithUrl(
`
<!doctype html>
<html>
<head>
<title>Head title should lose to article title</title>
<style>.secret { color: red; }</style>
</head>
<body>
<nav>Home Pricing Login</nav>
<script>window.__tracking = "do not include";</script>
<article>
<h1>Readable Article Title</h1>
<p>${articleText}</p>
</article>
<footer>Privacy Terms</footer>
</body>
</html>
`,
{ contentType: "text/html; charset=utf-8", url: "https://example.com/final/article" },
),
);
vi.stubGlobal("fetch", fetchMock);
const result = await fetchUrlForAgent("https://example.com/article");
expect(result).toEqual({
url: "https://example.com/article",
title: "Head title should lose to article title",
content: expect.stringContaining("actual article body"),
source: "local",
});
expect(result.content).toContain("Readable Article Title");
expect(result.content).not.toContain("Home Pricing Login");
expect(result.content).not.toContain("window.__tracking");
expect(result.content).not.toContain("color: red");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"https://example.com/article",
expect.objectContaining({
redirect: "manual",
signal: expect.any(AbortSignal),
}),
);
});
it("compacts local plain text and JSON responses", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(responseWithUrl("one\n\n two\tthree", { contentType: "text/plain" }))
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ name: "Ada", role: "Engineer" }, null, 2), {
contentType: "application/json",
}),
);
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("https://example.com/plain.txt")).resolves.toMatchObject({
content: "one two three",
source: "local",
title: null,
});
await expect(fetchUrlForAgent("https://example.com/data.json")).resolves.toMatchObject({
content: '{ "name": "Ada", "role": "Engineer" }',
source: "local",
title: null,
});
});
it("falls back to Cloudflare when local content type is unsupported and credentials exist", async () => {
vi.useFakeTimers();
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
const fetchMock = vi
.fn()
.mockResolvedValueOnce(responseWithUrl("binary", { contentType: "application/pdf" }))
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ success: true, result: "crawl-job-id" }), {
contentType: "application/json",
url: "https://api.cloudflare.com/client/v4/accounts/account-id/browser-rendering/crawl",
}),
)
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ result: { status: "running", records: [] } }), {
contentType: "application/json",
}),
)
.mockResolvedValueOnce(
responseWithUrl(
JSON.stringify({
result: { status: "completed", records: [{ markdown: "# Rendered\n\nCloudflare markdown" }] },
}),
{ contentType: "application/json" },
),
);
vi.stubGlobal("fetch", fetchMock);
const result = fetchUrlForAgent("https://example.com/file.pdf");
await vi.advanceTimersByTimeAsync(0);
expect(fetchMock).toHaveBeenCalledTimes(3);
await vi.advanceTimersByTimeAsync(500);
await expect(result).resolves.toMatchObject({
url: "https://example.com/file.pdf",
content: "# Rendered Cloudflare markdown",
source: "cloudflare",
});
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"https://api.cloudflare.com/client/v4/accounts/account-id/browser-rendering/crawl",
expect.objectContaining({
method: "POST",
signal: expect.any(AbortSignal),
body: JSON.stringify({
url: "https://example.com/file.pdf",
crawlPurposes: ["ai-input"],
formats: ["markdown"],
render: true,
limit: 1,
depth: 0,
}),
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
3,
"https://api.cloudflare.com/client/v4/accounts/account-id/browser-rendering/crawl/crawl-job-id?limit=1",
expect.objectContaining({
signal: expect.any(AbortSignal),
headers: expect.objectContaining({
authorization: "Bearer api-token",
}),
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
4,
"https://api.cloudflare.com/client/v4/accounts/account-id/browser-rendering/crawl/crawl-job-id?limit=1",
expect.any(Object),
);
});
it("submits the final validated redirect URL to Cloudflare when local extraction fails after redirects", async () => {
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: "/file.pdf" } }))
.mockResolvedValueOnce(
responseWithUrl("binary", { contentType: "application/pdf", url: "https://example.com/file.pdf" }),
)
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ success: true, result: "redirect-job-id" }), {
contentType: "application/json",
}),
)
.mockResolvedValueOnce(
responseWithUrl(
JSON.stringify({ result: { status: "completed", records: [{ markdown: "redirect fallback" }] } }),
{
contentType: "application/json",
},
),
);
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("https://example.com/start")).resolves.toMatchObject({
url: "https://example.com/file.pdf",
content: "redirect fallback",
source: "cloudflare",
});
expect(fetchMock).toHaveBeenNthCalledWith(
3,
"https://api.cloudflare.com/client/v4/accounts/account-id/browser-rendering/crawl",
expect.objectContaining({
body: expect.stringContaining('"url":"https://example.com/file.pdf"'),
}),
);
});
it("blocks DNS resolutions to private addresses before local fetch or Cloudflare fallback", async () => {
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
dnsMock.lookup.mockResolvedValueOnce([{ address: "10.0.0.8", family: 4 }]);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("https://public.example.com/article")).rejects.toThrow("URL_NOT_FETCHABLE");
expect(dnsMock.lookup).toHaveBeenCalledWith("public.example.com", { all: true });
expect(fetchMock).not.toHaveBeenCalled();
});
it("blocks DNS resolutions to special-use addresses before local fetch or Cloudflare fallback", async () => {
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
for (const address of [
"100.64.0.1",
"192.0.2.1",
"192.88.99.1",
"198.18.0.1",
"198.51.100.1",
"203.0.113.1",
"224.0.0.1",
"255.255.255.255",
"::",
"::ffff:8.8.8.8",
"::ffff:0808:0808",
"64:ff9b::1",
"100::1",
"100:0:0:1::1",
"2001::1",
"2001:2::1",
"2001:10::1",
"2001:100::1",
"ff02::1",
"2001:db8::1",
"3fff::1",
"5f00::1",
]) {
dnsMock.lookup.mockResolvedValueOnce([{ address, family: address.includes(":") ? 6 : 4 }]);
await expect(
fetchUrlForAgent(`https://special-${address.replaceAll(":", "-")}.example.com/article`),
).rejects.toThrow("URL_NOT_FETCHABLE");
}
expect(fetchMock).not.toHaveBeenCalled();
});
it("blocks IPv4-mapped IPv6 DNS resolutions to private addresses", async () => {
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
for (const address of ["::ffff:10.0.0.1", "::ffff:127.0.0.1", "::ffff:169.254.169.254"]) {
dnsMock.lookup.mockResolvedValueOnce([{ address, family: 6 }]);
await expect(fetchUrlForAgent(`https://${address.replaceAll(":", "-")}.example.com/article`)).rejects.toThrow(
"URL_NOT_FETCHABLE",
);
}
expect(fetchMock).not.toHaveBeenCalled();
});
it("uses a pinned dispatcher lookup for the validated DNS address", async () => {
dnsMock.lookup.mockResolvedValueOnce([{ address: "93.184.216.34", family: 4 }]);
const fetchMock = vi
.fn()
.mockResolvedValueOnce(responseWithUrl("Pinned DNS response", { contentType: "text/plain" }));
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("https://public.example.com/article")).resolves.toMatchObject({
content: "Pinned DNS response",
source: "local",
});
const [agent] = undiciMock.Agent.instances;
const connect = agent?.options.connect as
| {
autoSelectFamily?: boolean;
lookup?: (
hostname: string,
options: unknown,
callback: (
error: Error | null,
address: string | Array<{ address: string; family: number }>,
family?: number,
) => void,
) => void;
}
| undefined;
expect(connect?.autoSelectFamily).toBe(false);
await expect(
new Promise<{ address: string; family: number }>((resolve, reject) => {
connect?.lookup?.("public.example.com", {}, (error, address, family) => {
if (error) reject(error);
else if (typeof address === "string" && family) resolve({ address, family });
else reject(new Error("Expected single address lookup result"));
});
}),
).resolves.toEqual({ address: "93.184.216.34", family: 4 });
await expect(
new Promise<Array<{ address: string; family: number }>>((resolve, reject) => {
connect?.lookup?.("public.example.com", { all: true }, (error, addresses) => {
if (error) reject(error);
else if (Array.isArray(addresses)) resolve(addresses);
else reject(new Error("Expected all-address lookup result"));
});
}),
).resolves.toEqual([{ address: "93.184.216.34", family: 4 }]);
expect(dnsMock.lookup).toHaveBeenCalledTimes(1);
});
it("times out DNS validation before local fetch or Cloudflare fallback", async () => {
vi.useFakeTimers();
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
dnsMock.lookup.mockReturnValueOnce(new Promise(() => undefined));
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const result = fetchUrlForAgent("https://slow-dns.example.com/article");
const rejection = expect(result).rejects.toThrow("URL_NOT_FETCHABLE");
await vi.advanceTimersByTimeAsync(5_000);
await rejection;
expect(fetchMock).not.toHaveBeenCalled();
});
it("revalidates DNS for redirect targets before following them", async () => {
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
dnsMock.lookup
.mockResolvedValueOnce([{ address: "93.184.216.34", family: 4 }])
.mockResolvedValueOnce([{ address: "fd00::1", family: 6 }]);
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(null, { status: 302, headers: { location: "https://cdn.example.com/final" } }),
);
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("https://example.com/start")).rejects.toThrow("URL_NOT_FETCHABLE");
expect(dnsMock.lookup).toHaveBeenNthCalledWith(1, "example.com", { all: true });
expect(dnsMock.lookup).toHaveBeenNthCalledWith(2, "cdn.example.com", { all: true });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("uses the latest validated redirect URL for Cloudflare fallback after network errors", async () => {
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: "/final" } }))
.mockRejectedValueOnce(new Error("connect timeout"))
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ success: true, result: "network-job" }), { contentType: "application/json" }),
)
.mockResolvedValueOnce(
responseWithUrl(
JSON.stringify({ result: { status: "completed", records: [{ markdown: "network fallback" }] } }),
{
contentType: "application/json",
},
),
);
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("https://example.com/start")).resolves.toMatchObject({
url: "https://example.com/final",
content: "network fallback",
source: "cloudflare",
});
expect(fetchMock).toHaveBeenNthCalledWith(
3,
"https://api.cloudflare.com/client/v4/accounts/account-id/browser-rendering/crawl",
expect.objectContaining({
body: expect.stringContaining('"url":"https://example.com/final"'),
}),
);
});
it("cancels redirect, non-OK, unsupported, and oversized response bodies", async () => {
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
const redirectCancel = vi.fn();
const nonOkCancel = vi.fn();
const unsupportedCancel = vi.fn();
const oversizedCancel = vi.fn();
const oversizedBody = new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(2 * 1024 * 1024 + 1));
},
cancel: oversizedCancel,
});
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(new ReadableStream({ cancel: redirectCancel }), {
status: 302,
headers: { location: "https://example.com/redirected" },
}),
)
.mockResolvedValueOnce(
responseWithUrl("ok ".repeat(80), { contentType: "text/plain", url: "https://example.com/redirected" }),
)
.mockResolvedValueOnce(new Response(new ReadableStream({ cancel: nonOkCancel }), { status: 500 }))
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ success: true, result: "non-ok-job" }), { contentType: "application/json" }),
)
.mockResolvedValueOnce(
responseWithUrl(
JSON.stringify({ result: { status: "completed", records: [{ markdown: "non-ok fallback" }] } }),
{ contentType: "application/json" },
),
)
.mockResolvedValueOnce(
new Response(new ReadableStream({ cancel: unsupportedCancel }), {
status: 200,
headers: { "content-type": "application/pdf" },
}),
)
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ success: true, result: "unsupported-job" }), {
contentType: "application/json",
}),
)
.mockResolvedValueOnce(
responseWithUrl(
JSON.stringify({ result: { status: "completed", records: [{ markdown: "unsupported fallback" }] } }),
{ contentType: "application/json" },
),
)
.mockResolvedValueOnce(new Response(oversizedBody, { status: 200, headers: { "content-type": "text/plain" } }))
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ success: true, result: "oversized-job" }), {
contentType: "application/json",
}),
)
.mockResolvedValueOnce(
responseWithUrl(
JSON.stringify({ result: { status: "completed", records: [{ markdown: "oversized fallback" }] } }),
{ contentType: "application/json" },
),
);
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("https://example.com/redirect")).resolves.toMatchObject({ source: "local" });
await expect(fetchUrlForAgent("https://example.com/non-ok")).resolves.toMatchObject({ content: "non-ok fallback" });
await expect(fetchUrlForAgent("https://example.com/unsupported")).resolves.toMatchObject({
content: "unsupported fallback",
});
await expect(fetchUrlForAgent("https://example.com/oversized")).resolves.toMatchObject({
content: "oversized fallback",
});
expect(redirectCancel).toHaveBeenCalled();
expect(nonOkCancel).toHaveBeenCalled();
expect(unsupportedCancel).toHaveBeenCalled();
expect(oversizedCancel).toHaveBeenCalled();
});
it("cancels Cloudflare non-OK crawl creation and poll response bodies", async () => {
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
const createCancel = vi.fn();
const pollCancel = vi.fn();
const fetchMock = vi
.fn()
.mockResolvedValueOnce(responseWithUrl("binary", { contentType: "application/pdf" }))
.mockResolvedValueOnce(
new Response(new ReadableStream({ cancel: createCancel }), {
status: 500,
headers: { "content-type": "application/json" },
}),
)
.mockResolvedValueOnce(responseWithUrl("binary", { contentType: "application/pdf" }))
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ success: true, result: "poll-job" }), { contentType: "application/json" }),
)
.mockResolvedValueOnce(
new Response(new ReadableStream({ cancel: pollCancel }), {
status: 503,
headers: { "content-type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("https://example.com/create-fails")).rejects.toThrow("URL_FETCH_FAILED");
await expect(fetchUrlForAgent("https://example.com/poll-fails")).rejects.toThrow("URL_FETCH_FAILED");
expect(createCancel).toHaveBeenCalled();
expect(pollCancel).toHaveBeenCalled();
});
it("falls back to Cloudflare when local Readability content is too small and credentials exist", async () => {
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
responseWithUrl("<html><head><title>Thin</title></head><body><article><p>Tiny.</p></article></body></html>", {
contentType: "text/html",
}),
)
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ success: true, result: "thin-job-id" }), {
contentType: "application/json",
}),
)
.mockResolvedValueOnce(
responseWithUrl(
JSON.stringify({
result: { status: "completed", records: [{ markdown: "rendered fallback for thin page" }] },
}),
{
contentType: "application/json",
},
),
);
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("https://example.com/thin")).resolves.toMatchObject({
content: "rendered fallback for thin page",
source: "cloudflare",
});
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("parses supported Cloudflare markdown payload shapes", async () => {
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
const fetchMock = vi
.fn()
.mockResolvedValueOnce(responseWithUrl("nope", { contentType: "application/octet-stream" }))
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ success: true, result: "records-job" }), { contentType: "application/json" }),
)
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ result: { status: "completed", records: [{ markdown: "records shape" }] } }), {
contentType: "application/json",
}),
)
.mockResolvedValueOnce(responseWithUrl("nope", { contentType: "application/octet-stream" }))
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ success: true, result: "markdown-job" }), { contentType: "application/json" }),
)
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ result: { markdown: "first shape" } }), { contentType: "application/json" }),
)
.mockResolvedValueOnce(responseWithUrl("nope", { contentType: "application/octet-stream" }))
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ success: true, result: "array-job" }), { contentType: "application/json" }),
)
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ result: [{ markdown: "array shape" }] }), { contentType: "application/json" }),
);
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("https://example.com/records")).resolves.toMatchObject({ content: "records shape" });
await expect(fetchUrlForAgent("https://example.com/markdown")).resolves.toMatchObject({ content: "first shape" });
await expect(fetchUrlForAgent("https://example.com/array")).resolves.toMatchObject({ content: "array shape" });
});
it("blocks private and non-HTTPS URLs before Cloudflare fallback", async () => {
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = true;
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("http://example.com/article")).rejects.toThrow("URL_NOT_FETCHABLE");
await expect(fetchUrlForAgent("https://localhost/internal")).rejects.toThrow("URL_NOT_FETCHABLE");
await expect(fetchUrlForAgent("https://10.0.0.5/internal")).rejects.toThrow("URL_NOT_FETCHABLE");
expect(fetchMock).not.toHaveBeenCalled();
});
it("blocks redirects to private and non-HTTPS URLs before following them or falling back to Cloudflare", async () => {
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: "https://localhost/internal" } }))
.mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: "http://example.com/plain" } }));
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("https://example.com/private-redirect")).rejects.toThrow("URL_NOT_FETCHABLE");
await expect(fetchUrlForAgent("https://example.com/http-redirect")).rejects.toThrow("URL_NOT_FETCHABLE");
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"https://example.com/private-redirect",
expect.objectContaining({ redirect: "manual" }),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"https://example.com/http-redirect",
expect.objectContaining({ redirect: "manual" }),
);
});
it("blocks redirects without Location before falling back to Cloudflare", async () => {
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
const fetchMock = vi.fn().mockResolvedValueOnce(new Response(null, { status: 302 }));
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("https://example.com/missing-location")).rejects.toThrow("URL_NOT_FETCHABLE");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"https://example.com/missing-location",
expect.objectContaining({ redirect: "manual" }),
);
});
it("resolves relative HTTPS redirects and uses the final URL for local Readability extraction", async () => {
const articleText =
"This redirected article has enough readable content for extraction after following a relative Location header. ".repeat(
4,
);
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: "/final/article" } }))
.mockResolvedValueOnce(
responseWithUrl(
`<html><head><title>Redirected</title></head><body><article><h1>Redirected</h1><p>${articleText}</p></article></body></html>`,
{ contentType: "text/html", url: "https://example.com/final/article" },
),
);
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("https://example.com/start")).resolves.toMatchObject({
url: "https://example.com/final/article",
content: expect.stringContaining("redirected article"),
source: "local",
});
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"https://example.com/final/article",
expect.objectContaining({ redirect: "manual" }),
);
});
it("falls back to Cloudflare when the local response exceeds the byte limit", async () => {
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
const oversized = "a".repeat(2 * 1024 * 1024 + 1);
const fetchMock = vi
.fn()
.mockResolvedValueOnce(responseWithUrl(oversized, { contentType: "text/plain" }))
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ success: true, result: "huge-job-id" }), { contentType: "application/json" }),
)
.mockResolvedValueOnce(
responseWithUrl(
JSON.stringify({
result: { status: "completed", records: [{ markdown: "fallback after oversized response" }] },
}),
{
contentType: "application/json",
},
),
);
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("https://example.com/huge.txt")).resolves.toMatchObject({
content: "fallback after oversized response",
source: "cloudflare",
});
expect(fetchMock).toHaveBeenCalledTimes(3);
});
});
+361
View File
@@ -0,0 +1,361 @@
import { lookup } from "node:dns/promises";
import { Readability } from "@mozilla/readability";
import { JSDOM } from "jsdom";
import { Agent, fetch as undiciFetch } from "undici";
import { env } from "@reactive-resume/env/server";
import { isPrivateOrLoopbackHost } from "@reactive-resume/utils/url-security.node";
import { assertFetchablePublicHttpsUrl } from "./ai-url-policy";
const MAX_FETCHED_TEXT_CHARS = 40_000;
const MAX_LOCAL_FETCH_BYTES = 2 * 1024 * 1024;
const MAX_LOCAL_REDIRECTS = 5;
const MAX_CLOUDFLARE_CRAWL_POLLS = 6;
const CLOUDFLARE_CRAWL_POLL_DELAY_MS = 500;
const LOCAL_FETCH_TIMEOUT_MS = 10_000;
const DNS_LOOKUP_TIMEOUT_MS = 5_000;
const CLOUDFLARE_CRAWL_CREATE_TIMEOUT_MS = 15_000;
const CLOUDFLARE_CRAWL_POLL_TIMEOUT_MS = 10_000;
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
type FetchedUrlResult = {
url: string;
title: string | null;
content: string;
source: "local" | "cloudflare";
};
type FetchResponse = Awaited<ReturnType<typeof undiciFetch>>;
type ResolvedAddress = { address: string; family: number };
class LocalFetchError extends Error {
constructor(
message: string,
readonly fallbackUrl: string,
) {
super(message);
}
}
function compactText(value: string) {
return value.replace(/\s+/g, " ").trim().slice(0, MAX_FETCHED_TEXT_CHARS);
}
function extractReadableHtml(html: string, url: string) {
const dom = new JSDOM(html, { url });
try {
const article = new Readability(dom.window.document).parse();
const content = compactText(article?.textContent ?? "");
if (content.length < 160) throw new Error("URL_READABILITY_FAILED");
return {
title: article?.title ? compactText(article.title) : null,
content,
};
} finally {
dom.window.close();
}
}
async function readLimitedText(response: FetchResponse) {
const reader = response.body?.getReader();
if (!reader) return response.text();
const chunks: Uint8Array[] = [];
let size = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
size += value.byteLength;
if (size > MAX_LOCAL_FETCH_BYTES) {
await reader.cancel("FETCHED_URL_TOO_LARGE");
throw new Error("FETCHED_URL_TOO_LARGE");
}
chunks.push(value);
}
return new TextDecoder().decode(Buffer.concat(chunks));
}
function timeoutSignal(ms: number) {
if (typeof AbortSignal.timeout === "function") return AbortSignal.timeout(ms);
const controller = new AbortController();
setTimeout(() => controller.abort(), ms);
return controller.signal;
}
async function withTimeout<T>(promise: Promise<T>, ms: number, errorCode: string) {
let timeout: NodeJS.Timeout | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timeout = setTimeout(() => reject(new Error(errorCode)), ms);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
}
async function cancelResponseBody(response: FetchResponse) {
try {
await response.body?.cancel();
} catch {
// Best effort cleanup only; the original fetch/extraction error should stay authoritative.
}
}
function isRedirectResponse(response: { status: number }) {
return REDIRECT_STATUSES.has(response.status);
}
function resolveRedirectUrl(location: string, currentUrl: string) {
try {
return new URL(location, currentUrl).toString();
} catch {
throw new Error("URL_NOT_FETCHABLE");
}
}
async function assertResolvesToPublicAddress(url: string) {
const { hostname } = new URL(url);
let addresses: ResolvedAddress[];
try {
addresses = await withTimeout(lookup(hostname, { all: true }), DNS_LOOKUP_TIMEOUT_MS, "URL_NOT_FETCHABLE");
} catch {
throw new Error("URL_NOT_FETCHABLE");
}
if (addresses.length === 0) throw new Error("URL_NOT_FETCHABLE");
if (addresses.some(({ address }) => isPrivateOrLoopbackHost(address))) throw new Error("URL_NOT_FETCHABLE");
const [address] = addresses;
if (!address) throw new Error("URL_NOT_FETCHABLE");
return address;
}
function createPinnedDispatcher(url: string, address: string, family: number) {
const { hostname } = new URL(url);
return new Agent({
connect: {
autoSelectFamily: false,
servername: hostname,
lookup: (_hostname, options, callback) => {
if (typeof options === "object" && options && "all" in options && options.all) {
callback(null, [{ address, family }]);
return;
}
callback(null, address, family);
},
},
});
}
async function fetchLocalResponse(inputUrl: string) {
let url = assertFetchablePublicHttpsUrl(inputUrl);
for (let redirectCount = 0; redirectCount <= MAX_LOCAL_REDIRECTS; redirectCount++) {
const address = await assertResolvesToPublicAddress(url);
const dispatcher = createPinnedDispatcher(url, address.address, address.family);
let response: FetchResponse;
try {
response = await undiciFetch(url, {
dispatcher,
redirect: "manual",
signal: timeoutSignal(LOCAL_FETCH_TIMEOUT_MS),
headers: {
accept: "text/html, text/plain;q=0.9, application/json;q=0.8",
"user-agent": "ReactiveResumeAI/1.0",
},
});
} catch (error) {
await dispatcher.close();
if (error instanceof Error && error.message === "URL_NOT_FETCHABLE") throw error;
throw new LocalFetchError("URL_FETCH_FAILED", url);
}
if (!isRedirectResponse(response)) return { response, url, dispatcher };
const location = response.headers.get("location");
await cancelResponseBody(response);
await dispatcher.close();
if (!location) throw new Error("URL_NOT_FETCHABLE");
if (redirectCount === MAX_LOCAL_REDIRECTS) throw new Error("URL_NOT_FETCHABLE");
url = assertFetchablePublicHttpsUrl(resolveRedirectUrl(location, url));
}
throw new Error("URL_NOT_FETCHABLE");
}
async function fetchLocally(url: string): Promise<FetchedUrlResult> {
const { response, url: responseUrl, dispatcher } = await fetchLocalResponse(url);
try {
if (!response.ok) {
await cancelResponseBody(response);
throw new LocalFetchError("URL_FETCH_FAILED", responseUrl);
}
const contentType = response.headers.get("content-type") ?? "";
if (
!contentType.includes("text/html") &&
!contentType.includes("text/plain") &&
!contentType.includes("application/json")
) {
await cancelResponseBody(response);
throw new LocalFetchError("URL_FETCH_UNSUPPORTED_CONTENT_TYPE", responseUrl);
}
const raw = await readLimitedText(response);
const isHtml = contentType.includes("text/html");
const extracted = isHtml ? extractReadableHtml(raw, responseUrl) : { title: null, content: compactText(raw) };
return {
url: responseUrl,
title: extracted.title,
content: extracted.content,
source: "local",
};
} catch (error) {
if (error instanceof LocalFetchError) throw error;
if (error instanceof Error) throw new LocalFetchError(error.message, responseUrl);
throw new LocalFetchError("URL_READABILITY_FAILED", responseUrl);
} finally {
await dispatcher.close();
}
}
function extractCloudflareMarkdown(payload: unknown): string | null {
if (!payload || typeof payload !== "object") return null;
const record = payload as Record<string, unknown>;
const result = record.result;
if (result && typeof result === "object") {
const resultRecord = result as Record<string, unknown>;
if (Array.isArray(resultRecord.records)) {
const [first] = resultRecord.records;
const markdown = first && typeof first === "object" ? (first as Record<string, unknown>).markdown : null;
if (typeof markdown === "string") return markdown;
}
if (typeof resultRecord.markdown === "string") return resultRecord.markdown;
if (Array.isArray(resultRecord.pages)) {
const [first] = resultRecord.pages;
const markdown = first && typeof first === "object" ? (first as Record<string, unknown>).markdown : null;
if (typeof markdown === "string") return markdown;
}
}
if (Array.isArray(result)) {
const [first] = result;
const markdown = first && typeof first === "object" ? (first as Record<string, unknown>).markdown : null;
if (typeof markdown === "string") return markdown;
}
return null;
}
function extractCloudflareJobId(payload: unknown): string | null {
if (!payload || typeof payload !== "object") return null;
const result = (payload as Record<string, unknown>).result;
return typeof result === "string" && result.trim() ? result : null;
}
function extractCloudflareCrawlStatus(payload: unknown): string | null {
if (!payload || typeof payload !== "object") return null;
const result = (payload as Record<string, unknown>).result;
if (!result || typeof result !== "object") return null;
const status = (result as Record<string, unknown>).status;
return typeof status === "string" ? status.toLowerCase() : null;
}
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchWithCloudflare(url: string): Promise<FetchedUrlResult> {
if (!env.CLOUDFLARE_ACCOUNT_ID || !env.CLOUDFLARE_API_TOKEN) throw new Error("URL_READABILITY_FAILED");
const crawlUrl = `https://api.cloudflare.com/client/v4/accounts/${env.CLOUDFLARE_ACCOUNT_ID}/browser-rendering/crawl`;
const headers = {
authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}`,
};
const response = await undiciFetch(crawlUrl, {
method: "POST",
signal: timeoutSignal(CLOUDFLARE_CRAWL_CREATE_TIMEOUT_MS),
headers: {
...headers,
"content-type": "application/json",
},
body: JSON.stringify({
url,
crawlPurposes: ["ai-input"],
formats: ["markdown"],
render: true,
limit: 1,
depth: 0,
}),
});
if (!response.ok) {
await cancelResponseBody(response);
throw new Error("URL_FETCH_FAILED");
}
const jobId = extractCloudflareJobId(await response.json());
if (!jobId) throw new Error("URL_READABILITY_FAILED");
let markdown: string | null = null;
for (let attempt = 0; attempt < MAX_CLOUDFLARE_CRAWL_POLLS; attempt++) {
const resultResponse = await undiciFetch(`${crawlUrl}/${encodeURIComponent(jobId)}?limit=1`, {
headers,
signal: timeoutSignal(CLOUDFLARE_CRAWL_POLL_TIMEOUT_MS),
});
if (!resultResponse.ok) {
await cancelResponseBody(resultResponse);
throw new Error("URL_FETCH_FAILED");
}
const payload = await resultResponse.json();
markdown = extractCloudflareMarkdown(payload);
if (markdown) break;
const status = extractCloudflareCrawlStatus(payload);
if (status !== "running" && status !== "queued") break;
if (attempt < MAX_CLOUDFLARE_CRAWL_POLLS - 1) await wait(CLOUDFLARE_CRAWL_POLL_DELAY_MS);
}
if (!markdown) throw new Error("URL_READABILITY_FAILED");
return {
url,
title: null,
content: compactText(markdown),
source: "cloudflare",
};
}
export async function fetchUrlForAgent(input: string): Promise<FetchedUrlResult> {
const url = assertFetchablePublicHttpsUrl(input);
try {
return await fetchLocally(url);
} catch (error) {
if (error instanceof Error && error.message === "URL_NOT_FETCHABLE") throw error;
return fetchWithCloudflare(error instanceof LocalFetchError ? error.fallbackUrl : url);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { isDirectOpenAIProvider, supportsOpenAIWebSearch } from "./ai-capabilities";
describe("AI provider capabilities", () => {
it("identifies direct OpenAI base URL configs", () => {
expect(isDirectOpenAIProvider({ provider: "openai", baseURL: "" })).toBe(true);
expect(isDirectOpenAIProvider({ provider: "openai", baseURL: "https://api.openai.com/v1/" })).toBe(true);
expect(isDirectOpenAIProvider({ provider: "openai", baseURL: "https://example.com/v1" })).toBe(false);
expect(isDirectOpenAIProvider({ provider: "openai", baseURL: "https://api.openai.com/v1?proxy=1" })).toBe(false);
expect(isDirectOpenAIProvider({ provider: "openai", baseURL: "https://api.openai.com/v1#fragment" })).toBe(false);
expect(isDirectOpenAIProvider({ provider: "openrouter", baseURL: "https://api.openai.com/v1" })).toBe(false);
});
it("keeps the OpenAI web search model predicate conservative", () => {
const allowedModels = [
"gpt-5.5",
"gpt-5.5-2026-04-23",
"gpt-5.5-pro",
"gpt-5.5-pro-2026-04-23",
"gpt-5.4",
"gpt-5.4-2026-03-05",
"gpt-5.4-mini",
"gpt-5.4-mini-2026-03-17",
"gpt-5.4-nano",
"gpt-5.4-nano-2026-03-17",
"gpt-5.4-pro",
"gpt-5.4-pro-2026-03-05",
"gpt-5",
"gpt-5-2025-08-07",
"gpt-5-mini",
"gpt-5-mini-2025-08-07",
"gpt-5-nano",
"gpt-5-nano-2025-08-07",
"gpt-4.1",
"gpt-4.1-2025-04-14",
"gpt-4.1-mini",
"gpt-4.1-mini-2025-04-14",
"o4-mini",
"o4-mini-2025-04-16",
];
const deniedModels = [
"gpt-4.1-nano",
"gpt-4.1-nano-2025-04-14",
"gpt-4o",
"gpt-4o-mini",
"gpt-4o-search-preview",
"o1",
"o1-2024-12-17",
"o3",
"o3-mini",
"gpt-3.5-turbo",
"gpt-5-codex",
"gpt-5.1-codex",
"gpt-5.5-codex",
"gpt-4x1-2025-04-14",
"gpt-5x5-2026-04-23",
"gpt-5x5-pro-2026-04-23",
"custom-model",
];
for (const model of allowedModels) {
expect(supportsOpenAIWebSearch(model), model).toBe(true);
}
for (const model of deniedModels) {
expect(supportsOpenAIWebSearch(model), model).toBe(false);
}
});
});
@@ -0,0 +1,90 @@
import type { AIProvider } from "@reactive-resume/ai/types";
import { AI_PROVIDER_DEFAULT_BASE_URLS } from "@reactive-resume/ai/types";
type AiProviderCapabilityInput = {
provider: AIProvider;
model: string;
baseURL?: string | null;
};
function normalizeDirectOpenAIBaseUrl(baseURL: string) {
try {
const parsed = new URL(baseURL);
if (parsed.search || parsed.hash) return null;
return parsed.toString().replace(/\/+$/, "");
} catch {
return baseURL.trim().replace(/\/+$/, "");
}
}
export function isDirectOpenAIProvider(input: Pick<AiProviderCapabilityInput, "provider" | "baseURL">) {
if (input.provider !== "openai") return false;
if (!input.baseURL?.trim()) return true;
const baseURL = normalizeDirectOpenAIBaseUrl(input.baseURL);
if (!baseURL) return false;
return baseURL === normalizeDirectOpenAIBaseUrl(AI_PROVIDER_DEFAULT_BASE_URLS.openai);
}
const OPENAI_WEB_SEARCH_RESPONSES_MODEL_IDS = new Set([
// Snapshot from official OpenAI model docs on 2026-05-13. These model pages list Responses
// API support and Responses web search support. Most are also explicit in installed
// @ai-sdk/openai OpenAIResponsesModelId; gpt-5.5-pro is accepted through the SDK's string
// model ID fallback and openai.responses("gpt-5.5-pro") runtime construction.
// https://developers.openai.com/api/docs/models/gpt-5.5-pro
"gpt-5.5-pro",
// https://developers.openai.com/api/docs/models/gpt-5.5
"gpt-5.5",
// https://developers.openai.com/api/docs/models/gpt-5.4
"gpt-5.4",
// https://developers.openai.com/api/docs/models/gpt-5.4-mini
"gpt-5.4-mini",
// https://developers.openai.com/api/docs/models/gpt-5.4-nano
"gpt-5.4-nano",
// https://developers.openai.com/api/docs/models/gpt-5.4-pro
"gpt-5.4-pro",
// https://developers.openai.com/api/docs/models/gpt-5
"gpt-5",
// https://developers.openai.com/api/docs/models/gpt-5-mini
"gpt-5-mini",
// https://developers.openai.com/api/docs/models/gpt-5-nano
"gpt-5-nano",
// https://developers.openai.com/api/docs/models/gpt-4.1
"gpt-4.1",
// https://developers.openai.com/api/docs/models/gpt-4.1-mini
"gpt-4.1-mini",
// https://developers.openai.com/api/docs/guides/tools-web-search?api-mode=responses
"o4-mini",
]);
function isDateSnapshotForModel(model: string, modelId: string) {
const snapshotPrefix = `${modelId}-`;
if (!model.startsWith(snapshotPrefix)) return false;
const suffix = model.slice(snapshotPrefix.length);
const [year, month, day] = suffix.split("-");
return (
suffix.length === "YYYY-MM-DD".length &&
year?.length === 4 &&
month?.length === 2 &&
day?.length === 2 &&
[year, month, day].every((part) => /^\d+$/.test(part))
);
}
export function supportsOpenAIWebSearch(model: string) {
const normalized = model.trim().toLowerCase();
if (!normalized || normalized.includes("codex")) return false;
if (OPENAI_WEB_SEARCH_RESPONSES_MODEL_IDS.has(normalized)) return true;
return Array.from(OPENAI_WEB_SEARCH_RESPONSES_MODEL_IDS).some((modelId) =>
isDateSnapshotForModel(normalized, modelId),
);
}
export function supportsProviderNativeWebSearch(provider: AiProviderCapabilityInput) {
return isDirectOpenAIProvider(provider) && supportsOpenAIWebSearch(provider.model);
}
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from "vitest";
const envMock = vi.hoisted(() => ({
ENCRYPTION_SECRET: "test-secret-with-enough-entropy",
REDIS_URL: "redis://localhost:6379",
}));
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
const {
assertAgentEnvironment,
decryptCredential,
encryptCredential,
fingerprintCredential,
isAgentEnvironmentConfigured,
redactEncryptedCredential,
} = await import("./ai-credentials");
describe("AI credential encryption", () => {
it("encrypts and decrypts provider API keys without storing plaintext", () => {
const encrypted = encryptCredential("sk-test-secret");
expect(encrypted.encryptedApiKey).not.toContain("sk-test-secret");
expect(encrypted.apiKeyPreview).toBe("sk-t...cret");
expect(decryptCredential(encrypted.encryptedApiKey)).toBe("sk-test-secret");
});
it("generates salted non-revealable fingerprints", () => {
const first = fingerprintCredential("sk-test-secret", "salt-a");
const again = fingerprintCredential("sk-test-secret", "salt-a");
const differentSalt = fingerprintCredential("sk-test-secret", "salt-b");
expect(first).toBe(again);
expect(first).not.toBe(differentSalt);
expect(first).not.toContain("sk-test-secret");
});
it("redacts stored encrypted credential fields from API responses", () => {
const encrypted = encryptCredential("sk-test-secret");
const redacted = redactEncryptedCredential({
encryptedApiKey: encrypted.encryptedApiKey,
apiKeySalt: encrypted.apiKeySalt,
apiKeyHash: encrypted.apiKeyHash,
apiKeyPreview: encrypted.apiKeyPreview,
});
expect(redacted).toEqual({
apiKeyFingerprint: encrypted.apiKeyHash,
apiKeyPreview: encrypted.apiKeyPreview,
});
expect(JSON.stringify(redacted)).not.toContain(encrypted.encryptedApiKey);
expect(JSON.stringify(redacted)).not.toContain(encrypted.apiKeySalt);
});
});
describe("AI agent environment", () => {
it("is available only when Redis and encryption secret are configured", () => {
expect(isAgentEnvironmentConfigured()).toBe(true);
expect(() => assertAgentEnvironment()).not.toThrow();
envMock.REDIS_URL = "";
expect(isAgentEnvironmentConfigured()).toBe(false);
expect(() => assertAgentEnvironment()).toThrow("AGENT_ENVIRONMENT_UNAVAILABLE");
envMock.REDIS_URL = "redis://localhost:6379";
envMock.ENCRYPTION_SECRET = "";
expect(isAgentEnvironmentConfigured()).toBe(false);
expect(() => assertAgentEnvironment()).toThrow("AGENT_ENVIRONMENT_UNAVAILABLE");
});
});
+113
View File
@@ -0,0 +1,113 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { env } from "@reactive-resume/env/server";
const CIPHER = "aes-256-gcm";
const CREDENTIAL_VERSION = "v1";
const IV_BYTES = 12;
const SALT_BYTES = 16;
type StoredCredentialFields = {
encryptedApiKey: string;
apiKeySalt: string;
apiKeyHash: string;
apiKeyPreview: string;
};
type RedactedCredentialFields = {
apiKeyFingerprint: string;
apiKeyPreview: string;
};
function getEncryptionSecret() {
return env.ENCRYPTION_SECRET?.trim() ?? "";
}
function getEncryptionKey() {
const secret = getEncryptionSecret();
if (!secret) throw new Error("AI_CREDENTIAL_ENCRYPTION_UNAVAILABLE");
return createHash("sha256").update(secret).digest();
}
function encode(value: Buffer) {
return value.toString("base64url");
}
function decode(value: string) {
return Buffer.from(value, "base64url");
}
function makePreview(apiKey: string) {
const trimmed = apiKey.trim();
if (trimmed.length <= 8) return "••••";
return `${trimmed.slice(0, 4)}...${trimmed.slice(-4)}`;
}
export function fingerprintCredential(apiKey: string, salt: string) {
return createHash("sha256").update(salt).update(":").update(apiKey).digest("hex");
}
export function encryptCredential(apiKey: string): StoredCredentialFields {
const iv = randomBytes(IV_BYTES);
const salt = encode(randomBytes(SALT_BYTES));
const cipher = createCipheriv(CIPHER, getEncryptionKey(), iv);
const ciphertext = Buffer.concat([cipher.update(apiKey, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
const payload = [CREDENTIAL_VERSION, encode(iv), encode(authTag), encode(ciphertext)].join(".");
return {
encryptedApiKey: payload,
apiKeySalt: salt,
apiKeyHash: fingerprintCredential(apiKey, salt),
apiKeyPreview: makePreview(apiKey),
};
}
export function decryptCredential(payload: string) {
const [version, encodedIv, encodedAuthTag, encodedCiphertext] = payload.split(".");
if (version !== CREDENTIAL_VERSION || !encodedIv || !encodedAuthTag || !encodedCiphertext) {
throw new Error("INVALID_ENCRYPTED_CREDENTIAL");
}
const decipher = createDecipheriv(CIPHER, getEncryptionKey(), decode(encodedIv));
decipher.setAuthTag(decode(encodedAuthTag));
return Buffer.concat([decipher.update(decode(encodedCiphertext)), decipher.final()]).toString("utf8");
}
export function credentialMatchesFingerprint(input: { apiKey: string; salt: string; hash: string }) {
const nextHash = fingerprintCredential(input.apiKey, input.salt);
const current = Buffer.from(input.hash, "hex");
const next = Buffer.from(nextHash, "hex");
return current.length === next.length && timingSafeEqual(current, next);
}
export function redactEncryptedCredential(fields: StoredCredentialFields): RedactedCredentialFields {
return {
apiKeyFingerprint: fields.apiKeyHash,
apiKeyPreview: fields.apiKeyPreview,
};
}
export function isCredentialEncryptionConfigured() {
return !!getEncryptionSecret();
}
export function isAgentStreamingConfigured() {
return !!env.REDIS_URL?.trim();
}
export function isAgentEnvironmentConfigured() {
return isCredentialEncryptionConfigured() && isAgentStreamingConfigured();
}
export function assertCredentialEncryptionConfigured() {
if (!isCredentialEncryptionConfigured()) throw new Error("AI_CREDENTIAL_ENCRYPTION_UNAVAILABLE");
}
export function assertAgentEnvironment() {
if (!isAgentEnvironmentConfigured()) throw new Error("AGENT_ENVIRONMENT_UNAVAILABLE");
}
+275
View File
@@ -0,0 +1,275 @@
import type { AIProvider } from "@reactive-resume/ai/types";
import { ORPCError } from "@orpc/client";
import { and, asc, desc, eq, sql } from "drizzle-orm";
import { aiProviderSchema } from "@reactive-resume/ai/types";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import { testConnection } from "./ai";
import {
assertCredentialEncryptionConfigured,
decryptCredential,
encryptCredential,
redactEncryptedCredential,
} from "./ai-credentials";
import { resolveAiBaseUrl } from "./ai-url-policy";
type AiProviderRecord = typeof schema.aiProvider.$inferSelect;
export type AiProviderResponse = {
id: string;
label: string;
provider: AIProvider;
model: string;
baseURL: string | null;
enabled: boolean;
testStatus: string;
testError: string | null;
apiKeyPreview: string;
apiKeyFingerprint: string;
lastTestedAt: Date | null;
lastUsedAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
type CreateAiProviderInput = {
userId: string;
label: string;
provider: AIProvider;
model: string;
baseURL?: string | null;
apiKey: string;
};
type UpdateAiProviderInput = {
id: string;
userId: string;
label?: string;
provider?: AIProvider;
model?: string;
baseURL?: string | null;
apiKey?: string;
enabled?: boolean;
};
function toResponse(row: AiProviderRecord): AiProviderResponse {
const provider = aiProviderSchema.parse(row.provider);
const { apiKeyFingerprint, apiKeyPreview } = redactEncryptedCredential({
encryptedApiKey: row.encryptedApiKey,
apiKeySalt: row.apiKeySalt,
apiKeyHash: row.apiKeyHash,
apiKeyPreview: row.apiKeyPreview,
});
return {
id: row.id,
label: row.label,
provider,
model: row.model,
baseURL: row.baseUrl,
enabled: row.enabled,
testStatus: row.testStatus,
testError: row.testError,
apiKeyPreview,
apiKeyFingerprint,
lastTestedAt: row.lastTestedAt,
lastUsedAt: row.lastUsedAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function normalizeBaseUrl(input: { provider: AIProvider; baseURL?: string | null }) {
const trimmed = input.baseURL?.trim() ?? "";
if (!trimmed) return null;
return resolveAiBaseUrl({ provider: input.provider, baseURL: trimmed });
}
function orderByLastUsedAtDescNullsLast() {
return desc(sql<Date>`coalesce(${schema.aiProvider.lastUsedAt}, '1970-01-01T00:00:00.000Z'::timestamptz)`);
}
async function getOwnedProvider(input: { id: string; userId: string }) {
const [provider] = await db
.select()
.from(schema.aiProvider)
.where(and(eq(schema.aiProvider.id, input.id), eq(schema.aiProvider.userId, input.userId)))
.limit(1);
if (!provider) throw new ORPCError("NOT_FOUND");
return provider;
}
export const aiProvidersService = {
list: async (input: { userId: string }) => {
assertCredentialEncryptionConfigured();
const providers = await db
.select()
.from(schema.aiProvider)
.where(eq(schema.aiProvider.userId, input.userId))
.orderBy(orderByLastUsedAtDescNullsLast(), asc(schema.aiProvider.createdAt));
return providers.map(toResponse);
},
getRunnableById: async (input: { id: string; userId: string }) => {
assertCredentialEncryptionConfigured();
const provider = await getOwnedProvider(input);
if (!provider.enabled || provider.testStatus !== "success") {
throw new ORPCError("BAD_REQUEST", { message: "AI provider must be tested and enabled before use." });
}
return {
...toResponse(provider),
apiKey: decryptCredential(provider.encryptedApiKey),
baseURL: provider.baseUrl ?? "",
};
},
getDefaultRunnable: async (input: { userId: string }) => {
assertCredentialEncryptionConfigured();
const [provider] = await db
.select()
.from(schema.aiProvider)
.where(
and(
eq(schema.aiProvider.userId, input.userId),
eq(schema.aiProvider.enabled, true),
eq(schema.aiProvider.testStatus, "success"),
),
)
.orderBy(orderByLastUsedAtDescNullsLast(), asc(schema.aiProvider.createdAt))
.limit(1);
return provider
? {
...toResponse(provider),
apiKey: decryptCredential(provider.encryptedApiKey),
baseURL: provider.baseUrl ?? "",
}
: null;
},
create: async (input: CreateAiProviderInput) => {
assertCredentialEncryptionConfigured();
const encrypted = encryptCredential(input.apiKey.trim());
const [provider] = await db
.insert(schema.aiProvider)
.values({
userId: input.userId,
label: input.label.trim(),
provider: input.provider,
model: input.model.trim(),
baseUrl: normalizeBaseUrl(input),
...encrypted,
})
.returning();
if (!provider) throw new Error("AI_PROVIDER_CREATE_FAILED");
return toResponse(provider);
},
update: async (input: UpdateAiProviderInput) => {
assertCredentialEncryptionConfigured();
const existing = await getOwnedProvider(input);
const provider = input.provider ?? aiProviderSchema.parse(existing.provider);
const nextApiKey = input.apiKey?.trim();
const encrypted = nextApiKey ? encryptCredential(nextApiKey) : {};
const credentialChanged = !!nextApiKey;
const nextBaseUrl =
input.baseURL !== undefined ? normalizeBaseUrl({ provider, baseURL: input.baseURL }) : existing.baseUrl;
const providerChanged = input.provider !== undefined && input.provider !== existing.provider;
const modelChanged = input.model !== undefined && input.model.trim() !== existing.model;
const baseUrlChanged = input.baseURL !== undefined && nextBaseUrl !== existing.baseUrl;
const runtimeChanged = credentialChanged || providerChanged || modelChanged || baseUrlChanged;
if (input.enabled === true && existing.testStatus !== "success" && !runtimeChanged) {
throw new ORPCError("BAD_REQUEST", { message: "AI provider must be tested successfully before enabling." });
}
const [updated] = await db
.update(schema.aiProvider)
.set({
...(input.label !== undefined ? { label: input.label.trim() } : {}),
...(input.provider !== undefined ? { provider: input.provider } : {}),
...(input.model !== undefined ? { model: input.model.trim() } : {}),
...(input.baseURL !== undefined ? { baseUrl: nextBaseUrl } : {}),
...(input.enabled !== undefined && !runtimeChanged ? { enabled: input.enabled } : {}),
...(runtimeChanged ? { enabled: false, testStatus: "untested", lastTestedAt: null, testError: null } : {}),
...encrypted,
})
.where(and(eq(schema.aiProvider.id, input.id), eq(schema.aiProvider.userId, input.userId)))
.returning();
if (!updated) throw new ORPCError("NOT_FOUND");
return toResponse(updated);
},
delete: async (input: { id: string; userId: string }) => {
assertCredentialEncryptionConfigured();
await db
.delete(schema.aiProvider)
.where(and(eq(schema.aiProvider.id, input.id), eq(schema.aiProvider.userId, input.userId)));
},
test: async (input: { id: string; userId: string }) => {
assertCredentialEncryptionConfigured();
const provider = await getOwnedProvider(input);
const parsedProvider = aiProviderSchema.parse(provider.provider);
const apiKey = decryptCredential(provider.encryptedApiKey);
try {
const ok = await testConnection({
provider: parsedProvider,
model: provider.model,
apiKey,
baseURL: provider.baseUrl ?? "",
});
const [updated] = await db
.update(schema.aiProvider)
.set({
enabled: ok,
testStatus: ok ? "success" : "failure",
testError: ok ? null : "The provider test returned an unexpected response.",
lastTestedAt: new Date(),
})
.where(and(eq(schema.aiProvider.id, input.id), eq(schema.aiProvider.userId, input.userId)))
.returning();
if (!updated) throw new ORPCError("NOT_FOUND");
return toResponse(updated);
} catch (error) {
const [updated] = await db
.update(schema.aiProvider)
.set({
enabled: false,
testStatus: "failure",
testError: error instanceof Error ? error.message : "Failed to test provider.",
lastTestedAt: new Date(),
})
.where(and(eq(schema.aiProvider.id, input.id), eq(schema.aiProvider.userId, input.userId)))
.returning();
if (!updated) throw error;
throw error;
}
},
markUsed: async (input: { id: string; userId: string }) => {
await db
.update(schema.aiProvider)
.set({ lastUsedAt: new Date() })
.where(and(eq(schema.aiProvider.id, input.id), eq(schema.aiProvider.userId, input.userId)));
},
};
@@ -0,0 +1,86 @@
import { describe, expect, it, vi } from "vitest";
const envMock = vi.hoisted(() => ({
FLAG_ALLOW_UNSAFE_AI_BASE_URL: false,
}));
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
const { assertFetchablePublicHttpsUrl, resolveAiBaseUrl } = await import("./ai-url-policy");
describe("AI provider base URL policy", () => {
it("allows public HTTPS provider URLs", () => {
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = false;
expect(resolveAiBaseUrl({ provider: "openai", baseURL: "https://api.openai.com/v1" })).toBe(
"https://api.openai.com/v1",
);
});
it("blocks private and non-HTTPS provider URLs by default", () => {
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = false;
expect(() => resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "https://localhost:11434/v1" })).toThrow(
"INVALID_AI_BASE_URL",
);
expect(() => resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "http://example.com/v1" })).toThrow(
"INVALID_AI_BASE_URL",
);
});
it("allows private and non-HTTPS provider URLs when explicitly enabled", () => {
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = true;
expect(resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "http://localhost:11434/v1" })).toBe(
"http://localhost:11434/v1",
);
expect(resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "https://10.0.0.5/v1" })).toBe(
"https://10.0.0.5/v1",
);
});
it("rejects non-HTTP schemes even when unsafe provider URLs are enabled", () => {
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = true;
expect(() => resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "file:///etc/passwd" })).toThrow(
"INVALID_AI_BASE_URL",
);
expect(() => resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "ftp://example.com/v1" })).toThrow(
"INVALID_AI_BASE_URL",
);
});
it("keeps URL-fetch tools public HTTPS only even when unsafe provider URLs are enabled", () => {
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = true;
expect(() => assertFetchablePublicHttpsUrl("https://localhost/internal-job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("http://example.com/job")).toThrow("URL_NOT_FETCHABLE");
expect(assertFetchablePublicHttpsUrl("https://example.com/job")).toBe("https://example.com/job");
});
it("blocks special-use IP literals for URL-fetch tools", () => {
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = true;
expect(() => assertFetchablePublicHttpsUrl("https://100.64.0.1/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://192.0.2.1/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://192.88.99.1/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://198.18.0.1/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://198.51.100.1/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://203.0.113.1/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://224.0.0.1/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://[::]/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://[::ffff:8.8.8.8]/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://[::ffff:0808:0808]/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://[64:ff9b::1]/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://[100::1]/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://[100:0:0:1::1]/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://[2001::1]/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://[2001:100::1]/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://[2001:2::1]/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://[2001:10::1]/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://[ff02::1]/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://[2001:db8::1]/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://[3fff::1]/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://[5f00::1]/job")).toThrow("URL_NOT_FETCHABLE");
});
});

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