diff --git a/package.json b/package.json index 2fc71bf..f852078 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "preview": "vite preview", "type-check": "tsc --noEmit", "tauri": "tauri", + "pretauri:dev": "kill -9 $(lsof -ti:1420 -sTCP:LISTEN) 2>/dev/null || true", "tauri:dev": "tauri dev --config src-tauri/tauri.conf.dev.json", "tauri:build": "tauri build", "generate-test-data": "tsx scripts/generate-test-data.ts --employees", diff --git a/src-tauri/src/commands/recruiting.rs b/src-tauri/src/commands/recruiting.rs index ffa149c..a5815c4 100644 --- a/src-tauri/src/commands/recruiting.rs +++ b/src-tauri/src/commands/recruiting.rs @@ -1,7 +1,10 @@ //! Tauri command bridge for the `recruiting` module. use crate::db::Database; -use crate::recruiting::{self, RecruitingSearch}; +use crate::keyring::{self, KeyringError}; +use crate::recruiting::adapters::exa::{self, ExaError, ExaSearchResponse}; +use crate::recruiting::{self, RecruitingSearch, EXA_PROVIDER_ID}; +use serde::Serialize; /// Create a new recruiting search. Returns the generated row ID. #[tauri::command] @@ -24,3 +27,96 @@ pub(crate) async fn recruiting_list_searches( .await .map_err(|e| e.to_string()) } + +/// Discriminated error type for `recruiting_search_exa`. The frontend +/// pattern-matches on `kind`: +/// - `MissingKey` → render the "Recruiting needs your Exa API key" banner. +/// - `InvalidKey` → same banner; suffix that Exa rejected the stored key. +/// - `RateLimit` → soft toast with the Exa-supplied message. +/// - `Network` → soft toast ("couldn't reach Exa"). +/// - `ExaApi` → surface status + body inline (debugging-friendly). +/// - `Internal` → unexpected path: Keychain read failure, response +/// parse failure, etc. +#[derive(Debug, Serialize)] +#[serde(tag = "kind")] +pub enum RecruitingSearchError { + MissingKey, + InvalidKey, + RateLimit { message: String }, + Network { message: String }, + ExaApi { status: u16, body: String }, + Internal { message: String }, +} + +impl From for RecruitingSearchError { + fn from(err: ExaError) -> Self { + match err { + ExaError::Network(e) => RecruitingSearchError::Network { + message: e.to_string(), + }, + ExaError::InvalidKey => RecruitingSearchError::InvalidKey, + ExaError::RateLimit { message } => RecruitingSearchError::RateLimit { message }, + ExaError::Api { status, body } => RecruitingSearchError::ExaApi { status, body }, + ExaError::InvalidResponse(message) => RecruitingSearchError::Internal { message }, + } + } +} + +// ============================================================================ +// Exa API key management — Recruiting-namespaced wrappers +// ============================================================================ +// +// These deliberately bypass `commands::api_keys::*_provider_api_key` because +// those commands gate on the LLM-provider registry (`providers::get_provider`) +// — Exa is a data source, not an LLM, so it isn't (and shouldn't be) registered +// there. The gate is a real safety net for the LLM-key path (catches typos +// like "anthropic-key"), so punching a hole in it would be wrong; better to +// keep the LLM and data-source key surfaces independent. +// +// Storage is shared with the LLM keys (same Keychain service, account +// `exa_api_key` per `keychain_account_for_provider("exa")`), so a key written +// here is readable by `recruiting_search_exa` and vice-versa. + +/// Check whether an Exa API key is stored in the Keychain. +#[tauri::command] +pub(crate) fn recruiting_has_exa_key() -> Result { + Ok(keyring::has_provider_api_key(EXA_PROVIDER_ID)) +} + +/// Store the Exa API key in the Keychain. Format validation lives client-side +/// (UUID regex in `ExaKeyInput.tsx`); the Rust side is intentionally permissive +/// to keep the recruiting command surface small. If Exa changes its key format, +/// nothing here needs to change. +#[tauri::command] +pub(crate) fn recruiting_store_exa_key(api_key: String) -> Result<(), String> { + keyring::store_provider_api_key(EXA_PROVIDER_ID, &api_key).map_err(|e| e.to_string()) +} + +/// Delete the Exa API key from the Keychain. Idempotent — returns Ok even +/// when no key was stored (matches `keyring::delete_provider_api_key`). +#[tauri::command] +pub(crate) fn recruiting_delete_exa_key() -> Result<(), String> { + keyring::delete_provider_api_key(EXA_PROVIDER_ID).map_err(|e| e.to_string()) +} + +/// Execute an Exa search using the user's BYOK key from Keychain. +/// +/// Returns `MissingKey` when no key is stored — the frontend uses this as +/// the signal to render the "add your Exa key in Settings" banner instead +/// of treating it as an error. +#[tauri::command] +pub(crate) async fn recruiting_search_exa( + query: String, +) -> Result { + let api_key = match keyring::get_provider_api_key(EXA_PROVIDER_ID) { + Ok(key) => key, + Err(KeyringError::NotFound) => return Err(RecruitingSearchError::MissingKey), + Err(other) => { + return Err(RecruitingSearchError::Internal { + message: other.to_string(), + }) + } + }; + + exa::search(&query, &api_key).await.map_err(Into::into) +} diff --git a/src-tauri/src/keyring.rs b/src-tauri/src/keyring.rs index 4b02228..39e4321 100644 --- a/src-tauri/src/keyring.rs +++ b/src-tauri/src/keyring.rs @@ -268,5 +268,6 @@ mod tests { assert_eq!(keychain_account_for_provider("anthropic"), "anthropic_api_key"); assert_eq!(keychain_account_for_provider("openai"), "openai_api_key"); assert_eq!(keychain_account_for_provider("gemini"), "gemini_api_key"); + assert_eq!(keychain_account_for_provider("exa"), "exa_api_key"); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a15e340..d7721c8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -229,9 +229,13 @@ pub fn run() { commands::documents::get_document_folder, commands::documents::rescan_documents, commands::documents::get_document_stats, - // Recruiting (Sourcerer module) — FHR-71 + // Recruiting (Sourcerer module) — FHR-71, FHR-72 commands::recruiting::recruiting_create_search, commands::recruiting::recruiting_list_searches, + commands::recruiting::recruiting_search_exa, + commands::recruiting::recruiting_has_exa_key, + commands::recruiting::recruiting_store_exa_key, + commands::recruiting::recruiting_delete_exa_key, ]) .setup(|app| { // Register updater plugin for auto-updates via GitHub Releases diff --git a/src-tauri/src/recruiting/adapters/exa.rs b/src-tauri/src/recruiting/adapters/exa.rs new file mode 100644 index 0000000..d00fd1c --- /dev/null +++ b/src-tauri/src/recruiting/adapters/exa.rs @@ -0,0 +1,216 @@ +//! Exa search adapter for the recruiting module. +//! +//! Wraps Exa's `/search` endpoint (). Exa is the recruiting +//! module's primary candidate-discovery source; the team-seeded `findSimilar` +//! variant (roadmap S1.1) and per-hit `getContents` (S1.1) will extend the +//! same types defined here. +//! +//! Key conventions: +//! - The API key is read from the macOS Keychain via +//! `keyring::get_provider_api_key(crate::recruiting::EXA_PROVIDER_ID)` +//! at the command boundary; this module takes the key as a `&str` +//! argument so unit tests never touch OS-level storage. +//! - Wire format matches Exa verbatim via `#[serde(rename_all = "camelCase")]`. +//! - All hit fields except `id` and `url` are `Option` because Exa +//! populates them inconsistently depending on search params (highlights, +//! summary, contents are opt-in). + +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +const EXA_SEARCH_URL: &str = "https://api.exa.ai/search"; +const DEFAULT_NUM_RESULTS: u32 = 10; +/// Exa search mode: "neural" (semantic), "keyword" (lexical), "auto" (Exa picks). +/// "auto" is the right default for v1 — it lets Exa decide and removes a +/// premature tuning knob from the UX. +const DEFAULT_SEARCH_TYPE: &str = "auto"; + +// ============================================================================ +// Wire types — request +// ============================================================================ + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ExaSearchRequest<'a> { + query: &'a str, + num_results: u32, + #[serde(rename = "type")] + search_type: &'a str, +} + +// ============================================================================ +// Wire types — response +// ============================================================================ + +/// Top-level response from Exa's `/search` endpoint. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExaSearchResponse { + pub results: Vec, + /// Exa's rewrite of the user query (only present for `neural` / `auto`). + pub autoprompt_string: Option, + /// Server-side request ID — quote this when filing Exa support tickets. + pub request_id: Option, +} + +/// One search hit. `id` and `url` are guaranteed on every result; every +/// other field is opt-in depending on Exa search params. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExaHit { + pub id: String, + pub url: String, + pub title: Option, + /// Relevance score in `[0.0, 1.0]`. `f32` is enough precision — Exa + /// returns ~2 decimal places of meaningful signal. + pub score: Option, + /// Publication date as a raw string. Kept as `String` (not parsed) because + /// Exa returns mixed formats (ISO 8601, `YYYY-MM-DD`, sometimes null) and + /// the frontend just renders it. + pub published_date: Option, + pub author: Option, + /// Full document text — only populated when fetched via `getContents`. + pub text: Option, + /// Matched snippets — only when `highlights=true` was requested. + pub highlights: Option>, + /// LLM-generated summary — only when `summary=true` was requested. + pub summary: Option, +} + +// ============================================================================ +// Errors +// ============================================================================ + +#[derive(Debug, Error)] +pub enum ExaError { + #[error("network error: {0}")] + Network(#[from] reqwest::Error), + /// HTTP 401 — Exa rejected the key. Distinct from `MissingKey` at the + /// command layer, which means "no key was stored at all." + #[error("exa rejected the api key")] + InvalidKey, + /// HTTP 429 — quota exhausted or rate-limit window hit. + #[error("rate limited: {message}")] + RateLimit { message: String }, + /// Any other non-2xx response. + #[error("exa api returned {status}: {body}")] + Api { status: u16, body: String }, + /// Body was 2xx but didn't deserialize into `ExaSearchResponse`. + #[error("failed to parse exa response: {0}")] + InvalidResponse(String), +} + +// ============================================================================ +// Search +// ============================================================================ + +/// Execute a search against Exa's `/search` endpoint. +/// +/// The caller fetches `api_key` from the Keychain (typically via +/// `keyring::get_provider_api_key(EXA_PROVIDER_ID)`) and is responsible for +/// translating `KeyringError::NotFound` into a higher-level "missing key" +/// condition — this function only sees the key as an opaque `&str`. +pub async fn search(query: &str, api_key: &str) -> Result { + let body = ExaSearchRequest { + query, + num_results: DEFAULT_NUM_RESULTS, + search_type: DEFAULT_SEARCH_TYPE, + }; + + let response = Client::new() + .post(EXA_SEARCH_URL) + .header("x-api-key", api_key) + .header("content-type", "application/json") + .json(&body) + .send() + .await?; + + let status = response.status(); + if status.is_success() { + let text = response.text().await?; + return serde_json::from_str(&text) + .map_err(|e| ExaError::InvalidResponse(e.to_string())); + } + + let status_code = status.as_u16(); + let body = response.text().await.unwrap_or_default(); + Err(match status_code { + 401 => ExaError::InvalidKey, + 429 => ExaError::RateLimit { message: body }, + _ => ExaError::Api { + status: status_code, + body, + }, + }) +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + /// Realistic Exa `/search` response — trimmed from a live call, covers + /// the common-case hit (id, url, title, score, publishedDate, author). + const FIXTURE_FULL: &str = r#"{ + "results": [ + { + "id": "https://example.com/jane-doe", + "url": "https://example.com/jane-doe", + "title": "Senior Rust Engineer at Acme", + "score": 0.87, + "publishedDate": "2024-03-15", + "author": "Jane Doe" + } + ], + "autopromptString": "rust engineers berlin", + "requestId": "req_abc123" + }"#; + + /// Sparse fixture — only the fields Exa guarantees on every hit. Locks + /// down `Option` handling for the long tail of partial responses. + const FIXTURE_SPARSE: &str = r#"{ + "results": [ + {"id": "abc", "url": "https://example.org/post"} + ] + }"#; + + #[test] + fn deserializes_full_response() { + let parsed: ExaSearchResponse = + serde_json::from_str(FIXTURE_FULL).expect("parse full fixture"); + assert_eq!(parsed.results.len(), 1); + let hit = &parsed.results[0]; + assert_eq!(hit.url, "https://example.com/jane-doe"); + assert_eq!(hit.title.as_deref(), Some("Senior Rust Engineer at Acme")); + assert_eq!(hit.score, Some(0.87)); + assert_eq!(hit.published_date.as_deref(), Some("2024-03-15")); + assert_eq!(hit.author.as_deref(), Some("Jane Doe")); + assert!(hit.text.is_none()); + assert!(hit.highlights.is_none()); + assert!(hit.summary.is_none()); + assert_eq!( + parsed.autoprompt_string.as_deref(), + Some("rust engineers berlin") + ); + assert_eq!(parsed.request_id.as_deref(), Some("req_abc123")); + } + + #[test] + fn deserializes_sparse_response_with_only_required_fields() { + let parsed: ExaSearchResponse = + serde_json::from_str(FIXTURE_SPARSE).expect("parse sparse fixture"); + assert_eq!(parsed.results.len(), 1); + let hit = &parsed.results[0]; + assert_eq!(hit.id, "abc"); + assert_eq!(hit.url, "https://example.org/post"); + assert!(hit.title.is_none(), "title is Option"); + assert!(hit.score.is_none(), "score is Option"); + assert!(hit.published_date.is_none(), "publishedDate is Option"); + assert!(parsed.autoprompt_string.is_none(), "autoprompt is Option"); + assert!(parsed.request_id.is_none(), "requestId is Option"); + } +} diff --git a/src-tauri/src/recruiting/adapters/mod.rs b/src-tauri/src/recruiting/adapters/mod.rs new file mode 100644 index 0000000..28fa70b --- /dev/null +++ b/src-tauri/src/recruiting/adapters/mod.rs @@ -0,0 +1,10 @@ +//! Data-source adapters for the recruiting module. +//! +//! Each adapter wraps one external source (Exa, and later Hunter / GitHub / +//! X — see roadmap phases S1–S3) with a consistent shape: take a query + +//! API key, return a typed response or a typed error. Adapters never read +//! the Keychain themselves; the command layer fetches the key and passes +//! it in. That separation keeps adapters unit-testable without OS-level +//! Keychain access. + +pub mod exa; diff --git a/src-tauri/src/recruiting/mod.rs b/src-tauri/src/recruiting/mod.rs index 988641e..8665954 100644 --- a/src-tauri/src/recruiting/mod.rs +++ b/src-tauri/src/recruiting/mod.rs @@ -12,10 +12,17 @@ //! `chat`, `employees`, `documents` are organized). //! - The migration that backs this module is `013_recruiting.sql`. +pub mod adapters; + use crate::db::DbPool; use serde::{Deserialize, Serialize}; use uuid::Uuid; +/// Keyring provider ID for the Exa search API key. Used by the recruiting +/// search command to look up the user's BYOK Exa key: +/// `keyring::get_provider_api_key(EXA_PROVIDER_ID)`. +pub const EXA_PROVIDER_ID: &str = "exa"; + #[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize)] pub struct RecruitingSearch { pub id: String, diff --git a/src/App.tsx b/src/App.tsx index fd92a0e..a271816 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -349,7 +349,7 @@ function MainAppContent() { > {showRecruiting ? ( - + setIsSettingsOpen(true)} /> ) : ( diff --git a/src/components/recruiting/RecruitingView.tsx b/src/components/recruiting/RecruitingView.tsx index 14bd63a..483a05d 100644 --- a/src/components/recruiting/RecruitingView.tsx +++ b/src/components/recruiting/RecruitingView.tsx @@ -1,14 +1,139 @@ // People Partner — Recruit module (talent sourcing) // -// S0.1 skeleton (FHR-70): an empty Recruiting view, rendered in the main -// content area when the `recruiting` tab is active and gated behind -// RECRUITING_ENABLED. This is the first visible seam for the module — S0.2 -// stands up the Rust `recruiting` module + first command, and S0.3 round-trips -// a live Exa search into this view. Intentionally empty until then. +// FHR-72 (S0.3): round-trip one live Exa search to the UI. +// - Single-line input → submit → render raw Exa hits as cards. +// - BYOK: the Tauri command reads the Exa key from macOS Keychain. +// - Missing/invalid-key path renders the inline banner; the call-site is +// responsible for branching on the result's `error.kind`. -export function RecruitingView() { +import { useState } from 'react'; +import { recruitingSearchExa } from '../../lib/tauri-commands'; +import type { + ExaHit, + ExaSearchResponse, + RecruitingSearchError, +} from '../../lib/types'; + +type ViewState = + | { kind: 'idle' } + | { kind: 'loading' } + | { kind: 'success'; data: ExaSearchResponse } + | { kind: 'error'; error: RecruitingSearchError }; + +interface RecruitingViewProps { + /** Open the Settings panel — passed in by App.tsx so the missing-key + * banner can deep-link to the Recruiting section. Optional so the + * component remains usable in isolated previews/stories. */ + onOpenSettings?: () => void; +} + +export function RecruitingView({ onOpenSettings }: RecruitingViewProps = {}) { + const [query, setQuery] = useState(''); + const [view, setView] = useState({ kind: 'idle' }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = query.trim(); + if (!trimmed || view.kind === 'loading') return; + setView({ kind: 'loading' }); + const result = await recruitingSearchExa(trimmed); + setView( + result.ok + ? { kind: 'success', data: result.data } + : { kind: 'error', error: result.error }, + ); + }; + + // Render helper — discriminated narrowing reads cleaner as a function than + // a chain of ternaries, and lets TS narrow `view.error.kind` in the + // missing-key branch without explicit casts. + function renderContent() { + if (view.kind === 'idle') return ; + if (view.kind === 'loading') return ; + if (view.kind === 'success') return ; + // view.kind === 'error' + if (view.error.kind === 'MissingKey' || view.error.kind === 'InvalidKey') { + return ( + + ); + } + return ; + } + + return ( +
+
+
+ setQuery(e.target.value)} + placeholder="Search for candidates…" + className="flex-1 px-3 py-2 border border-stone-300 rounded-md text-sm placeholder-stone-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500" + disabled={view.kind === 'loading'} + autoFocus + aria-label="Recruiting search query" + /> + +
+
+ +
{renderContent()}
+
+ ); +} + +// ============================================================================ +// Sub-components +// ============================================================================ + +function MissingKeyBanner({ + kind, + onOpenSettings, +}: { + kind: 'MissingKey' | 'InvalidKey'; + onOpenSettings?: () => void; +}) { + const heading = + kind === 'MissingKey' + ? 'Recruiting needs your Exa API key' + : 'Your Exa API key was rejected'; + const detail = + kind === 'MissingKey' + ? 'Recruiting search uses Exa to discover candidates. Add your key in Settings to start searching.' + : 'Exa returned 401 for the stored key. Update it in Settings and try again.'; + + return ( +
+
+

