From 55780c6e8bd597af394d6379668f42b0f866c89e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E6=9D=B0?= Date: Sat, 7 Mar 2026 18:02:21 +0800 Subject: [PATCH] refactor: share research fetch helpers --- .../research/ApprovalQueuePanel.tsx | 5 +- web/src/components/research/FeedTab.tsx | 3 +- web/src/components/research/MemoryTab.tsx | 3 +- .../components/research/ResearchDashboard.tsx | 67 +++---------------- .../research/ResearchDiscoveryPage.tsx | 51 +------------- .../components/research/ResearchPageNew.tsx | 59 ++-------------- web/src/components/research/SavedTab.tsx | 3 +- web/src/lib/fetch.ts | 49 ++++++++++++++ 8 files changed, 79 insertions(+), 161 deletions(-) create mode 100644 web/src/lib/fetch.ts diff --git a/web/src/components/research/ApprovalQueuePanel.tsx b/web/src/components/research/ApprovalQueuePanel.tsx index 5e683a30..c3bdf468 100644 --- a/web/src/components/research/ApprovalQueuePanel.tsx +++ b/web/src/components/research/ApprovalQueuePanel.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react" import { Check, Loader2, RefreshCw, X } from "lucide-react" import { Button } from "@/components/ui/button" +import { getErrorMessage } from "@/lib/fetch" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" type ApprovalItem = { @@ -35,7 +36,7 @@ export function ApprovalQueuePanel() { const payload = (await res.json()) as ApprovalQueueResponse setItems(payload.items || []) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) setItems([]) } finally { setLoading(false) @@ -62,7 +63,7 @@ export function ApprovalQueuePanel() { } await load() } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) } finally { setActingId(null) } diff --git a/web/src/components/research/FeedTab.tsx b/web/src/components/research/FeedTab.tsx index 9b42cdc5..c02d7242 100644 --- a/web/src/components/research/FeedTab.tsx +++ b/web/src/components/research/FeedTab.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react" import { Loader2, RefreshCw } from "lucide-react" import { Button } from "@/components/ui/button" +import { getErrorMessage } from "@/lib/fetch" import { Card, CardContent } from "@/components/ui/card" import { PaperCard, type Paper } from "./PaperCard" @@ -81,7 +82,7 @@ export function FeedTab({ userId, trackId, onLike, onSave, onDislike }: FeedTabP const payload = (await res.json()) as FeedResponse setItems(payload.items || []) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) setItems([]) } finally { setLoading(false) diff --git a/web/src/components/research/MemoryTab.tsx b/web/src/components/research/MemoryTab.tsx index 4757d51b..82ab6875 100644 --- a/web/src/components/research/MemoryTab.tsx +++ b/web/src/components/research/MemoryTab.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react" import { Loader2, RefreshCw } from "lucide-react" import { Button } from "@/components/ui/button" +import { getErrorMessage } from "@/lib/fetch" import { Card, CardContent } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" @@ -50,7 +51,7 @@ export function MemoryTab({ userId, trackId }: MemoryTabProps) { const payload = (await res.json()) as MemoryResponse setItems(payload.items || []) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) setItems([]) } finally { setLoading(false) diff --git a/web/src/components/research/ResearchDashboard.tsx b/web/src/components/research/ResearchDashboard.tsx index a357dce0..9219e3ac 100644 --- a/web/src/components/research/ResearchDashboard.tsx +++ b/web/src/components/research/ResearchDashboard.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react" +import { fetchJson, getErrorMessage } from "@/lib/fetch" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -90,52 +91,6 @@ 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(() => "") - 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 -} - function clampNumber(value: number, min: number, max: number, fallback: number) { if (!Number.isFinite(value)) return fallback return Math.min(max, Math.max(min, value)) @@ -256,7 +211,7 @@ export default function ResearchDashboard() { useEffect(() => { setError(null) - refreshTracks().catch((e) => setError(e instanceof Error ? e.message : String(e))) + refreshTracks().catch((e) => setError(getErrorMessage(e))) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -309,7 +264,7 @@ export default function ResearchDashboard() { await refreshTracks() } } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) } finally { setLoading(false) } @@ -334,7 +289,7 @@ export default function ResearchDashboard() { setSuggestText("") await refreshInbox(activeTrackId) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) } finally { setLoading(false) } @@ -357,7 +312,7 @@ export default function ResearchDashboard() { }) await refreshInbox(activeTrackId) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) } finally { setLoading(false) } @@ -381,7 +336,7 @@ export default function ResearchDashboard() { }) await refreshInbox(activeTrackId) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) } finally { setLoading(false) } @@ -420,7 +375,7 @@ export default function ResearchDashboard() { const activeId = await refreshTracks() await refreshInbox(activeId) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) } finally { setLoading(false) } @@ -458,7 +413,7 @@ export default function ResearchDashboard() { }) await buildContext(false) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) } finally { setLoading(false) } @@ -474,7 +429,7 @@ export default function ResearchDashboard() { ) await refreshInbox(trackId) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) } finally { setLoading(false) } @@ -973,7 +928,7 @@ export default function ResearchDashboard() { /> ) : ( diff --git a/web/src/components/research/ResearchDiscoveryPage.tsx b/web/src/components/research/ResearchDiscoveryPage.tsx index fd1958de..a09c653f 100644 --- a/web/src/components/research/ResearchDiscoveryPage.tsx +++ b/web/src/components/research/ResearchDiscoveryPage.tsx @@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from "react" import { useSearchParams } from "next/navigation" import { ArrowLeft, Compass } from "lucide-react" +import { fetchJson, getErrorMessage } from "@/lib/fetch" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -14,52 +15,6 @@ 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(() => "") - 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 -} - export default function ResearchDiscoveryPage() { const searchParams = useSearchParams() const [userId] = useState("default") @@ -91,7 +46,7 @@ export default function ResearchDiscoveryPage() { ) useEffect(() => { - refreshTracks().catch((err) => setError(err instanceof Error ? err.message : String(err))) + refreshTracks().catch((err) => setError(getErrorMessage(err))) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -126,7 +81,7 @@ export default function ResearchDiscoveryPage() { ) await refreshTracks() } catch (err) { - setError(err instanceof Error ? err.message : String(err)) + setError(getErrorMessage(err)) } finally { setLoading(false) } diff --git a/web/src/components/research/ResearchPageNew.tsx b/web/src/components/research/ResearchPageNew.tsx index ae1ce736..00ff2669 100644 --- a/web/src/components/research/ResearchPageNew.tsx +++ b/web/src/components/research/ResearchPageNew.tsx @@ -5,6 +5,7 @@ import Link from "next/link" import { useSearchParams } from "next/navigation" import { cn } from "@/lib/utils" +import { fetchJson, getErrorMessage } from "@/lib/fetch" import { ArrowRight, BookOpen, GitBranch, Search, Sparkles } from "lucide-react" import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -46,52 +47,6 @@ 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(() => "") - 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 -} - function getGreeting(): string { const hour = new Date().getHours() if (hour < 12) return "Good morning" @@ -152,7 +107,7 @@ export default function ResearchPageNew() { // Load tracks on mount useEffect(() => { - refreshTracks().catch((e) => setError(e instanceof Error ? e.message : String(e))) + refreshTracks().catch((e) => setError(getErrorMessage(e))) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -194,7 +149,7 @@ export default function ResearchPageNew() { ) await refreshTracks() } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) } finally { setLoading(false) } @@ -244,7 +199,7 @@ export default function ResearchPageNew() { setContextPack(data.context_pack) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) } finally { setIsSearching(false) } @@ -315,7 +270,7 @@ export default function ResearchPageNew() { await refreshTracks() return true } catch (e) { - const message = e instanceof Error ? e.message : String(e) + const message = getErrorMessage(e) if (message.startsWith("409")) { setCreateError(`Track "${name}" already exists.`) } else { @@ -361,7 +316,7 @@ export default function ResearchPageNew() { await refreshTracks() return true } catch (e) { - const message = e instanceof Error ? e.message : String(e) + const message = getErrorMessage(e) if (message.startsWith("409")) { setEditError(`Track "${name}" already exists.`) } else { @@ -395,7 +350,7 @@ export default function ResearchPageNew() { setConfirmClearOpen(false) setTrackToClear(null) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) } finally { setLoading(false) } diff --git a/web/src/components/research/SavedTab.tsx b/web/src/components/research/SavedTab.tsx index 6f31a6ac..3941390f 100644 --- a/web/src/components/research/SavedTab.tsx +++ b/web/src/components/research/SavedTab.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react" import { Copy, Download, FileText, Loader2, RefreshCw } from "lucide-react" import { Button } from "@/components/ui/button" +import { getErrorMessage } from "@/lib/fetch" import { Card, CardContent } from "@/components/ui/card" import { Dialog, @@ -145,7 +146,7 @@ export function SavedTab({ userId, trackId }: SavedTabProps) { const payload = (await res.json()) as SavedResponse setItems(payload.items || []) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) setItems([]) } finally { setLoading(false) diff --git a/web/src/lib/fetch.ts b/web/src/lib/fetch.ts new file mode 100644 index 00000000..d643eb27 --- /dev/null +++ b/web/src/lib/fetch.ts @@ -0,0 +1,49 @@ +export type UpstreamErrorBody = { + detail?: string + error?: string +} + +export 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 +} + +export async function fetchJson(url: string, init?: RequestInit): Promise { + const res = await fetch(url, init) + if (!res.ok) { + const text = await res.text().catch(() => "") + 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 +} + +export function getErrorMessage(e: unknown): string { + return e instanceof Error ? e.message : String(e) +}