import { useEffect, useMemo, useState } from "react"; import { resolveResearchSettings, type Settings } from "@fusion/core"; import { Loader2, Search } from "lucide-react"; import { fetchAuthStatus, fetchSettings } from "../api"; import { useResearch } from "../hooks/useResearch"; import type { ResearchProviderOption } from "../research-types"; import { ResearchTaskActionModal } from "./ResearchTaskActionModal"; import type { SectionId } from "./SettingsModal"; import "./ResearchView.css"; interface ResearchViewProps { projectId?: string; addToast?: (message: string, type?: "success" | "error" | "info") => void; onOpenSettings?: (section?: SectionId) => void; readinessVersion?: number; } const DEFAULT_PROVIDERS: ResearchProviderOption[] = ["web-search", "page-fetch", "github", "local-docs", "llm-synthesis"]; const PROVIDER_TO_SOURCE_KEY: Record["enabledSources"]> = { "web-search": "webSearch", "page-fetch": "pageFetch", github: "github", "local-docs": "localDocs", "llm-synthesis": "llmSynthesis", }; const PROVIDER_LABELS: Record = { "web-search": "Web Search", "page-fetch": "Page Fetch", github: "GitHub", "local-docs": "Local Docs", "llm-synthesis": "LLM Synthesis", }; export function ResearchView({ projectId, addToast, onOpenSettings, readinessVersion = 0 }: ResearchViewProps) { const { runs, selectedRun, selectedRunId, setSelectedRunId, availability, loading, error, searchQuery, setSearchQuery, createRun, cancelRun, retryRun, exportRun, createTaskFromRun, attachRunToTask, statusCounts, refresh, uiError, runActionState, } = useResearch({ projectId }); const [query, setQuery] = useState(""); const [effectiveSettings, setEffectiveSettings] = useState(() => resolveResearchSettings(undefined)); const [authProviders, setAuthProviders] = useState>([]); const [submitting, setSubmitting] = useState(false); const [selectedProviders, setSelectedProviders] = useState([]); const [actionLoading, setActionLoading] = useState(null); const [modalState, setModalState] = useState(null); const providerOptions = availability.supportedProviders ?? DEFAULT_PROVIDERS; const selectedSearchProvider = effectiveSettings.searchProvider; const isProviderEnabled = (provider: ResearchProviderOption) => { if (provider === "web-search") { return true; } return effectiveSettings.enabledSources[PROVIDER_TO_SOURCE_KEY[provider]]; }; useEffect(() => { const enabledProviders = providerOptions.filter((provider) => isProviderEnabled(provider)); setSelectedProviders((current) => { const currentEnabled = current.filter((provider) => enabledProviders.includes(provider)); if (currentEnabled.length > 0) { return currentEnabled; } return enabledProviders; }); }, [effectiveSettings.enabledSources, providerOptions]); useEffect(() => { let cancelled = false; Promise.all([ fetchSettings(projectId) as Promise>, fetchAuthStatus().catch(() => ({ providers: [] })), ]) .then(([settings, authStatus]) => { if (cancelled) return; setEffectiveSettings(resolveResearchSettings(settings)); setAuthProviders( authStatus.providers .filter((provider) => provider.type === "api_key") .map((provider) => ({ id: provider.id, authenticated: provider.authenticated })), ); }) .catch(() => { if (!cancelled) { setEffectiveSettings(resolveResearchSettings(undefined)); } }); return () => { cancelled = true; }; }, [projectId, readinessVersion]); const statusLabel = useMemo(() => { if (!selectedRun) return "No run selected"; return selectedRun.status; }, [selectedRun]); const statusDotClass = useMemo(() => { if (!selectedRun) return "status-dot"; if (selectedRun.status === "queued" || selectedRun.status === "retry_waiting") return "status-dot status-dot--pending"; if (selectedRun.status === "running") return "status-dot status-dot--connecting"; if (selectedRun.status === "completed") return "status-dot status-dot--online"; if (selectedRun.status === "failed" || selectedRun.status === "cancelled") return "status-dot status-dot--error"; return "status-dot"; }, [selectedRun]); const supportedExportFormats = availability.supportedExportFormats ?? ["markdown", "json", "html"]; const apiKeyProviderAuth = useMemo(() => new Map(authProviders.map((provider) => [provider.id, provider.authenticated])), [authProviders]); const requiredCredentialProviders = useMemo(() => { const required = new Set(); if (effectiveSettings.enabledSources.webSearch && selectedSearchProvider) { required.add(selectedSearchProvider); } if (effectiveSettings.enabledSources.llmSynthesis && effectiveSettings.synthesisProvider) { required.add(effectiveSettings.synthesisProvider); } return [...required].filter((providerId) => apiKeyProviderAuth.has(providerId)); }, [effectiveSettings.enabledSources.llmSynthesis, effectiveSettings.enabledSources.webSearch, effectiveSettings.synthesisProvider, selectedSearchProvider, apiKeyProviderAuth]); const missingCredentialProvider = requiredCredentialProviders.find((providerId) => apiKeyProviderAuth.get(providerId) !== true); const setupState = useMemo(() => { if (!availability.available) { return { reason: availability.reason ?? "Research is unavailable for this project.", details: availability.setupInstructions, settingsSection: "research-project" as SectionId, }; } if (!effectiveSettings.enabled) { return { reason: "Research is disabled for this project.", details: "Enable project research settings to create runs.", settingsSection: "research-project" as SectionId, }; } if (missingCredentialProvider) { return { reason: `Missing API key for ${missingCredentialProvider}.`, details: "Add provider credentials in Authentication settings.", settingsSection: "authentication" as SectionId, }; } return null; }, [availability.available, availability.reason, availability.setupInstructions, effectiveSettings.enabled, missingCredentialProvider]); const runAction = async (key: string, action: () => Promise, successMessage: string) => { setActionLoading(key); try { await action(); addToast?.(successMessage, "success"); await refresh(); } catch (err) { addToast?.(err instanceof Error ? err.message : "Action failed", "error"); } finally { setActionLoading(null); } }; const handleExport = async (format: "markdown" | "json" | "html") => { if (!selectedRun) return; setActionLoading(`export-${format}`); try { const payload = await exportRun(selectedRun.id, format); const blob = new Blob([payload.content], { type: "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = payload.filename; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); addToast?.(`Exported ${payload.filename}`, "success"); } catch (err) { addToast?.(err instanceof Error ? err.message : "Export failed", "error"); } finally { setActionLoading(null); } }; const handleCreateRun = async () => { if (!query.trim()) return; setSubmitting(true); try { const providers = selectedProviders.filter((provider) => isProviderEnabled(provider)); if (providers.length === 0) { setSubmitting(false); addToast?.("No enabled research sources are available for this project.", "error"); return; } const response = await createRun({ query: query.trim(), providers }); setSelectedRunId(response.run.id); setQuery(""); addToast?.("Research run created", "success"); await refresh(); } catch (err) { addToast?.(err instanceof Error ? err.message : "Failed to create run", "error"); } finally { setSubmitting(false); } }; return (

Research

Cited search and synthesis runs: gather sources, fetch content, and synthesize findings.

{setupState ? (

{setupState.reason}

{setupState.details &&

{setupState.details}

}

Current defaults: provider {effectiveSettings.searchProvider ?? "(not set)"}, max sources {effectiveSettings.limits.maxSourcesPerRun}

) : ( <>