diff --git a/web/src/components/research/ResearchDashboard.tsx b/web/src/components/research/ResearchDashboard.tsx index 2f6e7fce..a357dce0 100644 --- a/web/src/components/research/ResearchDashboard.tsx +++ b/web/src/components/research/ResearchDashboard.tsx @@ -90,11 +90,48 @@ type ConfirmAction = | { type: "bulk_move"; itemIds: number[]; targetTrackId: number } | { type: "clear_track_memory"; trackId: number } +type UpstreamErrorBody = { + detail?: string + error?: string +} + +function toFriendlyErrorMessage(status: number, rawText: string): string | null { + if (!rawText) return null + + let parsed: UpstreamErrorBody | null = null + try { + parsed = JSON.parse(rawText) as UpstreamErrorBody + } catch { + parsed = null + } + + const detail = parsed && typeof parsed.detail === "string" ? parsed.detail : undefined + + if (detail && (detail.includes("Upstream API unreachable") || detail.includes("Upstream API timed out"))) { + if (detail.includes("timed out")) { + return "Unable to connect to service (request timed out). Please ensure the backend is running. Please try again." + } + return "Unable to connect to service. Please ensure the backend is running." + } + + // Fallback: surface backend-provided detail when available + if (detail) return detail + + return null +} + async function fetchJson(url: string, init?: RequestInit): Promise { const res = await fetch(url, init) if (!res.ok) { const text = await res.text().catch(() => "") - throw new Error(`${res.status} ${res.statusText} ${text}`.trim()) + const friendly = toFriendlyErrorMessage(res.status, text) + if (friendly) { + throw new Error(friendly) + } + const statusLabel = `${res.status} ${res.statusText}`.trim() + const base = statusLabel || "Request failed" + const message = text ? `${base} ${text}`.trim() : base + throw new Error(message) } return res.json() as Promise } @@ -219,7 +256,7 @@ export default function ResearchDashboard() { useEffect(() => { setError(null) - refreshTracks().catch((e) => setError(String(e))) + refreshTracks().catch((e) => setError(e instanceof Error ? e.message : String(e))) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -272,7 +309,7 @@ export default function ResearchDashboard() { await refreshTracks() } } catch (e) { - setError(String(e)) + setError(e instanceof Error ? e.message : String(e)) } finally { setLoading(false) } @@ -297,7 +334,7 @@ export default function ResearchDashboard() { setSuggestText("") await refreshInbox(activeTrackId) } catch (e) { - setError(String(e)) + setError(e instanceof Error ? e.message : String(e)) } finally { setLoading(false) } @@ -320,7 +357,7 @@ export default function ResearchDashboard() { }) await refreshInbox(activeTrackId) } catch (e) { - setError(String(e)) + setError(e instanceof Error ? e.message : String(e)) } finally { setLoading(false) } @@ -344,7 +381,7 @@ export default function ResearchDashboard() { }) await refreshInbox(activeTrackId) } catch (e) { - setError(String(e)) + setError(e instanceof Error ? e.message : String(e)) } finally { setLoading(false) } @@ -383,7 +420,7 @@ export default function ResearchDashboard() { const activeId = await refreshTracks() await refreshInbox(activeId) } catch (e) { - setError(String(e)) + setError(e instanceof Error ? e.message : String(e)) } finally { setLoading(false) } @@ -421,7 +458,7 @@ export default function ResearchDashboard() { }) await buildContext(false) } catch (e) { - setError(String(e)) + setError(e instanceof Error ? e.message : String(e)) } finally { setLoading(false) } @@ -437,7 +474,7 @@ export default function ResearchDashboard() { ) await refreshInbox(trackId) } catch (e) { - setError(String(e)) + setError(e instanceof Error ? e.message : String(e)) } finally { setLoading(false) } @@ -936,7 +973,7 @@ export default function ResearchDashboard() { /> ) : ( diff --git a/web/src/components/research/ResearchDiscoveryPage.tsx b/web/src/components/research/ResearchDiscoveryPage.tsx index 657c88f9..fd1958de 100644 --- a/web/src/components/research/ResearchDiscoveryPage.tsx +++ b/web/src/components/research/ResearchDiscoveryPage.tsx @@ -14,11 +14,48 @@ import type { Track } from "./TrackSelector" type SeedType = "doi" | "arxiv" | "openalex" | "semantic_scholar" | "author" +type UpstreamErrorBody = { + detail?: string + error?: string +} + +function toFriendlyErrorMessage(status: number, rawText: string): string | null { + if (!rawText) return null + + let parsed: UpstreamErrorBody | null = null + try { + parsed = JSON.parse(rawText) as UpstreamErrorBody + } catch { + parsed = null + } + + const detail = parsed && typeof parsed.detail === "string" ? parsed.detail : undefined + + if (detail && (detail.includes("Upstream API unreachable") || detail.includes("Upstream API timed out"))) { + if (detail.includes("timed out")) { + return "Unable to connect to service (request timed out). Please ensure the backend is running. Please try again." + } + return "Unable to connect to service. Please ensure the backend is running." + } + + // Fallback: surface backend-provided detail when available + if (detail) return detail + + return null +} + async function fetchJson(url: string, init?: RequestInit): Promise { const res = await fetch(url, init) if (!res.ok) { const text = await res.text().catch(() => "") - throw new Error(`${res.status} ${res.statusText} ${text}`.trim()) + const friendly = toFriendlyErrorMessage(res.status, text) + if (friendly) { + throw new Error(friendly) + } + const statusLabel = `${res.status} ${res.statusText}`.trim() + const base = statusLabel || "Request failed" + const message = text ? `${base} ${text}`.trim() : base + throw new Error(message) } return res.json() as Promise } @@ -54,7 +91,7 @@ export default function ResearchDiscoveryPage() { ) useEffect(() => { - refreshTracks().catch((err) => setError(String(err))) + refreshTracks().catch((err) => setError(err instanceof Error ? err.message : String(err))) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -89,7 +126,7 @@ export default function ResearchDiscoveryPage() { ) await refreshTracks() } catch (err) { - setError(String(err)) + setError(err instanceof Error ? err.message : String(err)) } finally { setLoading(false) } diff --git a/web/src/components/research/ResearchPageNew.tsx b/web/src/components/research/ResearchPageNew.tsx index 04d4fa00..ae1ce736 100644 --- a/web/src/components/research/ResearchPageNew.tsx +++ b/web/src/components/research/ResearchPageNew.tsx @@ -46,11 +46,48 @@ type ContextPack = { paper_recommendation_reasons?: Record } +type UpstreamErrorBody = { + detail?: string + error?: string +} + +function toFriendlyErrorMessage(status: number, rawText: string): string | null { + if (!rawText) return null + + let parsed: UpstreamErrorBody | null = null + try { + parsed = JSON.parse(rawText) as UpstreamErrorBody + } catch { + parsed = null + } + + const detail = parsed && typeof parsed.detail === "string" ? parsed.detail : undefined + + if (detail && (detail.includes("Upstream API unreachable") || detail.includes("Upstream API timed out"))) { + if (detail.includes("timed out")) { + return "Unable to connect to service (request timed out). Please ensure the backend is running. Please try again." + } + return "Unable to connect to service. Please ensure the backend is running." + } + + // Fallback: surface backend-provided detail when available + if (detail) return detail + + return null +} + async function fetchJson(url: string, init?: RequestInit): Promise { const res = await fetch(url, init) if (!res.ok) { const text = await res.text().catch(() => "") - throw new Error(`${res.status} ${res.statusText} ${text}`.trim()) + const friendly = toFriendlyErrorMessage(res.status, text) + if (friendly) { + throw new Error(friendly) + } + const statusLabel = `${res.status} ${res.statusText}`.trim() + const base = statusLabel || "Request failed" + const message = text ? `${base} ${text}`.trim() : base + throw new Error(message) } return res.json() as Promise } @@ -115,7 +152,7 @@ export default function ResearchPageNew() { // Load tracks on mount useEffect(() => { - refreshTracks().catch((e) => setError(String(e))) + refreshTracks().catch((e) => setError(e instanceof Error ? e.message : String(e))) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -157,7 +194,7 @@ export default function ResearchPageNew() { ) await refreshTracks() } catch (e) { - setError(String(e)) + setError(e instanceof Error ? e.message : String(e)) } finally { setLoading(false) } @@ -207,7 +244,7 @@ export default function ResearchPageNew() { setContextPack(data.context_pack) } catch (e) { - setError(String(e)) + setError(e instanceof Error ? e.message : String(e)) } finally { setIsSearching(false) } @@ -278,7 +315,7 @@ export default function ResearchPageNew() { await refreshTracks() return true } catch (e) { - const message = String(e) + const message = e instanceof Error ? e.message : String(e) if (message.startsWith("409")) { setCreateError(`Track "${name}" already exists.`) } else { @@ -324,7 +361,7 @@ export default function ResearchPageNew() { await refreshTracks() return true } catch (e) { - const message = String(e) + const message = e instanceof Error ? e.message : String(e) if (message.startsWith("409")) { setEditError(`Track "${name}" already exists.`) } else { @@ -358,7 +395,7 @@ export default function ResearchPageNew() { setConfirmClearOpen(false) setTrackToClear(null) } catch (e) { - setError(String(e)) + setError(e instanceof Error ? e.message : String(e)) } finally { setLoading(false) }