Public Access
1
0

feat: add deep research planning and postal batch runs

Add a dedicated Deep Research view with postal-area preview overlays, batch execution, and bundled map results.

Also add postal dataset import tooling and fix local API networking and research insert issues needed to support the new workflow.
This commit is contained in:
pguerrerox
2026-04-05 18:05:04 +00:00
parent a1ba5ee093
commit cc00a439bf
29 changed files with 1860 additions and 82 deletions
+26 -19
View File
@@ -1,7 +1,8 @@
import { type ReactElement, type SVGProps, useEffect, useState } from 'react';
import { APIProvider } from '@vis.gl/react-google-maps';
import { AlertCircle, Briefcase, LogIn, ShieldAlert, UserPlus } from 'lucide-react';
import { Layout } from './components/Layout';
import { DeepResearchView } from './components/DeepResearchView';
import { Layout, type AppTab } from './components/Layout';
import { SearchSetup } from './components/SearchSetup';
import { Dashboard } from './components/Dashboard';
import { MapView } from './components/MapView';
@@ -15,7 +16,7 @@ const hasValidMapsKey = Boolean(GOOGLE_MAPS_API_KEY) && GOOGLE_MAPS_API_KEY !==
export default function App() {
const [user, setUser] = useState<AppUser | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<'setup' | 'dashboard' | 'map'>('setup');
const [activeTab, setActiveTab] = useState<AppTab>('setup');
const [selectedJobIds, setSelectedJobIds] = useState<string[]>([]);
const [authError, setAuthError] = useState<string | null>(null);
const [authNotice, setAuthNotice] = useState<string | null>(null);
@@ -79,6 +80,11 @@ export default function App() {
setSelectedJobIds([]);
};
const handleShowJobIdsOnMap = (jobIds: string[]) => {
setSelectedJobIds(jobIds);
setActiveTab('map');
};
const handleLogin = async () => {
setAuthError(null);
setAuthNotice(null);
@@ -146,23 +152,6 @@ export default function App() {
);
}
if (!hasValidMapsKey) {
return (
<ConfigScreen
icon={<MapIcon className="h-8 w-8" />}
title="Google Maps API Key Required"
description="Add a browser key for map rendering and a server key for the local search API."
steps={[
'Create a Google Maps Platform API key for the browser app.',
'Set VITE_GOOGLE_MAPS_PLATFORM_KEY locally for the frontend.',
'Set GOOGLE_MAPS_SERVER_KEY for the local API server.',
'Enable Geocoding API and Places API in Google Cloud.',
]}
footer="The app will start once the browser key is available."
/>
);
}
if (!user) {
return (
<div className="flex h-screen flex-col items-center justify-center bg-stone-50 p-4">
@@ -291,6 +280,23 @@ export default function App() {
);
}
if (!hasValidMapsKey) {
return (
<ConfigScreen
icon={<MapIcon className="h-8 w-8" />}
title="Google Maps API Key Required"
description="Add a browser key for map rendering and a server key for the local search API."
steps={[
'Create a Google Maps Platform API key for the browser app.',
'Set VITE_GOOGLE_MAPS_PLATFORM_KEY locally for the frontend.',
'Set GOOGLE_MAPS_SERVER_KEY for the local API server.',
'Enable Geocoding API and Places API in Google Cloud.',
]}
footer="You can create a local account without the maps key, but the workspace needs it before loading."
/>
);
}
return (
<APIProvider apiKey={GOOGLE_MAPS_API_KEY} version="weekly">
<Layout
@@ -309,6 +315,7 @@ export default function App() {
onClearSelection={handleClearSelectedJobs}
/>
)}
{activeTab === 'deepResearch' && <DeepResearchView onShowBatchOnMap={handleShowJobIdsOnMap} />}
{activeTab === 'dashboard' && <Dashboard user={user} />}
{activeTab === 'map' && <MapView user={user} jobIds={selectedJobIds} />}
</Layout>
+121
View File
@@ -0,0 +1,121 @@
import React, { useEffect, useMemo } from 'react';
import { AdvancedMarker, Map, Pin, useMap } from '@vis.gl/react-google-maps';
import type { DeepResearchPreview } from '../../shared/types';
interface DeepResearchPreviewMapProps {
pin: google.maps.LatLngLiteral | null;
preview: DeepResearchPreview | null;
onPinChange: (nextPin: google.maps.LatLngLiteral) => void;
}
const ringPalette = ['#059669', '#10b981', '#34d399', '#6ee7b7', '#a7f3d0', '#d1fae5'];
export function DeepResearchPreviewMap({ pin, preview, onPinChange }: DeepResearchPreviewMapProps) {
const defaultCenter = useMemo(() => {
if (pin) {
return pin;
}
const baseArea = preview?.baseArea;
if (baseArea?.centroidLat != null && baseArea?.centroidLng != null) {
return { lat: baseArea.centroidLat, lng: baseArea.centroidLng };
}
return { lat: 39.5, lng: -98.35 };
}, [pin, preview]);
return (
<div className="relative h-[440px] overflow-hidden rounded-3xl border border-stone-200 bg-stone-100 shadow-sm">
<Map
defaultCenter={defaultCenter}
defaultZoom={5}
mapId="DEMO_MAP_ID"
{...({ internalUsageAttributionIds: ['gmp_mcp_codeassist_v1_aistudio'] } as Record<string, unknown>)}
style={{ width: '100%', height: '100%' }}
gestureHandling="greedy"
onClick={(event) => {
const latLng = event.detail.latLng;
if (latLng) {
onPinChange(latLng);
}
}}
>
<PreviewOverlay overlay={preview?.overlay ?? null} pin={pin} preview={preview} />
{pin && (
<AdvancedMarker position={pin}>
<Pin background="#059669" glyphColor="#fff" borderColor="#064e3b" />
</AdvancedMarker>
)}
</Map>
{!pin && (
<div className="pointer-events-none absolute inset-x-6 top-6 rounded-2xl border border-white/20 bg-white/90 p-4 text-sm text-stone-600 shadow-lg backdrop-blur-sm">
Click anywhere on the map to drop a pin and preview the ZIP/FSA areas included in the deep research run.
</div>
)}
</div>
);
}
function PreviewOverlay({
overlay,
pin,
preview,
}: {
overlay: DeepResearchPreview['overlay'] | null;
pin: google.maps.LatLngLiteral | null;
preview: DeepResearchPreview | null;
}) {
const map = useMap();
useEffect(() => {
if (!map || !overlay) {
return;
}
const addedFeatures = map.data.addGeoJson(overlay as never);
map.data.setStyle((feature) => {
const ring = Number(feature.getProperty('propagationRing') ?? 0);
const color = ringPalette[Math.min(ring, ringPalette.length - 1)];
return {
fillColor: color,
fillOpacity: ring === 0 ? 0.28 : 0.16,
strokeColor: color,
strokeWeight: ring === 0 ? 3 : 2,
clickable: false,
};
});
return () => {
addedFeatures.forEach((feature) => map.data.remove(feature));
};
}, [map, overlay]);
useEffect(() => {
if (!map) {
return;
}
const bounds = new google.maps.LatLngBounds();
let hasBounds = false;
if (pin) {
bounds.extend(pin);
hasBounds = true;
}
preview?.areas.forEach((area) => {
if (area.centroidLat != null && area.centroidLng != null) {
bounds.extend({ lat: area.centroidLat, lng: area.centroidLng });
hasBounds = true;
}
});
if (hasBounds) {
map.fitBounds(bounds, 60);
}
}, [map, pin, preview]);
return null;
}
+372
View File
@@ -0,0 +1,372 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { AlertCircle, Crosshair, Loader2, MapPinned, Sparkles } from 'lucide-react';
import type { DeepResearchBatchSummary, DeepResearchPreview } from '../../shared/types';
import { createDeepResearchBatch, getDeepResearchBatch, listDeepResearchBatches, previewDeepResearch } from '../lib/database';
import { DeepResearchPreviewMap } from './DeepResearchPreviewMap';
interface DeepResearchViewProps {
onShowBatchOnMap: (jobIds: string[]) => void;
}
export function DeepResearchView({ onShowBatchOnMap }: DeepResearchViewProps) {
const [pin, setPin] = useState<google.maps.LatLngLiteral | null>(null);
const [businessType, setBusinessType] = useState('');
const [keywords, setKeywords] = useState('');
const [propagation, setPropagation] = useState(1);
const [preview, setPreview] = useState<DeepResearchPreview | null>(null);
const [batches, setBatches] = useState<DeepResearchBatchSummary[]>([]);
const [error, setError] = useState<string | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
const [isPreviewing, setIsPreviewing] = useState(false);
const [isRunning, setIsRunning] = useState(false);
const [isLoadingBatches, setIsLoadingBatches] = useState(true);
const [activeBatchId, setActiveBatchId] = useState<string | null>(null);
const refreshBatches = useCallback(async () => {
setIsLoadingBatches(true);
try {
const nextBatches = await listDeepResearchBatches();
setBatches(nextBatches);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load deep research history.');
} finally {
setIsLoadingBatches(false);
}
}, []);
useEffect(() => {
void refreshBatches();
}, [refreshBatches]);
useEffect(() => {
setPreview(null);
setPreviewError(null);
}, [businessType, keywords, pin?.lat, pin?.lng, propagation]);
const canPreview = Boolean(pin && businessType.trim());
const previewSummary = useMemo(() => {
if (!preview) {
return null;
}
return `${preview.totalAreas} postal areas across ${preview.countryCode} with ${preview.estimatedChildJobs} child researches.`;
}, [preview]);
const handlePreview = async () => {
if (!pin || !businessType.trim()) {
setPreviewError('Drop a pin and add a business type before previewing deep research.');
return;
}
setIsPreviewing(true);
setPreviewError(null);
try {
const nextPreview = await previewDeepResearch({
lat: pin.lat,
lng: pin.lng,
propagation,
businessType: businessType.trim(),
keywords: keywords.trim() || undefined,
});
setPreview(nextPreview);
} catch (err) {
setPreviewError(err instanceof Error ? err.message : 'Failed to preview deep research.');
} finally {
setIsPreviewing(false);
}
};
const handleRunDeepResearch = async () => {
if (!pin || !businessType.trim()) {
setPreviewError('Drop a pin and add a business type before running deep research.');
return;
}
setIsRunning(true);
setPreviewError(null);
try {
const batch = await createDeepResearchBatch({
lat: pin.lat,
lng: pin.lng,
propagation,
businessType: businessType.trim(),
keywords: keywords.trim() || undefined,
});
setActiveBatchId(batch.id);
await refreshBatches();
if (batch.jobIds.length > 0) {
onShowBatchOnMap(batch.jobIds);
}
} catch (err) {
setPreviewError(err instanceof Error ? err.message : 'Failed to run deep research.');
} finally {
setActiveBatchId(null);
setIsRunning(false);
}
};
const handleOpenBatchOnMap = async (batchId: string) => {
setActiveBatchId(batchId);
setError(null);
try {
const batch = await getDeepResearchBatch(batchId);
if (batch.jobIds.length === 0) {
setError('This deep research batch does not have child jobs yet.');
return;
}
onShowBatchOnMap(batch.jobIds);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load batch details.');
} finally {
setActiveBatchId(null);
}
};
return (
<div className="flex-1 overflow-y-auto bg-stone-50 p-6 sm:p-8">
<div className="mx-auto max-w-7xl space-y-8">
<header className="space-y-2">
<h1 className="text-3xl font-bold text-stone-900">Deep Research</h1>
<p className="max-w-3xl text-stone-600">
Drop a pin, choose a propagation depth, preview the ZIP or FSA areas that will be covered, and run one bundled research batch across every adjacent postal area.
</p>
</header>
<section className="grid grid-cols-1 gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
<div className="space-y-6 rounded-3xl border border-stone-200 bg-white p-6 shadow-sm">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-emerald-600">Planner</p>
<h2 className="mt-2 text-2xl font-bold text-stone-900">Configure the deep research batch</h2>
<p className="mt-2 text-sm text-stone-600">
The preview uses local postal boundaries to determine which child research jobs will be created.
</p>
</div>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-semibold text-stone-700">Business Type</label>
<input
type="text"
value={businessType}
onChange={(event) => setBusinessType(event.target.value)}
placeholder="e.g. dentists, HVAC, bakeries"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
/>
</div>
<div>
<label className="mb-2 block text-sm font-semibold text-stone-700">Keywords</label>
<input
type="text"
value={keywords}
onChange={(event) => setKeywords(event.target.value)}
placeholder="Optional comma-separated keywords"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
/>
</div>
<div>
<label className="mb-2 block text-sm font-semibold text-stone-700">Propagation</label>
<input
type="range"
min="0"
max="5"
value={propagation}
onChange={(event) => setPropagation(Number.parseInt(event.target.value, 10) || 0)}
className="w-full accent-emerald-600"
/>
<div className="mt-2 flex items-center justify-between text-xs text-stone-500">
<span>Base postal area only</span>
<span className="rounded-full bg-emerald-50 px-3 py-1 font-semibold text-emerald-700">{propagation} hop{propagation === 1 ? '' : 's'}</span>
<span>Expand outward</span>
</div>
</div>
<div className="rounded-2xl border border-stone-200 bg-stone-50 p-4 text-sm text-stone-600">
<div className="flex items-center gap-2 font-semibold text-stone-900">
<Crosshair className="h-4 w-4 text-emerald-600" />
Pin placement
</div>
<p className="mt-2">Click the map to drop a pin. The preview will find the containing ZIP or FSA and expand to adjacent postal areas based on the propagation depth.</p>
{pin && <p className="mt-3 font-medium text-stone-800">Pin: {pin.lat.toFixed(5)}, {pin.lng.toFixed(5)}</p>}
</div>
</div>
{previewError && (
<div className="flex items-start gap-3 rounded-2xl border border-red-100 bg-red-50 p-4 text-sm text-red-700">
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0" />
<span>{previewError}</span>
</div>
)}
{previewSummary && (
<div className="rounded-2xl border border-emerald-200 bg-emerald-50/70 p-4 text-sm text-emerald-900">
<div className="flex items-center gap-2 font-semibold">
<Sparkles className="h-4 w-4" />
Preview ready
</div>
<p className="mt-2">{previewSummary}</p>
<p className="mt-2 text-emerald-800">
Base area: <span className="font-semibold">{preview?.baseArea.displayName}</span>
</p>
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row">
<button
type="button"
onClick={() => void handlePreview()}
disabled={!canPreview || isPreviewing || isRunning}
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm font-semibold text-stone-700 transition-all hover:bg-stone-50 disabled:cursor-not-allowed disabled:opacity-50"
>
{isPreviewing ? <Loader2 className="h-4 w-4 animate-spin" /> : <MapPinned className="h-4 w-4" />}
Preview areas
</button>
<button
type="button"
onClick={() => void handleRunDeepResearch()}
disabled={!canPreview || isPreviewing || isRunning}
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-3 text-sm font-semibold text-white transition-all hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isRunning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
Run deep research
</button>
</div>
</div>
<div className="space-y-4">
<DeepResearchPreviewMap pin={pin} preview={preview} onPinChange={setPin} />
{preview && (
<div className="rounded-3xl border border-stone-200 bg-white p-6 shadow-sm">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-lg font-bold text-stone-900">Preview coverage</h3>
<p className="mt-1 text-sm text-stone-600">These postal areas will become child researches in the batch.</p>
</div>
<div className="rounded-full border border-stone-200 bg-stone-50 px-4 py-2 text-sm font-medium text-stone-700">
{preview.totalAreas} areas
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{preview.areas.map((area) => (
<span
key={area.id}
className={`rounded-full px-3 py-1 text-xs font-semibold ${
area.propagationRing === 0 ? 'bg-emerald-100 text-emerald-800' : 'bg-stone-100 text-stone-700'
}`}
>
{area.displayName} · ring {area.propagationRing}
</span>
))}
</div>
</div>
)}
</div>
</section>
<section className="space-y-5">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Deep Research History</p>
<h2 className="mt-2 text-2xl font-bold text-stone-900">Previous deep research batches</h2>
<p className="mt-2 text-sm text-stone-600">Review completed or failed batch runs and open their bundled map results.</p>
</div>
<div className="rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-medium text-stone-600 shadow-sm">
{batches.length} batches
</div>
</div>
{error && <div className="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
{isLoadingBatches ? (
<div className="flex items-center gap-3 rounded-2xl border border-stone-200 bg-white p-5 text-sm text-stone-500 shadow-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Loading deep research batches...
</div>
) : batches.length === 0 ? (
<div className="rounded-2xl border border-dashed border-stone-300 bg-white p-10 text-center shadow-sm">
<p className="text-lg font-semibold text-stone-900">No deep research batches yet.</p>
<p className="mt-2 text-sm text-stone-500">Preview a pin on the map and run your first deep research batch.</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{batches.map((batch) => (
<div key={batch.id} className="rounded-2xl border border-stone-200 bg-white p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-lg font-bold text-stone-900">{batch.businessType}</p>
<p className="mt-1 text-sm text-stone-500">
{batch.basePostalCode ? `${batch.basePostalCode} · ${batch.countryCode ?? 'N/A'}` : 'Base postal area unavailable'}
</p>
</div>
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${statusBadgeClass(batch.status)}`}>
{batch.status}
</span>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 rounded-2xl bg-stone-50 p-4 text-sm text-stone-600">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-stone-400">Propagation</p>
<p className="mt-1 text-base font-bold text-stone-900">{batch.propagation}</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-stone-400">Postal Areas</p>
<p className="mt-1 text-base font-bold text-stone-900">{batch.totalPostalAreas}</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-stone-400">Child Jobs</p>
<p className="mt-1 text-base font-bold text-stone-900">{batch.childJobCount}</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-stone-400">Leads</p>
<p className="mt-1 text-base font-bold text-stone-900">{batch.totalResults}</p>
</div>
</div>
{batch.keywords && <p className="mt-4 text-sm text-stone-500">Keywords: {batch.keywords}</p>}
<div className="mt-4 flex items-center justify-between border-t border-stone-100 pt-4 text-sm text-stone-500">
<span>Created {new Date(batch.createdAt).toLocaleDateString()}</span>
<button
type="button"
onClick={() => void handleOpenBatchOnMap(batch.id)}
disabled={activeBatchId === batch.id}
className="rounded-xl bg-emerald-600 px-4 py-2 font-semibold text-white transition-all hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{activeBatchId === batch.id ? 'Loading...' : 'Show bundle on map'}
</button>
</div>
</div>
))}
</div>
)}
</section>
</div>
</div>
);
}
function statusBadgeClass(status: DeepResearchBatchSummary['status']) {
switch (status) {
case 'completed':
return 'bg-emerald-100 text-emerald-800';
case 'failed':
return 'bg-red-100 text-red-700';
case 'running':
return 'bg-sky-100 text-sky-700';
case 'stopped':
return 'bg-stone-200 text-stone-700';
case 'pending':
default:
return 'bg-amber-100 text-amber-700';
}
}
+8 -5
View File
@@ -1,18 +1,20 @@
import React from 'react';
import { Search, LayoutDashboard, Map as MapIcon, LogOut, Briefcase } from 'lucide-react';
import { Search, LayoutDashboard, Map as MapIcon, LogOut, Briefcase, MapPinned } from 'lucide-react';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import type { AppUser } from '../../shared/types';
import { getUserAvatarUrl, getUserDisplayName } from '../lib/auth';
export type AppTab = 'setup' | 'deepResearch' | 'dashboard' | 'map';
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
interface LayoutProps {
user: AppUser;
activeTab: 'setup' | 'dashboard' | 'map';
setActiveTab: (tab: 'setup' | 'dashboard' | 'map') => void;
activeTab: AppTab;
setActiveTab: (tab: AppTab) => void;
onLogout: () => void;
children: React.ReactNode;
}
@@ -23,9 +25,10 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La
const navigation = [
{ id: 'setup', name: 'Research', icon: Search },
{ id: 'deepResearch', name: 'Deep Research', icon: MapPinned },
{ id: 'dashboard', name: 'Dashboard', icon: LayoutDashboard },
{ id: 'map', name: 'Map View', icon: MapIcon },
];
] as const;
return (
<div className="flex h-screen bg-stone-50 overflow-hidden">
@@ -42,7 +45,7 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La
{navigation.map((item) => (
<button
key={item.id}
onClick={() => setActiveTab(item.id as any)}
onClick={() => setActiveTab(item.id)}
className={cn(
"w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all",
activeTab === item.id
+53 -9
View File
@@ -1,4 +1,39 @@
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '';
function isLoopbackHostname(hostname: string) {
return hostname === 'localhost' || hostname === '127.0.0.1';
}
function normalizeBaseUrl(baseUrl: string) {
return baseUrl.replace(/\/+$/, '');
}
function resolveApiBaseUrl() {
const configuredBaseUrl = (import.meta.env.VITE_API_BASE_URL ?? '').trim();
if (typeof window === 'undefined') {
return normalizeBaseUrl(configuredBaseUrl);
}
const fallbackBaseUrl = `${window.location.protocol}//${window.location.hostname}:4000/api`;
if (!configuredBaseUrl) {
return fallbackBaseUrl;
}
try {
const configuredUrl = new URL(configuredBaseUrl);
if (isLoopbackHostname(configuredUrl.hostname) && !isLoopbackHostname(window.location.hostname)) {
configuredUrl.hostname = window.location.hostname;
return normalizeBaseUrl(configuredUrl.toString());
}
return normalizeBaseUrl(configuredUrl.toString());
} catch {
return normalizeBaseUrl(configuredBaseUrl);
}
}
const apiBaseUrl = resolveApiBaseUrl();
export const hasApiConfig = Boolean(apiBaseUrl);
@@ -7,14 +42,23 @@ export async function apiRequest<T>(path: string, init?: RequestInit): Promise<T
throw new Error('VITE_API_BASE_URL is not configured.');
}
const response = await fetch(`${apiBaseUrl}${path}`, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(init?.headers ?? {}),
},
...init,
});
let response: Response;
try {
response = await fetch(`${apiBaseUrl}${path}`, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(init?.headers ?? {}),
},
...init,
});
} catch (error) {
throw new Error(
`Failed to reach the local API at ${apiBaseUrl}. Check that the API server is running and that VITE_API_BASE_URL matches the host you opened in the browser.`,
{ cause: error },
);
}
const contentType = response.headers.get('content-type') || '';
const payload = contentType.includes('application/json') ? ((await response.json()) as unknown) : null;
+29
View File
@@ -1,5 +1,6 @@
import { apiRequest } from './api';
import type { Business, SearchJob } from '../types';
import type { CreateDeepResearchBatchRequest, DeepResearchBatchDetail, DeepResearchBatchSummary, DeepResearchPreview, DeepResearchPreviewRequest } from '../../shared/types';
export type SearchJobResultLink = {
businessId: string;
@@ -58,3 +59,31 @@ export async function runSearch(payload: RunSearchPayload): Promise<RunSearchRes
body: JSON.stringify(payload),
});
}
export async function previewDeepResearch(payload: DeepResearchPreviewRequest): Promise<DeepResearchPreview> {
const response = await apiRequest<{ preview: DeepResearchPreview }>('/deep-research/preview', {
method: 'POST',
body: JSON.stringify(payload),
});
return response.preview;
}
export async function createDeepResearchBatch(payload: CreateDeepResearchBatchRequest): Promise<DeepResearchBatchDetail> {
const response = await apiRequest<{ batch: DeepResearchBatchDetail }>('/deep-research/batches', {
method: 'POST',
body: JSON.stringify(payload),
});
return response.batch;
}
export async function listDeepResearchBatches(): Promise<DeepResearchBatchSummary[]> {
const response = await apiRequest<{ batches: DeepResearchBatchSummary[] }>('/deep-research/batches');
return response.batches;
}
export async function getDeepResearchBatch(batchId: string): Promise<DeepResearchBatchDetail> {
const response = await apiRequest<{ batch: DeepResearchBatchDetail }>(`/deep-research/batches/${batchId}`);
return response.batch;
}