{heading}

+

{detail}

+ {onOpenSettings && ( + + )} +
+
+ ); +} + +function EmptyState() { return ( -
+
-

Recruit

+

+ Recruit +

Context-aware talent sourcing, seeded by the employee data you already - have. This module is under construction — nothing to show here yet. + have. Type a query above to start.

); } +function LoadingState() { + return ( +
+

Searching Exa…

+
+ ); +} + +// Narrowed: this only receives the soft-error variants. Missing/invalid key +// is handled separately by `MissingKeyBanner`. +type SoftError = Exclude< + RecruitingSearchError, + { kind: 'MissingKey' } | { kind: 'InvalidKey' } +>; + +function ErrorState({ error }: { error: SoftError }) { + let heading: string; + let message: string; + switch (error.kind) { + case 'RateLimit': + heading = 'Exa rate limit hit'; + message = error.message; + break; + case 'Network': + heading = "Couldn't reach Exa"; + message = error.message; + break; + case 'ExaApi': + heading = 'Exa returned an error'; + message = `${error.status}: ${error.body}`; + break; + case 'Internal': + heading = 'Unexpected error'; + message = error.message; + break; + } + + return ( +
+
+

{heading}

+

+ {message} +

+
+
+ ); +} + +function ResultsList({ data }: { data: ExaSearchResponse }) { + if (data.results.length === 0) { + return ( +
+

No results for that query.

+
+ ); + } + + return ( +
+ {data.autopromptString && ( +

+ Exa rewrote your query:{' '} + “{data.autopromptString}” +

+ )} +
    + {data.results.map((hit) => ( + + ))} +
+
+ ); +} + +function HitCard({ hit }: { hit: ExaHit }) { + return ( +
  • + +

    + {hit.title || hit.url} +

    +

    {hit.url}

    +
    +
    + {hit.author && by {hit.author}} + {hit.publishedDate && {hit.publishedDate}} + {typeof hit.score === 'number' && ( + score {hit.score.toFixed(2)} + )} +
    + {hit.summary && ( +

    {hit.summary}

    + )} +
  • + ); +} + export default RecruitingView; diff --git a/src/components/settings/ExaKeyInput.tsx b/src/components/settings/ExaKeyInput.tsx new file mode 100644 index 0000000..f7def5a --- /dev/null +++ b/src/components/settings/ExaKeyInput.tsx @@ -0,0 +1,261 @@ +// People Partner — Exa API key input (Recruiting / data-source key, FHR-72). +// +// Intentionally separate from `ApiKeyInput` because Exa is a *data source*, +// not an LLM provider — `ApiKeyInput` is coupled to `PROVIDER_META` (model +// name, console URL, setup steps) which doesn't apply here. This keeps the +// LLM-provider section and the recruiting data-source section conceptually +// distinct in Settings, and leaves room for sibling adapters (Hunter, GitHub, +// X) to follow the same minimal pattern when S3.x lands. +// +// Storage layer is shared: this uses the existing generic +// `storeProviderApiKey('exa', ...)` / `hasProviderApiKey('exa')` / +// `deleteProviderApiKey('exa')` commands, which write under Keychain account +// `exa_api_key` (locked down by a unit test in `src-tauri/src/keyring.rs`). + +import { useState, useEffect, useCallback } from 'react'; +import { + storeExaApiKey, + hasExaApiKey, + deleteExaApiKey, +} from '../../lib/tauri-commands'; + +// Exa keys are UUIDs (verified from Exa's docs). A loose UUID regex catches +// fat-finger paste errors without being so strict it rejects valid keys. +const EXA_KEY_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +type Status = 'idle' | 'saving' | 'saved' | 'error'; + +export function ExaKeyInput() { + const [apiKey, setApiKey] = useState(''); + const [status, setStatus] = useState('idle'); + const [hasExisting, setHasExisting] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + + useEffect(() => { + hasExaApiKey() + .then(setHasExisting) + .catch(() => setHasExisting(false)); + }, []); + + const isValid = EXA_KEY_PATTERN.test(apiKey.trim()); + + const handleSave = useCallback(async () => { + if (!isValid || status === 'saving') return; + setStatus('saving'); + setErrorMessage(''); + try { + await storeExaApiKey(apiKey.trim()); + setStatus('saved'); + setHasExisting(true); + setApiKey(''); + setTimeout(() => setStatus('idle'), 1500); + } catch (err) { + setStatus('error'); + setErrorMessage( + err instanceof Error ? err.message : 'Failed to save Exa key', + ); + } + }, [apiKey, isValid, status]); + + const handleDelete = useCallback(async () => { + try { + await deleteExaApiKey(); + setHasExisting(false); + setStatus('idle'); + setErrorMessage(''); + } catch (err) { + setErrorMessage( + err instanceof Error ? err.message : 'Failed to remove Exa key', + ); + } + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && isValid && status === 'idle') { + handleSave(); + } + }; + + if (hasExisting && status !== 'saved') { + return ( +
    +
    +
    + + + +
    +
    +

    + Exa API Key Configured +

    +

    + Stored securely in your system keychain +

    +
    +
    + +
    + ); + } + + const borderColor = errorMessage + ? 'border-red-300 focus-within:border-red-400 focus-within:ring-red-100' + : status === 'saved' || (apiKey && isValid) + ? 'border-green-300 focus-within:border-green-400 focus-within:ring-green-100' + : 'border-stone-200 focus-within:border-primary-300 focus-within:ring-primary-100'; + + return ( +
    +
    +

    Exa API Key

    +

    + Get your key from{' '} + + exa.ai + + . Used for candidate discovery in the Recruit tab. +

    +
    + +
    +
    + + + +
    + + { + setApiKey(e.target.value); + setErrorMessage(''); + if (status !== 'saving') setStatus('idle'); + }} + onKeyDown={handleKeyDown} + placeholder="00000000-0000-0000-0000-000000000000" + disabled={status === 'saving'} + aria-label="Exa API key" + className="flex-1 bg-transparent text-stone-700 placeholder:text-stone-400 focus:outline-none font-mono text-sm" + /> + + {apiKey && ( +
    + {isValid ? ( + + + + ) : ( + + + + )} +
    + )} + + +
    + + {errorMessage && ( +

    + {errorMessage} +

    + )} + + {apiKey && !isValid && !errorMessage && ( +

    + Exa keys are UUIDs — they look like{' '} + + xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + + . +

    + )} +
    + ); +} + +export default ExaKeyInput; diff --git a/src/components/settings/SettingsPanel.tsx b/src/components/settings/SettingsPanel.tsx index 5671d4e..bd2da3e 100644 --- a/src/components/settings/SettingsPanel.tsx +++ b/src/components/settings/SettingsPanel.tsx @@ -9,6 +9,7 @@ import { useState, useEffect, useCallback } from 'react'; import { Modal } from '../shared/Modal'; import { Button } from '../ui/Button'; import { ApiKeyInput } from './ApiKeyInput'; +import { ExaKeyInput } from './ExaKeyInput'; import { ProviderPicker } from './ProviderPicker'; import { CompanySetup } from '../company/CompanySetup'; import { BackupRestore } from './BackupRestore'; @@ -25,6 +26,7 @@ import { import { useTrial } from '../../contexts/TrialContext'; import { UPGRADE_URL } from '../../lib/constants'; import { PROVIDER_ORDER } from '../../lib/provider-config'; +import { RECRUITING_ENABLED } from '../../lib/featureFlags'; interface SettingsPanelProps { /** Whether the panel is open */ @@ -347,6 +349,18 @@ export function SettingsPanel({ isOpen, onClose }: SettingsPanelProps) {
    + {/* Recruiting Section (FHR-72) — gated on RECRUITING_ENABLED so the + section tree-shakes out when the module is off. Keeps Settings + uncluttered until the Recruit tab is enabled. */} + {RECRUITING_ENABLED && ( +
    +

    + Recruiting +

    + +
    + )} + {/* Documents Section (V3.0) */}

    diff --git a/src/lib/tauri-commands.ts b/src/lib/tauri-commands.ts index d126ae9..63edc4d 100644 --- a/src/lib/tauri-commands.ts +++ b/src/lib/tauri-commands.ts @@ -30,6 +30,10 @@ import type { DocumentStats, // FHR-71 - Recruiting (Sourcerer module) RecruitingSearch, + // FHR-72 - Recruiting Exa search round-trip + ExaSearchResponse, + RecruitingSearchError, + RecruitingSearchResult, } from './types'; /** @@ -2131,3 +2135,58 @@ export async function createRecruitingSearch( export async function listRecruitingSearches(): Promise { return invoke('recruiting_list_searches'); } + +/** + * Execute an Exa search using the user's BYOK Exa key from Keychain (FHR-72). + * + * Returns a discriminated {@link RecruitingSearchResult} instead of throwing. + * Call-sites typically need to branch on the error `kind` (`MissingKey` + * triggers the Settings banner; other kinds are soft toasts), so the + * Result shape is more ergonomic than try/catch here. + * + * ```ts + * const result = await recruitingSearchExa('rust engineers berlin'); + * if (!result.ok) { + * if (result.error.kind === 'MissingKey') showBanner(); + * else showToast(result.error); + * } else { + * render(result.data.results); + * } + * ``` + */ +export async function recruitingSearchExa( + query: string, +): Promise { + try { + const data = await invoke('recruiting_search_exa', { query }); + return { ok: true, data }; + } catch (raw) { + // Tauri serializes Rust `Err(E: Serialize)` as JSON. If raw has `.kind`, + // it's our RecruitingSearchError; otherwise wrap unknowns as Internal. + if (typeof raw === 'object' && raw !== null && 'kind' in raw) { + return { ok: false, error: raw as RecruitingSearchError }; + } + return { ok: false, error: { kind: 'Internal', message: String(raw) } }; + } +} + +/** + * Check whether an Exa API key is stored in macOS Keychain. + * + * Recruit-namespaced because the generic {@link hasProviderApiKey} gates on + * the LLM-provider registry (Exa is a data source, not an LLM). This bypasses + * that gate while sharing the same Keychain storage account (`exa_api_key`). + */ +export async function hasExaApiKey(): Promise { + return invoke('recruiting_has_exa_key'); +} + +/** Store the Exa API key in macOS Keychain. Format-validate client-side first. */ +export async function storeExaApiKey(apiKey: string): Promise { + return invoke('recruiting_store_exa_key', { apiKey }); +} + +/** Remove the Exa API key from macOS Keychain. Idempotent. */ +export async function deleteExaApiKey(): Promise { + return invoke('recruiting_delete_exa_key'); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 3f8d4cf..2a9ea26 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -672,3 +672,64 @@ export interface RecruitingSearch { created_at: string; updated_at: string; } + +// ============================================================================ +// Recruiting (Sourcerer module) — FHR-72 (S0.3): Exa search round-trip +// ============================================================================ + +/** + * One Exa search hit. `id` and `url` are always present; everything else is + * opt-in depending on Exa search params (Exa returns them inconsistently). + * + * Field names are camelCase to match Exa's wire format — the Rust adapter + * uses `#[serde(rename_all = "camelCase")]` so the bytes cross the Tauri + * IPC boundary unchanged. + */ +export interface ExaHit { + id: string; + url: string; + title: string | null; + /** Relevance score in [0.0, 1.0] — higher is better. */ + score: number | null; + /** Raw publication date. Format varies (ISO 8601 / YYYY-MM-DD / null). */ + publishedDate: string | null; + author: string | null; + /** Full text — only populated when fetched via getContents (S1.1+). */ + text: string | null; + /** Matched snippets — only when highlights=true was requested. */ + highlights: string[] | null; + /** LLM-generated summary — only when summary=true was requested. */ + summary: string | null; +} + +/** Top-level response from Exa's /search endpoint. */ +export interface ExaSearchResponse { + results: ExaHit[]; + /** Exa's rewrite of the user query — present for neural/auto search modes. */ + autopromptString: string | null; + /** Exa request ID — quote this when filing support tickets. */ + requestId: string | null; +} + +/** + * Discriminated error type for {@link recruitingSearchExa}. Pattern-match + * on `kind`: + * - `MissingKey` → render the "Recruiting needs your Exa API key" banner. + * - `InvalidKey` → same banner; Exa rejected the stored key. + * - `RateLimit` → soft toast with the Exa-supplied message. + * - `Network` → soft toast ("couldn't reach Exa"). + * - `ExaApi` → surface status + body for debugging. + * - `Internal` → unexpected (Keychain read failed, parse failed, ...). + */ +export type RecruitingSearchError = + | { kind: 'MissingKey' } + | { kind: 'InvalidKey' } + | { kind: 'RateLimit'; message: string } + | { kind: 'Network'; message: string } + | { kind: 'ExaApi'; status: number; body: string } + | { kind: 'Internal'; message: string }; + +/** Result of {@link recruitingSearchExa} — pattern-match on `ok`. */ +export type RecruitingSearchResult = + | { ok: true; data: ExaSearchResponse } + | { ok: false; error: RecruitingSearchError };