From 155619c805be4019caea3fd799e8981bfd5a2ce8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E6=9D=B0?= Date: Mon, 9 Mar 2026 23:29:08 +0800 Subject: [PATCH 1/7] refactor(research): dedupe fetch helpers across Research pages; fix Radix popover import; add @radix-ui/react-popover dep --- web/package-lock.json | 1 + web/package.json | 1 + .../components/research/ResearchDashboard.tsx | 30 ++++++++----------- .../research/ResearchDiscoveryPage.tsx | 14 ++++----- .../components/research/ResearchPageNew.tsx | 18 +++++------ 5 files changed, 28 insertions(+), 36 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 2790539b..3283fe32 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -53,6 +53,7 @@ }, "devDependencies": { "@playwright/test": "^1.58.2", + "@radix-ui/react-popover": "^1.1.15", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", diff --git a/web/package.json b/web/package.json index d8f77989..afb6f1dc 100644 --- a/web/package.json +++ b/web/package.json @@ -59,6 +59,7 @@ }, "devDependencies": { "@playwright/test": "^1.58.2", + "@radix-ui/react-popover": "^1.1.15", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", diff --git a/web/src/components/research/ResearchDashboard.tsx b/web/src/components/research/ResearchDashboard.tsx index cce4031a..afc30e07 100644 --- a/web/src/components/research/ResearchDashboard.tsx +++ b/web/src/components/research/ResearchDashboard.tsx @@ -2,8 +2,7 @@ import { useEffect, useMemo, useState } from "react" -import { fetchJson } from "@/lib/fetch" -import { mergeTracksStable } from "@/lib/utils" +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" @@ -91,8 +90,6 @@ type ConfirmAction = | { type: "bulk_moderate"; status: "approved" | "rejected"; itemIds: number[] } | { type: "bulk_move"; itemIds: number[]; targetTrackId: number } | { type: "clear_track_memory"; trackId: number } - -// Using shared fetch helpers from @/lib/fetch; remove local duplicates function clampNumber(value: number, min: number, max: number, fallback: number) { if (!Number.isFinite(value)) return fallback return Math.min(max, Math.max(min, value)) @@ -187,9 +184,8 @@ export default function ResearchDashboard() { async function refreshTracks(): Promise { const data = await fetchJson<{ tracks: Track[] }>(`/api/research/tracks?user_id=${encodeURIComponent(userId)}`) - const tracksFromApi = data.tracks || [] - setTracks((prev) => mergeTracksStable(prev, tracksFromApi)) - const active = tracksFromApi.find((t) => t.is_active) + setTracks(data.tracks || []) + const active = data.tracks.find((t) => t.is_active) const activeId = active?.id ?? null setActiveTrackId(activeId) setMoveTargetTrackId("") @@ -214,7 +210,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 }, []) @@ -267,7 +263,7 @@ export default function ResearchDashboard() { await refreshTracks() } } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) } finally { setLoading(false) } @@ -292,7 +288,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) } @@ -315,7 +311,7 @@ export default function ResearchDashboard() { }) await refreshInbox(activeTrackId) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) } finally { setLoading(false) } @@ -339,7 +335,7 @@ export default function ResearchDashboard() { }) await refreshInbox(activeTrackId) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) } finally { setLoading(false) } @@ -378,7 +374,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) } @@ -416,7 +412,7 @@ export default function ResearchDashboard() { }) await buildContext(false) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) } finally { setLoading(false) } @@ -432,7 +428,7 @@ export default function ResearchDashboard() { ) await refreshInbox(trackId) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) } finally { setLoading(false) } @@ -931,7 +927,7 @@ export default function ResearchDashboard() { /> ) : ( diff --git a/web/src/components/research/ResearchDiscoveryPage.tsx b/web/src/components/research/ResearchDiscoveryPage.tsx index b6b35238..c9f2b29e 100644 --- a/web/src/components/research/ResearchDiscoveryPage.tsx +++ b/web/src/components/research/ResearchDiscoveryPage.tsx @@ -5,8 +5,7 @@ import { useEffect, useMemo, useState } from "react" import { useSearchParams } from "next/navigation" import { ArrowLeft, Compass } from "lucide-react" -import { mergeTracksStable } from "@/lib/utils" -import { fetchJson } from "@/lib/fetch" +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" @@ -15,8 +14,6 @@ import DiscoveryGraphWorkspace from "./DiscoveryGraphWorkspace" import type { Track } from "./TrackSelector" type SeedType = "doi" | "arxiv" | "openalex" | "semantic_scholar" | "author" - -// Note: fetchJson is imported from @/lib/fetch export default function ResearchDiscoveryPage() { const searchParams = useSearchParams() const [userId] = useState("default") @@ -48,7 +45,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 }, []) @@ -64,9 +61,8 @@ export default function ResearchDiscoveryPage() { const data = await fetchJson<{ tracks: Track[] }>( `/api/research/tracks?user_id=${encodeURIComponent(userId)}` ) - const tracksFromApi = data.tracks || [] - setTracks((prev) => mergeTracksStable(prev, tracksFromApi)) - const active = tracksFromApi.find((track) => track.is_active) + setTracks(data.tracks || []) + const active = data.tracks.find((track) => track.is_active) setActiveTrackId(active?.id ?? null) } @@ -84,7 +80,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 418e7664..f1990f13 100644 --- a/web/src/components/research/ResearchPageNew.tsx +++ b/web/src/components/research/ResearchPageNew.tsx @@ -4,8 +4,9 @@ import { useEffect, useMemo, useState } from "react" import Link from "next/link" import { useSearchParams } from "next/navigation" +<<<<<<< HEAD import { cn, mergeTracksStable } from "@/lib/utils" -import { fetchJson } from "@/lib/fetch" +import { fetchJson, getErrorMessage } from "@/lib/fetch" import { showDiscoveryLink } from "@/config/features" import { ArrowRight, BookOpen } from "lucide-react" import { Badge } from "@/components/ui/badge" @@ -47,9 +48,6 @@ type ContextPack = { paper_recommendations?: Paper[] paper_recommendation_reasons?: Record } - -// Removed local UpstreamErrorBody/toFriendlyErrorMessage/fetchJson duplicates — using @/lib/fetch - function getGreeting(): string { const hour = new Date().getHours() if (hour < 12) return "Good morning" @@ -110,7 +108,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 }, []) @@ -153,7 +151,7 @@ export default function ResearchPageNew() { ) await refreshTracks() } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) } finally { setLoading(false) } @@ -203,7 +201,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) } @@ -274,7 +272,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 { @@ -320,7 +318,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 { @@ -354,7 +352,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) } From 172e86c10f67a4737682dd03132e45cd8a5bad8a Mon Sep 17 00:00:00 2001 From: jerry609 <1772030600@qq.com> Date: Tue, 10 Mar 2026 22:49:21 +0800 Subject: [PATCH 2/7] fix(web): preserve item order in research tracks selection Closes #304 Co-Authored-By: Claude Opus 4.6 --- src/paperbot/infrastructure/stores/research_store.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/paperbot/infrastructure/stores/research_store.py b/src/paperbot/infrastructure/stores/research_store.py index ac96daec..3d214620 100644 --- a/src/paperbot/infrastructure/stores/research_store.py +++ b/src/paperbot/infrastructure/stores/research_store.py @@ -156,9 +156,7 @@ def list_tracks( stmt = select(ResearchTrackModel).where(ResearchTrackModel.user_id == user_id) if not include_archived: stmt = stmt.where(ResearchTrackModel.archived_at.is_(None)) - stmt = stmt.order_by( - desc(ResearchTrackModel.is_active), desc(ResearchTrackModel.updated_at) - ).limit(limit) + stmt = stmt.order_by(ResearchTrackModel.id).limit(limit) tracks = session.execute(stmt).scalars().all() return [self._track_to_dict(t) for t in tracks] From 57ea2e767157d2568ba70caa9a1ad2e412caa066 Mon Sep 17 00:00:00 2001 From: jerry609 <1772030600@qq.com> Date: Tue, 10 Mar 2026 22:51:24 +0800 Subject: [PATCH 3/7] refactor(web): remove unused components Closes #305 Co-Authored-By: Claude Opus 4.6 --- web/src/components/research/ResearchPageNew.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/src/components/research/ResearchPageNew.tsx b/web/src/components/research/ResearchPageNew.tsx index f1990f13..190e9584 100644 --- a/web/src/components/research/ResearchPageNew.tsx +++ b/web/src/components/research/ResearchPageNew.tsx @@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from "react" import Link from "next/link" import { useSearchParams } from "next/navigation" -<<<<<<< HEAD import { cn, mergeTracksStable } from "@/lib/utils" import { fetchJson, getErrorMessage } from "@/lib/fetch" import { showDiscoveryLink } from "@/config/features" From d72c8e1dd492332fc079bd80a92157be4689052f Mon Sep 17 00:00:00 2001 From: jerry609 <1772030600@qq.com> Date: Wed, 11 Mar 2026 11:06:44 +0800 Subject: [PATCH 4/7] fix(web): keep active research track visible Refs #304 --- .../components/research/TrackPills.test.ts | 37 +++++++++++++++++++ web/src/components/research/TrackPills.tsx | 22 ++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 web/src/components/research/TrackPills.test.ts diff --git a/web/src/components/research/TrackPills.test.ts b/web/src/components/research/TrackPills.test.ts new file mode 100644 index 00000000..94da7d9a --- /dev/null +++ b/web/src/components/research/TrackPills.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest" + +import { getVisibleTracks } from "./TrackPills" +import type { Track } from "./TrackSelector" + +function makeTrack(id: number, name: string): Track { + return { id, name } +} + +describe("getVisibleTracks", () => { + it("keeps the active track visible when it falls outside the initial slice", () => { + const tracks = [ + makeTrack(1, "Track 1"), + makeTrack(2, "Track 2"), + makeTrack(3, "Track 3"), + makeTrack(4, "Track 4"), + makeTrack(5, "Track 5"), + makeTrack(6, "Track 6"), + ] + + const visible = getVisibleTracks(tracks, 6, 5) + + expect(visible.map((track) => track.id)).toEqual([1, 2, 3, 4, 6]) + }) + + it("does not reorder tracks when the active one is already visible", () => { + const tracks = [ + makeTrack(1, "Track 1"), + makeTrack(2, "Track 2"), + makeTrack(3, "Track 3"), + ] + + const visible = getVisibleTracks(tracks, 2, 5) + + expect(visible.map((track) => track.id)).toEqual([1, 2, 3]) + }) +}) diff --git a/web/src/components/research/TrackPills.tsx b/web/src/components/research/TrackPills.tsx index 5bc68343..7ee6d456 100644 --- a/web/src/components/research/TrackPills.tsx +++ b/web/src/components/research/TrackPills.tsx @@ -41,6 +41,26 @@ function getTrackIcon(name: string): LucideIcon { return trackIcons[name] || defaultIcon } +export function getVisibleTracks( + tracks: Track[], + activeTrackId: number | null, + maxVisible: number, +): Track[] { + if (maxVisible <= 0) { + return [] + } + + const activeTrack = tracks.find((track) => track.id === activeTrackId) || null + const visibleTracks = tracks.slice(0, maxVisible) + if ( + activeTrack && + !visibleTracks.some((track) => track.id === activeTrack.id) + ) { + visibleTracks.splice(Math.max(visibleTracks.length - 1, 0), 1, activeTrack) + } + return visibleTracks +} + export function TrackPills({ tracks, activeTrackId, @@ -49,7 +69,7 @@ export function TrackPills({ disabled = false, maxVisible = 5, }: TrackPillsProps) { - const visibleTracks = tracks.slice(0, maxVisible) + const visibleTracks = getVisibleTracks(tracks, activeTrackId, maxVisible) const hasMore = tracks.length > maxVisible return ( From 300b394153490f70593447bc9765e1bb86cad490 Mon Sep 17 00:00:00 2001 From: jerry609 <1772030600@qq.com> Date: Wed, 11 Mar 2026 11:07:07 +0800 Subject: [PATCH 5/7] fix(research): align paper feedback toggles with persisted state Refs #324 --- src/paperbot/api/routes/research.py | 214 +++++++----- .../application/services/anchor_service.py | 62 +++- src/paperbot/context_engine/engine.py | 30 +- .../infrastructure/stores/research_store.py | 306 +++++++++++++++--- tests/unit/test_anchor_service.py | 78 ++++- .../test_context_engine_personalized_mode.py | 23 +- tests/unit/test_feed_ranking.py | 157 +++++++-- tests/unit/test_paper_judge_persistence.py | 122 ++++++- tests/unit/test_research_feedback_state.py | 128 ++++++++ web/src/components/research/FeedTab.tsx | 35 +- web/src/components/research/PaperCard.tsx | 115 ++++--- .../components/research/ResearchPageNew.tsx | 50 ++- web/src/components/research/SearchResults.tsx | 25 +- web/src/lib/paper-feedback.test.ts | 36 +++ web/src/lib/paper-feedback.ts | 56 ++++ 15 files changed, 1196 insertions(+), 241 deletions(-) create mode 100644 tests/unit/test_research_feedback_state.py create mode 100644 web/src/lib/paper-feedback.test.ts create mode 100644 web/src/lib/paper-feedback.ts diff --git a/src/paperbot/api/routes/research.py b/src/paperbot/api/routes/research.py index 4b61c7b2..f5106148 100644 --- a/src/paperbot/api/routes/research.py +++ b/src/paperbot/api/routes/research.py @@ -26,9 +26,9 @@ router = APIRouter() -_research_store = SqlAlchemyResearchStore() -_memory_store = SqlAlchemyMemoryStore() -_track_router = TrackRouter(research_store=_research_store, memory_store=_memory_store) +_research_store: Optional[SqlAlchemyResearchStore] = None +_memory_store: Optional[SqlAlchemyMemoryStore] = None +_track_router: Optional[TrackRouter] = None _metric_collector: Optional[MemoryMetricCollector] = None _workflow_metric_store: Optional[WorkflowMetricStore] = None _paper_store: Optional["PaperStore"] = None @@ -36,6 +36,31 @@ _anchor_service: Optional["AnchorService"] = None _subscription_service: Optional["SubscriptionService"] = None + +def _get_research_store() -> SqlAlchemyResearchStore: + global _research_store + if _research_store is None: + _research_store = SqlAlchemyResearchStore() + return _research_store + + +def _get_memory_store() -> SqlAlchemyMemoryStore: + global _memory_store + if _memory_store is None: + _memory_store = SqlAlchemyMemoryStore() + return _memory_store + + +def _get_track_router() -> TrackRouter: + global _track_router + if _track_router is None: + _track_router = TrackRouter( + research_store=_get_research_store(), + memory_store=_get_memory_store(), + ) + return _track_router + + ENABLE_ANCHOR_AUTHORS = os.getenv("PAPERBOT_ENABLE_ANCHOR_AUTHORS", "true").lower() == "true" _DISCOVERY_STOPWORDS: Set[str] = { @@ -208,7 +233,7 @@ def _schedule_embedding_precompute( def _run() -> None: try: - _track_router.precompute_track_embeddings(user_id=user_id, track_ids=ids) + _get_track_router().precompute_track_embeddings(user_id=user_id, track_ids=ids) except Exception: return @@ -239,7 +264,7 @@ class TrackResponse(BaseModel): @router.post("/research/tracks", response_model=TrackResponse) def create_track(req: TrackCreateRequest, background_tasks: BackgroundTasks): - track = _research_store.create_track( + track = _get_research_store().create_track( user_id=req.user_id, name=req.name, description=req.description, @@ -265,7 +290,7 @@ def list_tracks( include_archived: bool = Query(False), limit: int = Query(100, ge=1, le=500), ): - tracks = _research_store.list_tracks( + tracks = _get_research_store().list_tracks( user_id=user_id, include_archived=include_archived, limit=limit ) return TrackListResponse(user_id=user_id, tracks=tracks) @@ -297,7 +322,7 @@ def get_deadline_radar( now = datetime.now(timezone.utc) cutoff = now + timedelta(days=int(days)) - tracks = _research_store.list_tracks(user_id=user_id, include_archived=False, limit=200) + tracks = _get_research_store().list_tracks(user_id=user_id, include_archived=False, limit=200) track_tokens: Dict[int, set[str]] = {} for track in tracks: track_id = int(track.get("id") or 0) @@ -369,7 +394,7 @@ def get_deadline_radar( @router.get("/research/tracks/active", response_model=TrackResponse) def get_active_track(user_id: str = "default"): - track = _research_store.get_active_track(user_id=user_id) + track = _get_research_store().get_active_track(user_id=user_id) if not track: raise HTTPException(status_code=404, detail="No active track for user") return TrackResponse(track=track) @@ -388,7 +413,9 @@ def update_track( raise HTTPException(status_code=400, detail="No fields to update") try: - track = _research_store.update_track(user_id=user_id, track_id=track_id, **update_data) + track = _get_research_store().update_track( + user_id=user_id, track_id=track_id, **update_data + ) except IntegrityError: raise HTTPException(status_code=409, detail="Track name already exists") from None if not track: @@ -399,7 +426,7 @@ def update_track( @router.post("/research/tracks/{track_id}/activate", response_model=TrackResponse) def activate_track(track_id: int, background_tasks: BackgroundTasks, user_id: str = "default"): - track = _research_store.activate_track(user_id=user_id, track_id=track_id) + track = _get_research_store().activate_track(user_id=user_id, track_id=track_id) if not track: raise HTTPException(status_code=404, detail="Track not found") _schedule_embedding_precompute(background_tasks, user_id=user_id, track_ids=[track_id]) @@ -422,7 +449,7 @@ class TaskResponse(BaseModel): @router.post("/research/tracks/{track_id}/tasks", response_model=TaskResponse) def add_task(track_id: int, req: TaskCreateRequest, background_tasks: BackgroundTasks): - task = _research_store.add_task( + task = _get_research_store().add_task( user_id=req.user_id, track_id=track_id, title=req.title, @@ -451,7 +478,7 @@ def list_tasks( status: Optional[str] = None, limit: int = Query(100, ge=1, le=500), ): - tasks = _research_store.list_tasks( + tasks = _get_research_store().list_tasks( user_id=user_id, track_id=track_id, status=status, limit=limit ) return TaskListResponse(user_id=user_id, track_id=track_id, tasks=tasks) @@ -480,7 +507,7 @@ def _resolve_track_scope_id( return scope_id if scope_id: return scope_id - active = _research_store.get_active_track(user_id=user_id) + active = _get_research_store().get_active_track(user_id=user_id) if not active: return None return str(active["id"]) @@ -503,7 +530,7 @@ def create_memory_item(req: MemoryItemCreateRequest, background_tasks: Backgroun scope_id=scope_id, status=req.status, ) - created, _, rows = _memory_store.add_memories(user_id=req.user_id, memories=[cand]) + created, _, rows = _get_memory_store().add_memories(user_id=req.user_id, memories=[cand]) if created <= 0 or not rows: raise HTTPException( status_code=409, detail="Duplicate memory item (same scope/kind/content)" @@ -530,7 +557,7 @@ def list_memory_items( include_pending: bool = False, limit: int = Query(100, ge=1, le=500), ): - items = _memory_store.list_memories( + items = _get_memory_store().list_memories( user_id=user_id, limit=limit, kind=kind, @@ -549,12 +576,12 @@ def list_memory_inbox( limit: int = Query(100, ge=1, le=500), ): if track_id is None: - active = _research_store.get_active_track(user_id=user_id) + active = _get_research_store().get_active_track(user_id=user_id) if not active: raise HTTPException(status_code=404, detail="No active track for user") track_id = int(active["id"]) - items = _memory_store.list_memories( + items = _get_memory_store().list_memories( user_id=user_id, limit=limit, scope_type="track", @@ -607,7 +634,7 @@ def suggest_memories(req: MemorySuggestRequest, background_tasks: BackgroundTask ) for m in extracted ] - created, skipped, rows = _memory_store.add_memories(user_id=req.user_id, memories=pending) + created, skipped, rows = _get_memory_store().add_memories(user_id=req.user_id, memories=pending) if scope_type == "track": _schedule_embedding_precompute( background_tasks, user_id=req.user_id, track_ids=[int(scope_id or 0)] @@ -632,7 +659,7 @@ class MemoryModerateRequest(BaseModel): @router.post("/research/memory/items/{item_id}/moderate", response_model=MemoryItemResponse) def moderate_memory_item(item_id: int, req: MemoryModerateRequest): - updated = _memory_store.update_item( + updated = _get_memory_store().update_item( user_id=req.user_id, item_id=item_id, status=req.status, @@ -662,9 +689,9 @@ class BulkModerateResponse(BaseModel): @router.post("/research/memory/bulk_moderate", response_model=BulkModerateResponse) def bulk_moderate(req: BulkModerateRequest, background_tasks: BackgroundTasks): # Get items before update to check their confidence for P0 metrics - items_before = _memory_store.get_items_by_ids(user_id=req.user_id, item_ids=req.item_ids) + items_before = _get_memory_store().get_items_by_ids(user_id=req.user_id, item_ids=req.item_ids) - updated = _memory_store.bulk_update_items( + updated = _get_memory_store().bulk_update_items( user_id=req.user_id, item_ids=req.item_ids, status=req.status, @@ -718,7 +745,7 @@ def bulk_move(req: BulkMoveRequest, background_tasks: BackgroundTasks): scope_id = _resolve_track_scope_id(req.user_id, scope_type, req.scope_id) if scope_type == "track" and not scope_id: raise HTTPException(status_code=400, detail="scope_id missing and no active track") - updated = _memory_store.bulk_update_items( + updated = _get_memory_store().bulk_update_items( user_id=req.user_id, item_ids=req.item_ids, scope_type=scope_type, @@ -820,7 +847,7 @@ def clear_track_memory( ): if not confirm: raise HTTPException(status_code=400, detail="confirm=true required") - deleted = _memory_store.soft_delete_by_scope( + deleted = _get_memory_store().soft_delete_by_scope( user_id=user_id, scope_type="track", scope_id=str(track_id), @@ -832,7 +859,7 @@ def clear_track_memory( # P0 Hook: Verify deletion compliance - deleted items should not be retrievable if deleted > 0: # Try to retrieve items from the cleared scope (should return empty) - retrieved_after_delete = _memory_store.list_memories( + retrieved_after_delete = _get_memory_store().list_memories( user_id=user_id, scope_type="track", scope_id=str(track_id), @@ -841,7 +868,7 @@ def clear_track_memory( limit=100, ) # Also try searching - search_results = _memory_store.search_memories( + search_results = _get_memory_store().search_memories( user_id=user_id, query="*", # broad query scope_type="track", @@ -878,7 +905,7 @@ class PrecomputeEmbeddingsResponse(BaseModel): @router.post("/research/embeddings/precompute", response_model=PrecomputeEmbeddingsResponse) def precompute_embeddings(req: PrecomputeEmbeddingsRequest): - result = _track_router.precompute_track_embeddings( + result = _get_track_router().precompute_track_embeddings( user_id=req.user_id, track_ids=req.track_ids or None ) return PrecomputeEmbeddingsResponse(user_id=req.user_id, result=result) @@ -896,7 +923,7 @@ def eval_summary( track_id: Optional[int] = None, days: int = Query(30, ge=1, le=365), ): - summary = _research_store.summarize_eval(user_id=user_id, track_id=track_id, days=days) + summary = _get_research_store().summarize_eval(user_id=user_id, track_id=track_id, days=days) return EvalSummaryResponse(user_id=user_id, track_id=track_id, summary=summary) @@ -904,7 +931,7 @@ class PaperFeedbackRequest(BaseModel): user_id: str = "default" track_id: Optional[int] = None paper_id: str = Field(..., min_length=1) - action: str = Field(..., min_length=1) # like/dislike/skip/save/cite + action: str = Field(..., min_length=1) # like/unlike/dislike/undislike/skip/save/unsave/cite weight: float = 0.0 metadata: Dict[str, Any] = {} context_run_id: Optional[int] = None @@ -923,17 +950,19 @@ class PaperFeedbackRequest(BaseModel): class PaperFeedbackResponse(BaseModel): feedback: Dict[str, Any] library_paper_id: Optional[int] = None # ID in papers table if saved + current_action: Optional[str] = None @router.post("/research/papers/feedback", response_model=PaperFeedbackResponse) def add_paper_feedback(req: PaperFeedbackRequest): set_trace_id() # Initialize trace_id for this request Logger.info(f"Received paper feedback request, action={req.action}", file=LogFiles.HARVEST) + research_store = _get_research_store() track_id = req.track_id if track_id is None: Logger.info("No track specified, getting active track", file=LogFiles.HARVEST) - active = _research_store.get_active_track(user_id=req.user_id) + active = research_store.get_active_track(user_id=req.user_id) if not active: Logger.error("No active track found", file=LogFiles.HARVEST) raise HTTPException(status_code=400, detail="track_id missing and no active track") @@ -997,7 +1026,7 @@ def add_paper_feedback(req: PaperFeedbackRequest): Logger.warning(f"Failed to save paper to library: {e}", file=LogFiles.HARVEST) Logger.info("Recording paper feedback to research store", file=LogFiles.HARVEST) - fb = _research_store.add_paper_feedback( + fb = research_store.add_paper_feedback( user_id=req.user_id, track_id=track_id, paper_id=req.paper_id, # Always use external ID for consistency @@ -1009,7 +1038,13 @@ def add_paper_feedback(req: PaperFeedbackRequest): Logger.error("Failed to record feedback - track not found", file=LogFiles.HARVEST) raise HTTPException(status_code=404, detail="Track not found") Logger.info("Paper feedback recorded successfully", file=LogFiles.HARVEST) - return PaperFeedbackResponse(feedback=fb, library_paper_id=library_paper_id) + normalized_action = research_store._normalize_feedback_action(req.action) + current_action = research_store._effective_feedback_action(normalized_action) + return PaperFeedbackResponse( + feedback=fb, + library_paper_id=library_paper_id, + current_action=current_action, + ) class PaperFeedbackListResponse(BaseModel): @@ -1025,7 +1060,7 @@ def list_paper_feedback( action: Optional[str] = None, limit: int = Query(200, ge=1, le=1000), ): - items = _research_store.list_paper_feedback( + items = _get_research_store().list_paper_feedback( user_id=user_id, track_id=track_id, action=action, limit=limit ) return PaperFeedbackListResponse(user_id=user_id, track_id=track_id, items=items) @@ -1156,7 +1191,7 @@ class PaperRepoListResponse(BaseModel): @router.post("/research/papers/{paper_id}/status", response_model=PaperReadingStatusResponse) def update_paper_status(paper_id: str, req: PaperReadingStatusRequest): - status = _research_store.set_paper_reading_status( + status = _get_research_store().set_paper_reading_status( user_id=req.user_id, paper_id=paper_id, status=req.status, @@ -1176,7 +1211,7 @@ def list_saved_papers( sort_by: str = Query("saved_at"), limit: int = Query(200, ge=1, le=1000), ): - items = _research_store.list_saved_papers( + items = _get_research_store().list_saved_papers( user_id=user_id, track_id=track_id, collection_id=collection_id, @@ -1293,7 +1328,10 @@ async def discover_from_seed(req: DiscoverySeedRequest): ) try: - openalex_work = openalex.resolve_work(seed_type=req.seed_type, seed_id=req.seed_id) + openalex_work = await openalex.resolve_work( + seed_type=req.seed_type, + seed_id=req.seed_id, + ) except Exception: openalex_work = None if openalex_work: @@ -1303,7 +1341,10 @@ async def discover_from_seed(req: DiscoverySeedRequest): seed_info["year"] = openalex_work.get("publication_year") if req.include_related: try: - related_rows = openalex.get_related_works(openalex_work, limit=req.limit) + related_rows = await openalex.get_related_works( + openalex_work, + limit=req.limit, + ) except Exception: related_rows = [] for row in related_rows: @@ -1317,7 +1358,10 @@ async def discover_from_seed(req: DiscoverySeedRequest): ) if req.include_cited: try: - cited_rows = openalex.get_referenced_works(openalex_work, limit=req.limit) + cited_rows = await openalex.get_referenced_works( + openalex_work, + limit=req.limit, + ) except Exception: cited_rows = [] for row in cited_rows: @@ -1331,7 +1375,10 @@ async def discover_from_seed(req: DiscoverySeedRequest): ) if req.include_citing: try: - citing_rows = openalex.get_citing_works(openalex_work, limit=req.limit) + citing_rows = await openalex.get_citing_works( + openalex_work, + limit=req.limit, + ) except Exception: citing_rows = [] for row in citing_rows: @@ -1345,6 +1392,7 @@ async def discover_from_seed(req: DiscoverySeedRequest): ) finally: await client.close() + await openalex.close() candidates = list(candidate_map.values()) filtered = _filter_discovery_candidates( @@ -1427,7 +1475,7 @@ async def discover_from_seed(req: DiscoverySeedRequest): @router.post("/research/collections", response_model=PaperCollectionResponse) def create_collection(req: PaperCollectionCreateRequest): try: - collection = _research_store.create_collection( + collection = _get_research_store().create_collection( user_id=req.user_id, name=req.name, description=req.description, @@ -1445,7 +1493,7 @@ def list_collections( track_id: Optional[int] = Query(default=None), limit: int = Query(200, ge=1, le=1000), ): - items = _research_store.list_collections( + items = _get_research_store().list_collections( user_id=user_id, include_archived=include_archived, track_id=track_id, @@ -1457,7 +1505,7 @@ def list_collections( @router.patch("/research/collections/{collection_id}", response_model=PaperCollectionResponse) def update_collection(collection_id: int, req: PaperCollectionUpdateRequest): try: - collection = _research_store.update_collection( + collection = _get_research_store().update_collection( user_id=req.user_id, collection_id=collection_id, name=req.name, @@ -1480,7 +1528,7 @@ def list_collection_items( user_id: str = "default", limit: int = Query(500, ge=1, le=5000), ): - items = _research_store.list_collection_items( + items = _get_research_store().list_collection_items( user_id=user_id, collection_id=collection_id, limit=limit, @@ -1493,7 +1541,7 @@ def list_collection_items( response_model=PaperCollectionItemsResponse, ) def upsert_collection_item(collection_id: int, req: PaperCollectionItemUpsertRequest): - item = _research_store.upsert_collection_item( + item = _get_research_store().upsert_collection_item( user_id=req.user_id, collection_id=collection_id, paper_id=req.paper_id, @@ -1502,7 +1550,9 @@ def upsert_collection_item(collection_id: int, req: PaperCollectionItemUpsertReq ) if item is None: raise HTTPException(status_code=404, detail="Collection or paper not found") - items = _research_store.list_collection_items(user_id=req.user_id, collection_id=collection_id) + items = _get_research_store().list_collection_items( + user_id=req.user_id, collection_id=collection_id + ) return PaperCollectionItemsResponse( user_id=req.user_id, collection_id=collection_id, items=items ) @@ -1513,7 +1563,7 @@ def upsert_collection_item(collection_id: int, req: PaperCollectionItemUpsertReq response_model=PaperCollectionItemsResponse, ) def patch_collection_item(collection_id: int, paper_id: str, req: PaperCollectionItemPatchRequest): - item = _research_store.upsert_collection_item( + item = _get_research_store().upsert_collection_item( user_id=req.user_id, collection_id=collection_id, paper_id=paper_id, @@ -1522,7 +1572,9 @@ def patch_collection_item(collection_id: int, paper_id: str, req: PaperCollectio ) if item is None: raise HTTPException(status_code=404, detail="Collection or paper not found") - items = _research_store.list_collection_items(user_id=req.user_id, collection_id=collection_id) + items = _get_research_store().list_collection_items( + user_id=req.user_id, collection_id=collection_id + ) return PaperCollectionItemsResponse( user_id=req.user_id, collection_id=collection_id, items=items ) @@ -1530,7 +1582,7 @@ def patch_collection_item(collection_id: int, paper_id: str, req: PaperCollectio @router.delete("/research/collections/{collection_id}/items/{paper_id}") def delete_collection_item(collection_id: int, paper_id: str, user_id: str = "default"): - ok = _research_store.remove_collection_item( + ok = _get_research_store().remove_collection_item( user_id=user_id, collection_id=collection_id, paper_id=paper_id, @@ -1547,11 +1599,11 @@ def get_track_feed( limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0), ): - track = _research_store.get_track(user_id=user_id, track_id=track_id) + track = _get_research_store().get_track(user_id=user_id, track_id=track_id) if not track: raise HTTPException(status_code=404, detail="Track not found") - payload = _research_store.list_track_feed( + payload = _get_research_store().list_track_feed( user_id=user_id, track_id=track_id, limit=limit, @@ -1573,7 +1625,7 @@ def export_papers( track_id: Optional[int] = None, format: str = Query("bibtex", pattern="^(bibtex|ris|markdown|csl_json)$"), ): - items = _research_store.list_saved_papers(user_id=user_id, track_id=track_id, limit=1000) + items = _get_research_store().list_saved_papers(user_id=user_id, track_id=track_id, limit=1000) papers = [item["paper"] for item in items if item.get("paper")] if not papers: @@ -1654,7 +1706,7 @@ def import_bibtex(req: BibtexImportRequest): track_pk = int(track["id"]) paper_store = _get_paper_store() - existing_saved_ids = _research_store.list_paper_feedback_ids( + existing_saved_ids = _get_research_store().list_paper_feedback_ids( user_id=req.user_id, track_id=track_pk, action="save", @@ -1695,7 +1747,7 @@ def import_bibtex(req: BibtexImportRequest): "citation_key": str(entry.get("key") or ""), "entry_type": str(entry.get("entry_type") or ""), } - _research_store.add_paper_feedback( + _get_research_store().add_paper_feedback( user_id=req.user_id, track_id=track_pk, paper_id=paper_ref, @@ -1767,7 +1819,7 @@ def pull_from_zotero(req: ZoteroSyncRequest): raise HTTPException(status_code=502, detail=f"Failed to pull from Zotero: {exc}") from exc paper_store = _get_paper_store() - existing_saved_ids = _research_store.list_paper_feedback_ids( + existing_saved_ids = _get_research_store().list_paper_feedback_ids( user_id=req.user_id, track_id=track_pk, action="save", @@ -1808,7 +1860,7 @@ def pull_from_zotero(req: ZoteroSyncRequest): "zotero_library_type": req.library_type, "zotero_library_id": req.library_id, } - _research_store.add_paper_feedback( + _get_research_store().add_paper_feedback( user_id=req.user_id, track_id=track_pk, paper_id=paper_ref, @@ -1855,12 +1907,12 @@ def push_to_zotero(req: ZoteroPushRequest): from paperbot.infrastructure.connectors.zotero_connector import ZoteroConnector if req.track_id is not None: - track = _research_store.get_track(user_id=req.user_id, track_id=req.track_id) + track = _get_research_store().get_track(user_id=req.user_id, track_id=req.track_id) if not track: raise HTTPException(status_code=404, detail="Track not found") connector = ZoteroConnector() - local_items = _research_store.list_saved_papers( + local_items = _get_research_store().list_saved_papers( user_id=req.user_id, track_id=req.track_id, sort_by="saved_at", @@ -1941,7 +1993,7 @@ def discover_track_anchors( ): _ensure_anchor_feature_enabled() - track = _research_store.get_track(user_id=user_id, track_id=track_id) + track = _get_research_store().get_track(user_id=user_id, track_id=track_id) if not track: raise HTTPException(status_code=404, detail="Track not found") @@ -1975,7 +2027,7 @@ def discover_track_anchors( def set_anchor_action(track_id: int, author_id: int, req: AnchorActionRequest): _ensure_anchor_feature_enabled() - track = _research_store.get_track(user_id=req.user_id, track_id=track_id) + track = _get_research_store().get_track(user_id=req.user_id, track_id=track_id) if not track: raise HTTPException(status_code=404, detail="Track not found") @@ -2011,7 +2063,7 @@ def set_anchor_action(track_id: int, author_id: int, req: AnchorActionRequest): def list_anchor_actions(track_id: int, user_id: str = "default"): _ensure_anchor_feature_enabled() - track = _research_store.get_track(user_id=user_id, track_id=track_id) + track = _get_research_store().get_track(user_id=user_id, track_id=track_id) if not track: raise HTTPException(status_code=404, detail="Track not found") @@ -2021,7 +2073,7 @@ def list_anchor_actions(track_id: int, user_id: str = "default"): @router.get("/research/papers/{paper_id}", response_model=PaperDetailResponse) def get_paper_detail(paper_id: str, user_id: str = "default"): - detail = _research_store.get_paper_detail(paper_id=paper_id, user_id=user_id) + detail = _get_research_store().get_paper_detail(paper_id=paper_id, user_id=user_id) if not detail: raise HTTPException(status_code=404, detail="Paper not found in registry") return PaperDetailResponse(detail=detail) @@ -2029,7 +2081,7 @@ def get_paper_detail(paper_id: str, user_id: str = "default"): @router.get("/research/papers/{paper_id}/repos", response_model=PaperRepoListResponse) def get_paper_repos(paper_id: str): - repos = _research_store.list_paper_repos(paper_id=paper_id) + repos = _get_research_store().list_paper_repos(paper_id=paper_id) if repos is None: raise HTTPException(status_code=404, detail="Paper not found in registry") return PaperRepoListResponse(paper_id=paper_id, repos=repos) @@ -2046,10 +2098,10 @@ class RouterSuggestResponse(BaseModel): @router.post("/research/router/suggest", response_model=RouterSuggestResponse) def suggest_track(req: RouterSuggestRequest): - active = _research_store.get_active_track(user_id=req.user_id) + active = _get_research_store().get_active_track(user_id=req.user_id) if not active: return RouterSuggestResponse(suggestion=None) - suggestion = _track_router.suggest_track( + suggestion = _get_track_router().suggest_track( user_id=req.user_id, query=req.query, active_track_id=int(active["id"]) ) return RouterSuggestResponse(suggestion=suggestion) @@ -2148,7 +2200,7 @@ async def build_context(req: ContextRequest): if req.activate_track_id is not None: Logger.info("Activating research track", file=LogFiles.HARVEST) - activated = _research_store.activate_track( + activated = _get_research_store().activate_track( user_id=req.user_id, track_id=req.activate_track_id ) if not activated: @@ -2167,11 +2219,11 @@ async def build_context(req: ContextRequest): ) engine = ContextEngine( - research_store=_research_store, - memory_store=_memory_store, + research_store=_get_research_store(), + memory_store=_get_memory_store(), paper_store=_get_paper_store(), search_service=search_service, - track_router=_track_router, + track_router=_get_track_router(), config=ContextEngineConfig( memory_limit=req.memory_limit, paper_limit=req.paper_limit, @@ -3144,20 +3196,20 @@ def _build_feedback_profile(user_id: str, track_id: Optional[int]) -> Dict[str, limit = 400 feedback_rows: List[Dict[str, Any]] if track_id is not None: - feedback_rows = _research_store.list_paper_feedback( + feedback_rows = _get_research_store().list_effective_paper_feedback( user_id=user_id, track_id=int(track_id), - action=None, limit=limit, ) else: feedback_rows = [] - for track in _research_store.list_tracks(user_id=user_id, include_archived=False, limit=20): + for track in _get_research_store().list_tracks( + user_id=user_id, include_archived=False, limit=20 + ): feedback_rows.extend( - _research_store.list_paper_feedback( + _get_research_store().list_effective_paper_feedback( user_id=user_id, track_id=int(track.get("id") or 0), - action=None, limit=100, ) ) @@ -3188,7 +3240,9 @@ def _build_feedback_profile(user_id: str, track_id: Optional[int]) -> Dict[str, for term in _extract_profile_terms(paper_text): profile[term] = profile.get(term, 0.0) + coeff - saved_rows = _research_store.list_saved_papers(user_id=user_id, track_id=track_id, limit=200) + saved_rows = _get_research_store().list_saved_papers( + user_id=user_id, track_id=track_id, limit=200 + ) for item in saved_rows: paper = item.get("paper") or {} paper_text = " ".join( @@ -3259,7 +3313,7 @@ def _find_track_by_name(*, user_id: str, track_name: str) -> Optional[Dict[str, target = (track_name or "").strip().casefold() if not target: return None - tracks = _research_store.list_tracks(user_id=user_id, include_archived=True, limit=500) + tracks = _get_research_store().list_tracks(user_id=user_id, include_archived=True, limit=500) for track in tracks: if str(track.get("name") or "").strip().casefold() == target: return track @@ -3274,7 +3328,7 @@ def _resolve_or_create_import_track( default_track_name: str, ) -> Dict[str, Any]: if track_id is not None: - track = _research_store.get_track(user_id=user_id, track_id=int(track_id)) + track = _get_research_store().get_track(user_id=user_id, track_id=int(track_id)) if not track: raise HTTPException(status_code=404, detail="Track not found") return track @@ -3283,19 +3337,19 @@ def _resolve_or_create_import_track( found = _find_track_by_name(user_id=user_id, track_name=track_name) if found: return found - return _research_store.create_track( + return _get_research_store().create_track( user_id=user_id, name=(track_name or "").strip(), description=f"Imported from {default_track_name.lower()}", activate=True, ) - active = _research_store.get_active_track(user_id=user_id) + active = _get_research_store().get_active_track(user_id=user_id) if active: return active today = datetime.now(timezone.utc).strftime("%Y-%m-%d") - return _research_store.create_track( + return _get_research_store().create_track( user_id=user_id, name=f"{default_track_name} {today}", description=f"Auto-created for {default_track_name.lower()}", @@ -3715,7 +3769,7 @@ class StructuredCardResponse(BaseModel): @router.get("/research/papers/{paper_id}/card", response_model=StructuredCardResponse) def get_structured_card(paper_id: str, user_id: str = "default"): - detail = _research_store.get_paper_detail(paper_id=paper_id, user_id=user_id) + detail = _get_research_store().get_paper_detail(paper_id=paper_id, user_id=user_id) if not detail: raise HTTPException(status_code=404, detail="Paper not found") @@ -3780,7 +3834,7 @@ class RelatedWorkResponse(BaseModel): @router.post("/research/papers/related-work", response_model=RelatedWorkResponse) def generate_related_work(req: RelatedWorkRequest): - items = _research_store.list_saved_papers( + items = _get_research_store().list_saved_papers( user_id=req.user_id, track_id=req.track_id, limit=req.limit ) papers = [item["paper"] for item in items if item.get("paper")] diff --git a/src/paperbot/application/services/anchor_service.py b/src/paperbot/application/services/anchor_service.py index ac8eb998..c4b8e9ba 100644 --- a/src/paperbot/application/services/anchor_service.py +++ b/src/paperbot/application/services/anchor_service.py @@ -69,6 +69,58 @@ def _safe_json_obj(text: str) -> dict[str, Any]: return {} +_FEEDBACK_ACTION_ALIASES = { + "not_relevant": "dislike", + "not-relevant": "dislike", + "not related": "dislike", +} +_FEEDBACK_GROUP_BY_ACTION = { + "save": "save_state", + "unsave": "save_state", + "like": "preference_state", + "unlike": "preference_state", + "dislike": "preference_state", + "undislike": "preference_state", + "skip": "preference_state", + "cite": "cite_state", +} +_FEEDBACK_EFFECTIVE_ACTIONS = { + "save": "save", + "unsave": None, + "like": "like", + "unlike": None, + "dislike": "dislike", + "undislike": None, + "skip": "skip", + "cite": "cite", +} + + +def _normalize_feedback_action(value: str) -> str: + normalized = (value or "").strip().lower().replace(" ", "_") + return _FEEDBACK_ACTION_ALIASES.get(normalized, normalized) + + +def _collapse_effective_feedback_actions(rows: list[PaperFeedbackModel]) -> list[str]: + effective_actions: list[str] = [] + seen: set[tuple[int, str]] = set() + for row in rows: + paper_id = int(row.canonical_paper_id or row.paper_ref_id or 0) + if paper_id <= 0: + continue + normalized_action = _normalize_feedback_action(str(row.action or "")) + group = _FEEDBACK_GROUP_BY_ACTION.get(normalized_action, normalized_action) + state_key = (paper_id, group) + if state_key in seen: + continue + seen.add(state_key) + + effective_action = _FEEDBACK_EFFECTIVE_ACTIONS.get(normalized_action, normalized_action) + if effective_action: + effective_actions.append(effective_action) + return effective_actions + + class AnchorService: """Discover anchor authors with intrinsic + relevance + network scoring.""" @@ -158,6 +210,7 @@ def discover( PaperFeedbackModel.paper_ref_id.in_(paper_ids), ) ) + .order_by(desc(PaperFeedbackModel.ts), desc(PaperFeedbackModel.id)) ) .scalars() .all() @@ -180,11 +233,14 @@ def discover( "dislike": -1.0, "skip": -0.3, } + effective_feedback_actions = _collapse_effective_feedback_actions(feedback_rows) raw_feedback = 0.0 - for row in feedback_rows: - raw_feedback += action_weights.get((row.action or "").lower(), 0.0) + for action in effective_feedback_actions: + raw_feedback += action_weights.get(action, 0.0) feedback_signal = ( - (math.tanh(raw_feedback / 4.0) + 1.0) / 2.0 if feedback_rows else 0.0 + (math.tanh(raw_feedback / 4.0) + 1.0) / 2.0 + if effective_feedback_actions + else 0.0 ) relevance_score = keyword_match_rate diff --git a/src/paperbot/context_engine/engine.py b/src/paperbot/context_engine/engine.py index b395133f..bedbafcc 100644 --- a/src/paperbot/context_engine/engine.py +++ b/src/paperbot/context_engine/engine.py @@ -524,7 +524,7 @@ def __init__( config=self.config.track_router, ) self._layer0_cache: Dict[str, Dict[str, Any]] = {} # keyed by user_id - self._layer0_cache_ts: Dict[str, float] = {} # keyed by user_id + self._layer0_cache_ts: Dict[str, float] = {} # keyed by user_id self._layer0_ttl: float = 300.0 # 5 minutes def _attach_latest_judge(self, papers: List[Dict[str, Any]]) -> None: @@ -591,9 +591,7 @@ def _load_layer0_profile(self, user_id: str) -> List[Dict[str, Any]]: self._layer0_cache_ts[user_id] = now return prefs - def _load_layer1_track( - self, user_id: str, track: Optional[Dict[str, Any]] - ) -> Dict[str, Any]: + def _load_layer1_track(self, user_id: str, track: Optional[Dict[str, Any]]) -> Dict[str, Any]: """Layer 1: track context — goals, keywords, tasks, milestones (~500 tokens).""" tasks: List[Dict[str, Any]] = [] milestones: List[Dict[str, Any]] = [] @@ -771,9 +769,7 @@ def _total_tokens() -> int: return prefs, task_list, milestone_list, relevant, cross_track, paper, layers, trimmed - def _load_layer3_paper( - self, user_id: str, paper_id: Optional[str] - ) -> List[Dict[str, Any]]: + def _load_layer3_paper(self, user_id: str, paper_id: Optional[str]) -> List[Dict[str, Any]]: """Layer 3: paper-scoped memories (on-demand, only when paper_id given).""" if not paper_id: return [] @@ -917,11 +913,23 @@ async def build_context_pack( if self.config.personalized and routed_track: try: - feedback_rows = self.research_store.list_paper_feedback( - user_id=user_id, - track_id=int(routed_track["id"]), - limit=500, + list_effective_feedback = getattr( + self.research_store, + "list_effective_paper_feedback", + None, ) + if callable(list_effective_feedback): + feedback_rows = list_effective_feedback( + user_id=user_id, + track_id=int(routed_track["id"]), + limit=500, + ) + else: + feedback_rows = self.research_store.list_paper_feedback( + user_id=user_id, + track_id=int(routed_track["id"]), + limit=500, + ) default_rrf_weights = getattr( self.search_service, "DEFAULT_SOURCE_WEIGHTS", diff --git a/src/paperbot/infrastructure/stores/research_store.py b/src/paperbot/infrastructure/stores/research_store.py index 3d214620..e6579e94 100644 --- a/src/paperbot/infrastructure/stores/research_store.py +++ b/src/paperbot/infrastructure/stores/research_store.py @@ -2,12 +2,14 @@ import hashlib import json +from collections import defaultdict from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List, Optional +from typing import Any, Dict, Iterable, List, Optional from sqlalchemy import desc, func, or_, select from sqlalchemy.exc import IntegrityError +from paperbot.application.ports.feedback_port import FeedbackPort from paperbot.application.services.identity_resolver import IdentityResolver from paperbot.domain.paper_identity import normalize_arxiv_id, normalize_doi from paperbot.infrastructure.stores.models import ( @@ -80,7 +82,34 @@ def _parse_datetime(value: Any) -> Optional[datetime]: return None -class SqlAlchemyResearchStore: +_FEEDBACK_ACTION_ALIASES: Dict[str, str] = { + "not_relevant": "dislike", + "not-relevant": "dislike", + "not related": "dislike", +} +_FEEDBACK_GROUP_BY_ACTION: Dict[str, str] = { + "save": "save_state", + "unsave": "save_state", + "like": "preference_state", + "unlike": "preference_state", + "dislike": "preference_state", + "undislike": "preference_state", + "skip": "preference_state", + "cite": "cite_state", +} +_FEEDBACK_EFFECTIVE_ACTIONS: Dict[str, Optional[str]] = { + "save": "save", + "unsave": None, + "like": "like", + "unlike": None, + "dislike": "dislike", + "undislike": None, + "skip": "skip", + "cite": "cite", +} + + +class SqlAlchemyResearchStore(FeedbackPort): """ Track/progress store for personalized paper recommendation. @@ -94,9 +123,70 @@ class SqlAlchemyResearchStore: def __init__(self, db_url: Optional[str] = None, *, auto_create_schema: bool = True): self.db_url = db_url or get_db_url() self._provider = SessionProvider(self.db_url) - self._identity_resolver = IdentityResolver(db_url=self.db_url) if auto_create_schema: - Base.metadata.create_all(self._provider.engine) + self._provider.ensure_tables(Base.metadata) + self._identity_resolver = IdentityResolver(db_url=self.db_url) + + @staticmethod + def _normalize_feedback_action(value: str) -> str: + normalized = (value or "").strip().lower().replace(" ", "_") + return _FEEDBACK_ACTION_ALIASES.get(normalized, normalized) + + @staticmethod + def _feedback_group(action: str) -> str: + return _FEEDBACK_GROUP_BY_ACTION.get(action, action) + + @staticmethod + def _effective_feedback_action(action: str) -> Optional[str]: + return _FEEDBACK_EFFECTIVE_ACTIONS.get(action, action or None) + + @staticmethod + def _feedback_identity_key( + *, + paper_ref_id: Optional[int], + canonical_paper_id: Optional[int], + paper_id: str, + ) -> str: + resolved_ref_id = int(canonical_paper_id or paper_ref_id or 0) + if resolved_ref_id > 0: + return f"ref:{resolved_ref_id}" + normalized_paper_id = str(paper_id or "").strip() + return f"external:{normalized_paper_id}" + + @classmethod + def _feedback_state_key( + cls, row: PaperFeedbackModel, normalized_action: str + ) -> tuple[str, str]: + return ( + cls._feedback_identity_key( + paper_ref_id=row.paper_ref_id, + canonical_paper_id=row.canonical_paper_id, + paper_id=row.paper_id, + ), + cls._feedback_group(normalized_action), + ) + + @classmethod + def _collapse_effective_feedback_rows( + cls, rows: Iterable[PaperFeedbackModel] + ) -> List[Dict[str, Any]]: + collapsed: List[Dict[str, Any]] = [] + seen: set[tuple[str, str]] = set() + for row in rows: + normalized_action = cls._normalize_feedback_action(str(row.action or "")) + state_key = cls._feedback_state_key(row, normalized_action) + if state_key in seen: + continue + seen.add(state_key) + + effective_action = cls._effective_feedback_action(normalized_action) + if effective_action is None: + continue + + payload = cls._feedback_to_dict(row) + payload["action"] = effective_action + collapsed.append(payload) + return collapsed def create_track( self, @@ -404,6 +494,7 @@ def add_paper_feedback( Logger.info("Recording paper feedback", file=LogFiles.HARVEST) now = _utcnow() metadata = dict(metadata or {}) + normalized_action = self._normalize_feedback_action(action) with self._provider.session() as session: track = session.execute( select(ResearchTrackModel).where( @@ -426,23 +517,44 @@ def add_paper_feedback( paper_id=(paper_id or "").strip(), paper_ref_id=resolved_paper_ref_id, canonical_paper_id=resolved_paper_ref_id, # dual-write - action=(action or "").strip(), + action=normalized_action, weight=float(weight or 0.0), ts=now, metadata_json=json.dumps(metadata or {}, ensure_ascii=False), ) session.add(row) - if resolved_paper_ref_id and (action or "").strip() == "save": - self._upsert_reading_status_row( - session=session, - user_id=user_id, - paper_ref_id=resolved_paper_ref_id, - status="unread", - mark_saved=True, - metadata=metadata, - now=now, - ) + if resolved_paper_ref_id: + if normalized_action == "save": + self._upsert_reading_status_row( + session=session, + user_id=user_id, + paper_ref_id=resolved_paper_ref_id, + status="unread", + mark_saved=True, + metadata=metadata, + now=now, + ) + elif normalized_action == "unsave": + existing_status = session.execute( + select(PaperReadingStatusModel).where( + PaperReadingStatusModel.user_id == user_id, + PaperReadingStatusModel.paper_id == int(resolved_paper_ref_id), + ) + ).scalar_one_or_none() + self._upsert_reading_status_row( + session=session, + user_id=user_id, + paper_ref_id=resolved_paper_ref_id, + status=( + str(existing_status.status or "unread") + if existing_status is not None + else "unread" + ), + mark_saved=False, + metadata=metadata, + now=now, + ) track.updated_at = now session.add(track) @@ -451,6 +563,37 @@ def add_paper_feedback( Logger.info("Feedback record created successfully", file=LogFiles.HARVEST) return self._feedback_to_dict(row) + def list_effective_paper_feedback( + self, + *, + user_id: str, + track_id: int, + limit: int = 200, + ) -> List[Dict[str, Any]]: + with self._provider.session() as session: + track = session.execute( + select(ResearchTrackModel).where( + ResearchTrackModel.user_id == user_id, ResearchTrackModel.id == track_id + ) + ).scalar_one_or_none() + if track is None: + return [] + + rows = ( + session.execute( + select(PaperFeedbackModel) + .where( + PaperFeedbackModel.user_id == user_id, + PaperFeedbackModel.track_id == track_id, + ) + .order_by(desc(PaperFeedbackModel.ts), desc(PaperFeedbackModel.id)) + ) + .scalars() + .all() + ) + collapsed = self._collapse_effective_feedback_rows(rows) + return collapsed[: max(1, int(limit))] + def list_paper_feedback( self, *, @@ -472,7 +615,9 @@ def list_paper_feedback( PaperFeedbackModel.user_id == user_id, PaperFeedbackModel.track_id == track_id ) if action: - stmt = stmt.where(PaperFeedbackModel.action == action) + stmt = stmt.where( + PaperFeedbackModel.action == self._normalize_feedback_action(action) + ) stmt = stmt.order_by(desc(PaperFeedbackModel.ts)).limit(limit) rows = session.execute(stmt).scalars().all() return [self._feedback_to_dict(r) for r in rows] @@ -486,9 +631,14 @@ def list_paper_feedback_ids( limit: int = 500, ) -> set[str]: ids: set[str] = set() - for row in self.list_paper_feedback( - user_id=user_id, track_id=track_id, action=action, limit=limit + normalized_action = self._normalize_feedback_action(action) + for row in self.list_effective_paper_feedback( + user_id=user_id, + track_id=track_id, + limit=limit, ): + if row.get("action") != normalized_action: + continue pid = str(row.get("paper_id") or "").strip() if pid: ids.add(pid) @@ -538,6 +688,7 @@ def list_saved_papers( ) -> List[Dict[str, Any]]: with self._provider.session() as session: saved_at_by_paper: Dict[int, datetime] = {} + saved_track_membership: Dict[int, set[int]] = defaultdict(set) status_rows = ( session.execute( @@ -555,29 +706,45 @@ def list_saved_papers( feedback_rows = ( session.execute( - select(PaperFeedbackModel).where( + select(PaperFeedbackModel) + .where( PaperFeedbackModel.user_id == user_id, - PaperFeedbackModel.action == "save", + PaperFeedbackModel.action.in_(["save", "unsave"]), PaperFeedbackModel.paper_ref_id.is_not(None), - ( - PaperFeedbackModel.track_id == int(track_id) - if track_id is not None - else True - ), ) + .order_by(desc(PaperFeedbackModel.ts), desc(PaperFeedbackModel.id)) ) .scalars() .all() ) + latest_save_state: Dict[int, tuple[bool, Optional[datetime]]] = {} for row in feedback_rows: pid = int(row.paper_ref_id or 0) if pid <= 0: continue + normalized_action = self._normalize_feedback_action(str(row.action or "")) + if normalized_action == "save": + saved_track_membership[pid].add(int(row.track_id or 0)) + if pid not in latest_save_state: + latest_save_state[pid] = (normalized_action == "save", row.ts) + + for pid, (is_saved, ts) in latest_save_state.items(): + if not is_saved: + saved_at_by_paper.pop(pid, None) + continue current = saved_at_by_paper.get(pid) - if current is None or (row.ts and row.ts > current): - saved_at_by_paper[pid] = row.ts or _utcnow() + if current is None or ((ts or _utcnow()) > current): + saved_at_by_paper[pid] = ts or _utcnow() - paper_ids = list(saved_at_by_paper.keys()) + if track_id is not None: + scoped_paper_ids = [ + pid + for pid in saved_at_by_paper.keys() + if int(track_id) in saved_track_membership.get(pid, set()) + ] + paper_ids = scoped_paper_ids + else: + paper_ids = list(saved_at_by_paper.keys()) if not paper_ids: return [] @@ -719,7 +886,18 @@ def list_track_feed( .where( PaperFeedbackModel.user_id == user_id, PaperFeedbackModel.track_id == int(track_id), - PaperFeedbackModel.action.in_(["save", "like", "dislike", "skip"]), + PaperFeedbackModel.action.in_( + [ + "save", + "unsave", + "like", + "unlike", + "dislike", + "undislike", + "skip", + "cite", + ] + ), ) .order_by(desc(PaperFeedbackModel.ts), desc(PaperFeedbackModel.id)) ) @@ -762,18 +940,53 @@ def list_track_feed( candidate_ids = [int(p.id) for p in candidates] - feedback_by_paper: Dict[int, PaperFeedbackModel] = {} feedback_summary_by_paper: Dict[int, Dict[str, int]] = {} + feedback_state_by_paper: Dict[int, Dict[str, Any]] = {} + resolved_feedback_groups: set[tuple[int, str]] = set() for row in feedback_rows: pid = int(row.canonical_paper_id or row.paper_ref_id or 0) if pid <= 0: continue - action = str(row.action or "").strip().lower() - if action: + normalized_action = self._normalize_feedback_action(str(row.action or "")) + if normalized_action: action_counter = feedback_summary_by_paper.setdefault(pid, {}) - action_counter[action] = action_counter.get(action, 0) + 1 - if pid not in feedback_by_paper: - feedback_by_paper[pid] = row + action_counter[normalized_action] = action_counter.get(normalized_action, 0) + 1 + + group = self._feedback_group(normalized_action) + group_key = (pid, group) + if group_key in resolved_feedback_groups: + continue + resolved_feedback_groups.add(group_key) + + effective_action = self._effective_feedback_action(normalized_action) + if effective_action is None: + continue + + state = feedback_state_by_paper.setdefault( + pid, + { + "is_saved": False, + "is_liked": False, + "is_disliked": False, + "latest_effective_action": None, + "latest_effective_ts": None, + }, + ) + + if effective_action == "save": + state["is_saved"] = True + elif effective_action == "like": + state["is_liked"] = True + state["is_disliked"] = False + elif effective_action == "dislike": + state["is_disliked"] = True + state["is_liked"] = False + + event_ts = row.ts or _utcnow() + latest_effective_ts = state.get("latest_effective_ts") + if latest_effective_ts is None or event_ts >= latest_effective_ts: + state["latest_effective_action"] = effective_action + state["latest_effective_ts"] = event_ts status_by_paper = { int(row.paper_id): row @@ -818,16 +1031,19 @@ def list_track_feed( matched_terms = [term for term in terms if term and term in text_blob] keyword_score = float(len(matched_terms)) - latest_feedback = feedback_by_paper.get(pid) + feedback_state = feedback_state_by_paper.get(pid, {}) latest_feedback_action = ( - str(latest_feedback.action or "").strip().lower() if latest_feedback else "" + str(feedback_state.get("latest_effective_action") or "").strip().lower() ) - feedback_boost = { - "save": 3.0, - "like": 2.0, - "skip": -1.0, - "dislike": -4.0, - }.get(latest_feedback_action, 0.0) + feedback_boost = 0.0 + if bool(feedback_state.get("is_saved")): + feedback_boost += 3.0 + if bool(feedback_state.get("is_liked")): + feedback_boost += 2.0 + if bool(feedback_state.get("is_disliked")): + feedback_boost -= 4.0 + if latest_feedback_action == "skip": + feedback_boost -= 1.0 citation_score = min(float(paper.citation_count or 0) / 200.0, 2.0) judge_row = latest_judge_by_paper.get(pid) @@ -853,6 +1069,9 @@ def list_track_feed( else None ), "latest_feedback_action": latest_feedback_action or None, + "is_saved": bool(feedback_state.get("is_saved")), + "is_liked": bool(feedback_state.get("is_liked")), + "is_disliked": bool(feedback_state.get("is_disliked")), "feedback_summary": feedback_summary_by_paper.get(pid, {}), "matched_terms": matched_terms, "keyword_score": keyword_score, @@ -1991,6 +2210,7 @@ def _feedback_to_dict(f: PaperFeedbackModel) -> Dict[str, Any]: "track_id": f.track_id, "paper_id": f.paper_id, "paper_ref_id": f.paper_ref_id, + "canonical_paper_id": f.canonical_paper_id, "action": f.action, "weight": float(f.weight or 0.0), "ts": f.ts.isoformat() if f.ts else None, diff --git a/tests/unit/test_anchor_service.py b/tests/unit/test_anchor_service.py index 3221db08..e3ad252f 100644 --- a/tests/unit/test_anchor_service.py +++ b/tests/unit/test_anchor_service.py @@ -7,7 +7,10 @@ import pytest from sqlalchemy import select -from paperbot.application.services.anchor_service import AnchorService +from paperbot.application.services.anchor_service import ( + AnchorService, + _collapse_effective_feedback_actions, +) from paperbot.infrastructure.stores.author_store import AuthorStore from paperbot.infrastructure.stores.models import ( AuthorModel, @@ -153,11 +156,54 @@ def test_anchor_service_discovers_and_scores_authors(tmp_path: Path): def test_anchor_service_raises_for_unknown_track(tmp_path: Path): db_url = f"sqlite:///{tmp_path / 'anchor-track-missing.db'}" + provider = SessionProvider(db_url) + Base.metadata.create_all(provider.engine) service = AnchorService(db_url=db_url) with pytest.raises(ValueError, match="track not found"): service.discover(track_id=999, user_id="default") +def test_collapse_effective_feedback_actions_ignores_toggled_off_state() -> None: + now = datetime.now(timezone.utc) + rows = [ + PaperFeedbackModel( + user_id="u1", + track_id=1, + paper_id="42", + paper_ref_id=42, + canonical_paper_id=42, + action="unlike", + weight=0.0, + ts=now, + metadata_json="{}", + ), + PaperFeedbackModel( + user_id="u1", + track_id=1, + paper_id="42", + paper_ref_id=42, + canonical_paper_id=42, + action="like", + weight=0.0, + ts=now, + metadata_json="{}", + ), + PaperFeedbackModel( + user_id="u1", + track_id=1, + paper_id="84", + paper_ref_id=84, + canonical_paper_id=84, + action="dislike", + weight=0.0, + ts=now, + metadata_json="{}", + ), + ] + + assert _collapse_effective_feedback_actions(rows) == ["dislike"] + + def test_recompute_author_network_scores_updates_metadata(tmp_path: Path): db_url = f"sqlite:///{tmp_path / 'anchor-network-recompute.db'}" paper_store = PaperStore(db_url=db_url) @@ -204,3 +250,33 @@ def test_recompute_author_network_scores_updates_metadata(tmp_path: Path): found += 1 assert metadata["network_score"] >= 0 assert found >= 3 + + +def test_cleared_feedback_does_not_contribute_to_anchor_personalization() -> None: + now = datetime.now(timezone.utc) + rows = [ + PaperFeedbackModel( + user_id="default", + track_id=1, + paper_id="paper-1", + paper_ref_id=1, + canonical_paper_id=1, + action="unlike", + weight=0.0, + ts=now, + metadata_json="{}", + ), + PaperFeedbackModel( + user_id="default", + track_id=1, + paper_id="paper-1", + paper_ref_id=1, + canonical_paper_id=1, + action="like", + weight=0.0, + ts=now, + metadata_json="{}", + ), + ] + + assert _collapse_effective_feedback_actions(rows) == [] diff --git a/tests/unit/test_context_engine_personalized_mode.py b/tests/unit/test_context_engine_personalized_mode.py index c65b1516..5e46c531 100644 --- a/tests/unit/test_context_engine_personalized_mode.py +++ b/tests/unit/test_context_engine_personalized_mode.py @@ -96,7 +96,7 @@ async def test_personalized_mode_applies_saved_and_anchor_boosts(monkeypatch): async def test_global_mode_disables_personalization_boosts(monkeypatch): monkeypatch.setattr(engine_module, "_get_anchor_service", lambda: _FakeAnchorService()) - engine = ContextEngine( + global_engine = ContextEngine( research_store=_FakeResearchStore(), memory_store=_FakeMemoryStore(), paper_store=None, @@ -104,8 +104,23 @@ async def test_global_mode_disables_personalization_boosts(monkeypatch): track_router=_FakeTrackRouter(), config=ContextEngineConfig(personalized=False, paper_limit=5), ) + personalized_engine = ContextEngine( + research_store=_FakeResearchStore(), + memory_store=_FakeMemoryStore(), + paper_store=None, + search_service=_FakeSearchService(), + track_router=_FakeTrackRouter(), + config=ContextEngineConfig(personalized=True, paper_limit=5), + ) - pack = await engine.build_context_pack(user_id="u1", query="transformer", track_id=1) - score = float(pack["paper_recommendation_scores"]["1"]) + global_pack = await global_engine.build_context_pack(user_id="u1", query="transformer", track_id=1) + personalized_pack = await personalized_engine.build_context_pack( + user_id="u1", + query="transformer", + track_id=1, + ) + global_score = float(global_pack["paper_recommendation_scores"]["1"]) + personalized_score = float(personalized_pack["paper_recommendation_scores"]["1"]) - assert score < 0.30 + assert global_score < personalized_score + assert (personalized_score - global_score) > 0.35 diff --git a/tests/unit/test_feed_ranking.py b/tests/unit/test_feed_ranking.py index 3a0093d2..79379cb2 100644 --- a/tests/unit/test_feed_ranking.py +++ b/tests/unit/test_feed_ranking.py @@ -71,29 +71,33 @@ def test_saved_papers_rank_higher_than_skipped(tmp_path: Path): ) # Add "save" feedback for saved_pid - session.add(PaperFeedbackModel( - user_id=user_id, - track_id=track_id, - paper_id=str(saved_pid), - paper_ref_id=saved_pid, - canonical_paper_id=saved_pid, - action="save", - weight=0.0, - ts=now, - metadata_json="{}", - )) + session.add( + PaperFeedbackModel( + user_id=user_id, + track_id=track_id, + paper_id=str(saved_pid), + paper_ref_id=saved_pid, + canonical_paper_id=saved_pid, + action="save", + weight=0.0, + ts=now, + metadata_json="{}", + ) + ) # Add "skip" feedback for skipped_pid - session.add(PaperFeedbackModel( - user_id=user_id, - track_id=track_id, - paper_id=str(skipped_pid), - paper_ref_id=skipped_pid, - canonical_paper_id=skipped_pid, - action="skip", - weight=0.0, - ts=now, - metadata_json="{}", - )) + session.add( + PaperFeedbackModel( + user_id=user_id, + track_id=track_id, + paper_id=str(skipped_pid), + paper_ref_id=skipped_pid, + canonical_paper_id=skipped_pid, + action="skip", + weight=0.0, + ts=now, + metadata_json="{}", + ) + ) session.commit() result = store.list_track_feed(user_id=user_id, track_id=track_id, limit=10, offset=0) @@ -113,3 +117,112 @@ def test_saved_papers_rank_higher_than_skipped(tmp_path: Path): f"Saved paper score ({scores_by_pid[saved_pid]}) should be higher " f"than skipped paper score ({scores_by_pid[skipped_pid]})" ) + + +def test_track_feed_restores_saved_state_after_preference_is_cleared(tmp_path: Path): + """Clearing a preference should preserve an independent save state.""" + db_url = f"sqlite:///{tmp_path / 'feed-effective-state.db'}" + store = SqlAlchemyResearchStore(db_url=db_url) + + user_id = "test-user" + track = store.create_track( + user_id=user_id, + name="ML Research", + keywords=["machine learning"], + activate=True, + ) + track_id = int(track["id"]) + + with store._provider.session() as session: + paper_id = _insert_paper( + session, + title="Effective Feedback State Paper", + keywords=["machine learning", "optimization"], + ) + session.commit() + + store.add_paper_feedback( + user_id=user_id, + track_id=track_id, + paper_id=str(paper_id), + action="save", + ) + store.add_paper_feedback( + user_id=user_id, + track_id=track_id, + paper_id=str(paper_id), + action="like", + ) + store.add_paper_feedback( + user_id=user_id, + track_id=track_id, + paper_id=str(paper_id), + action="unlike", + ) + + result = store.list_track_feed(user_id=user_id, track_id=track_id, limit=10, offset=0) + item = next(row for row in result["items"] if int(row["paper"]["id"]) == paper_id) + + assert item["is_saved"] is True + assert item["is_liked"] is False + assert item["is_disliked"] is False + assert item["latest_feedback_action"] == "save" + + +def test_track_feed_keeps_save_and_like_as_independent_flags(tmp_path: Path): + db_url = f"sqlite:///{tmp_path / 'feed-flags.db'}" + store = SqlAlchemyResearchStore(db_url=db_url) + + user_id = "stateful-user" + track = store.create_track( + user_id=user_id, + name="Agents", + keywords=["agent"], + activate=True, + ) + track_id = int(track["id"]) + + with store._provider.session() as session: + paper_id = _insert_paper( + session, + title="Agent Planning Systems", + keywords=["agent", "planning"], + ) + session.commit() + + store.add_paper_feedback( + user_id=user_id, + track_id=track_id, + paper_id=str(paper_id), + action="save", + metadata={"title": "Agent Planning Systems"}, + ) + store.add_paper_feedback( + user_id=user_id, + track_id=track_id, + paper_id=str(paper_id), + action="like", + metadata={"title": "Agent Planning Systems"}, + ) + + first = store.list_track_feed(user_id=user_id, track_id=track_id, limit=10, offset=0) + item = next(row for row in first["items"] if int(row["paper"]["id"]) == paper_id) + assert item["is_saved"] is True + assert item["is_liked"] is True + assert item["is_disliked"] is False + assert item["latest_feedback_action"] == "like" + + store.add_paper_feedback( + user_id=user_id, + track_id=track_id, + paper_id=str(paper_id), + action="unlike", + metadata={"title": "Agent Planning Systems"}, + ) + + second = store.list_track_feed(user_id=user_id, track_id=track_id, limit=10, offset=0) + item = next(row for row in second["items"] if int(row["paper"]["id"]) == paper_id) + assert item["is_saved"] is True + assert item["is_liked"] is False + assert item["is_disliked"] is False + assert item["latest_feedback_action"] == "save" diff --git a/tests/unit/test_paper_judge_persistence.py b/tests/unit/test_paper_judge_persistence.py index cd80dca3..4576ffb1 100644 --- a/tests/unit/test_paper_judge_persistence.py +++ b/tests/unit/test_paper_judge_persistence.py @@ -6,7 +6,7 @@ from sqlalchemy import select from paperbot.domain.identity import PaperIdentity -from paperbot.infrastructure.stores.models import PaperJudgeScoreModel +from paperbot.infrastructure.stores.models import PaperJudgeScoreModel, PaperReadingStatusModel from paperbot.infrastructure.stores.paper_store import SqlAlchemyPaperStore from paperbot.infrastructure.stores.research_store import SqlAlchemyResearchStore from paperbot.infrastructure.stores.identity_store import IdentityStore @@ -140,6 +140,126 @@ def test_saved_list_and_detail_from_research_store(tmp_path: Path): assert detail["reading_status"]["status"] == "read" +def test_unsave_removes_saved_state_from_library(tmp_path: Path): + db_path = tmp_path / "saved-unsave.db" + db_url = f"sqlite:///{db_path}" + + paper_store = SqlAlchemyPaperStore(db_url=db_url) + research_store = SqlAlchemyResearchStore(db_url=db_url) + + paper = paper_store.upsert_paper( + paper={ + "title": "Toggle Save Paper", + "url": "https://example.com/toggle-save", + "pdf_url": "https://example.com/toggle-save.pdf", + } + ) + + track = research_store.create_track(user_id="u-save", name="track-save", activate=True) + research_store.add_paper_feedback( + user_id="u-save", + track_id=int(track["id"]), + paper_id=str(paper["id"]), + action="save", + metadata={"title": "Toggle Save Paper"}, + ) + + assert len(research_store.list_saved_papers(user_id="u-save", limit=10)) == 1 + + research_store.add_paper_feedback( + user_id="u-save", + track_id=int(track["id"]), + paper_id=str(paper["id"]), + action="unsave", + metadata={"title": "Toggle Save Paper"}, + ) + + assert research_store.list_saved_papers(user_id="u-save", limit=10) == [] + + with research_store._provider.session() as session: + status = session.execute( + select(PaperReadingStatusModel).where( + PaperReadingStatusModel.user_id == "u-save", + PaperReadingStatusModel.paper_id == int(paper["id"]), + ) + ).scalar_one_or_none() + + assert status is not None + assert status.saved_at is None + + +def test_feedback_ids_follow_effective_toggle_state(tmp_path: Path): + db_path = tmp_path / "feedback-effective-state.db" + db_url = f"sqlite:///{db_path}" + + paper_store = SqlAlchemyPaperStore(db_url=db_url) + research_store = SqlAlchemyResearchStore(db_url=db_url) + + paper = paper_store.upsert_paper( + paper={ + "title": "Toggle Reaction Paper", + "url": "https://example.com/toggle-reaction", + "pdf_url": "https://example.com/toggle-reaction.pdf", + } + ) + + track = research_store.create_track(user_id="u-react", name="track-react", activate=True) + track_id = int(track["id"]) + paper_id = str(paper["id"]) + + research_store.add_paper_feedback( + user_id="u-react", + track_id=track_id, + paper_id=paper_id, + action="like", + metadata={"title": "Toggle Reaction Paper"}, + ) + assert research_store.list_paper_feedback_ids( + user_id="u-react", track_id=track_id, action="like" + ) == {paper_id} + + research_store.add_paper_feedback( + user_id="u-react", + track_id=track_id, + paper_id=paper_id, + action="unlike", + metadata={"title": "Toggle Reaction Paper"}, + ) + assert ( + research_store.list_paper_feedback_ids(user_id="u-react", track_id=track_id, action="like") + == set() + ) + + research_store.add_paper_feedback( + user_id="u-react", + track_id=track_id, + paper_id=paper_id, + action="dislike", + metadata={"title": "Toggle Reaction Paper"}, + ) + assert research_store.list_paper_feedback_ids( + user_id="u-react", track_id=track_id, action="dislike" + ) == {paper_id} + assert ( + research_store.list_paper_feedback_ids(user_id="u-react", track_id=track_id, action="like") + == set() + ) + + research_store.add_paper_feedback( + user_id="u-react", + track_id=track_id, + paper_id=paper_id, + action="undislike", + metadata={"title": "Toggle Reaction Paper"}, + ) + assert ( + research_store.list_paper_feedback_ids( + user_id="u-react", track_id=track_id, action="dislike" + ) + == set() + ) + + def test_feedback_resolves_via_identity_store_mapping(tmp_path: Path): db_path = tmp_path / "identity-feedback.db" db_url = f"sqlite:///{db_path}" diff --git a/tests/unit/test_research_feedback_state.py b/tests/unit/test_research_feedback_state.py new file mode 100644 index 00000000..13180b40 --- /dev/null +++ b/tests/unit/test_research_feedback_state.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi.testclient import TestClient + +from paperbot.api import main as api_main +from paperbot.api.routes import research as research_route +from paperbot.infrastructure.stores.paper_store import SqlAlchemyPaperStore +from paperbot.infrastructure.stores.research_store import SqlAlchemyResearchStore + + +def _prepare_feedback_state_db(tmp_path: Path) -> tuple[SqlAlchemyResearchStore, dict, dict]: + db_path = tmp_path / "feedback-state.db" + db_url = f"sqlite:///{db_path}" + + paper_store = SqlAlchemyPaperStore(db_url=db_url) + research_store = SqlAlchemyResearchStore(db_url=db_url) + + paper = paper_store.upsert_paper( + paper={ + "title": "Transformer Alignment in Practice", + "abstract": "transformer alignment retrieval analysis", + "url": "https://example.com/paper", + } + ) + track = research_store.create_track( + user_id="u-feedback", + name="feedback-track", + keywords=["transformer"], + activate=True, + ) + return research_store, paper, track + + +def test_feedback_route_returns_effective_action_after_toggle(tmp_path, monkeypatch): + store, paper, track = _prepare_feedback_state_db(tmp_path) + monkeypatch.setattr(research_route, "_research_store", store) + + with TestClient(api_main.app) as client: + liked = client.post( + "/api/research/papers/feedback", + json={ + "user_id": "u-feedback", + "track_id": int(track["id"]), + "paper_id": str(paper["id"]), + "action": "like", + }, + ) + cleared = client.post( + "/api/research/papers/feedback", + json={ + "user_id": "u-feedback", + "track_id": int(track["id"]), + "paper_id": str(paper["id"]), + "action": "unlike", + }, + ) + + assert liked.status_code == 200 + assert liked.json()["current_action"] == "like" + + assert cleared.status_code == 200 + assert cleared.json()["current_action"] is None + + +def test_like_keeps_saved_state_while_updating_effective_preference_ids(tmp_path: Path): + store, paper, track = _prepare_feedback_state_db(tmp_path) + track_id = int(track["id"]) + paper_id = str(paper["id"]) + + store.add_paper_feedback( + user_id="u-feedback", + track_id=track_id, + paper_id=paper_id, + action="save", + metadata={"title": paper["title"]}, + ) + saved_before = store.list_saved_papers(user_id="u-feedback", track_id=track_id) + assert len(saved_before) == 1 + + store.add_paper_feedback( + user_id="u-feedback", + track_id=track_id, + paper_id=paper_id, + action="like", + metadata={}, + ) + + saved_after = store.list_saved_papers(user_id="u-feedback", track_id=track_id) + assert len(saved_after) == 1 + assert store.list_paper_feedback_ids( + user_id="u-feedback", + track_id=track_id, + action="save", + ) == {paper_id} + assert store.list_paper_feedback_ids( + user_id="u-feedback", + track_id=track_id, + action="like", + ) == {paper_id} + + +def test_unsave_clears_feed_saved_flags(tmp_path: Path): + store, paper, track = _prepare_feedback_state_db(tmp_path) + track_id = int(track["id"]) + paper_id = int(paper["id"]) + + store.add_paper_feedback( + user_id="u-feedback", + track_id=track_id, + paper_id=str(paper_id), + action="save", + metadata={"title": paper["title"]}, + ) + store.add_paper_feedback( + user_id="u-feedback", + track_id=track_id, + paper_id=str(paper_id), + action="unsave", + metadata={}, + ) + + feed = store.list_track_feed(user_id="u-feedback", track_id=track_id, limit=10, offset=0) + item = next(row for row in feed["items"] if int(row["paper"]["id"]) == paper_id) + + assert item["latest_feedback_action"] is None + assert item["is_saved"] is False diff --git a/web/src/components/research/FeedTab.tsx b/web/src/components/research/FeedTab.tsx index c02d7242..0dad48af 100644 --- a/web/src/components/research/FeedTab.tsx +++ b/web/src/components/research/FeedTab.tsx @@ -5,6 +5,11 @@ import { Loader2, RefreshCw } from "lucide-react" import { Button } from "@/components/ui/button" import { getErrorMessage } from "@/lib/fetch" +import { + normalizePaperPreferenceAction, + type PaperFeedbackAction, + type PaperFeedbackRequestAction, +} from "@/lib/paper-feedback" import { Card, CardContent } from "@/components/ui/card" import { PaperCard, type Paper } from "./PaperCard" @@ -22,6 +27,9 @@ type FeedItem = { } latest_judge?: Paper["latest_judge"] latest_feedback_action?: string | null + is_saved?: boolean + is_liked?: boolean + is_disliked?: boolean } type FeedResponse = { @@ -34,13 +42,19 @@ type FeedResponse = { interface FeedTabProps { userId: string trackId: number | null - onLike?: (paperId: string, rank: number) => Promise | void - onSave?: (paperId: string, rank: number, paper: Paper) => Promise | void - onDislike?: (paperId: string, rank: number) => Promise | void + onFeedbackAction?: ( + paperId: string, + action: PaperFeedbackRequestAction, + rank: number, + paper: Paper + ) => Promise | PaperFeedbackAction | null | undefined } function toPaper(item: FeedItem): Paper { const id = String(item.paper.id || "") + const preferenceAction = + normalizePaperPreferenceAction(item.latest_feedback_action) || + (item.is_liked ? "like" : item.is_disliked ? "dislike" : null) return { paper_id: id, title: item.paper.title || "Untitled", @@ -51,11 +65,14 @@ function toPaper(item: FeedItem): Paper { citation_count: item.paper.citation_count || 0, url: item.paper.url, latest_judge: item.latest_judge, - is_saved: (item.latest_feedback_action || "").toLowerCase() === "save", + feedback_action: preferenceAction, + is_saved: Boolean(item.is_saved), + is_liked: preferenceAction === "like", + is_disliked: preferenceAction === "dislike", } } -export function FeedTab({ userId, trackId, onLike, onSave, onDislike }: FeedTabProps) { +export function FeedTab({ userId, trackId, onFeedbackAction }: FeedTabProps) { const [items, setItems] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -125,9 +142,11 @@ export function FeedTab({ userId, trackId, onLike, onSave, onDislike }: FeedTabP key={`${paper.paper_id}-${idx}`} paper={paper} rank={idx} - onLike={onLike ? () => onLike(paper.paper_id, idx) : undefined} - onSave={onSave ? () => onSave(paper.paper_id, idx, paper) : undefined} - onDislike={onDislike ? () => onDislike(paper.paper_id, idx) : undefined} + onFeedbackAction={ + onFeedbackAction + ? (action) => onFeedbackAction(paper.paper_id, action, idx, paper) + : undefined + } /> ))} diff --git a/web/src/components/research/PaperCard.tsx b/web/src/components/research/PaperCard.tsx index 6e920d3a..34f9997d 100644 --- a/web/src/components/research/PaperCard.tsx +++ b/web/src/components/research/PaperCard.tsx @@ -3,6 +3,14 @@ import { useEffect, useState } from "react" import { Check, ChevronDown, ChevronRight, ExternalLink, FlaskConical, Database, CheckCircle, AlertTriangle, Heart, Loader2, Save, ThumbsDown } from "lucide-react" +import { + normalizePaperPreferenceAction, + togglePaperPreferenceAction, + toggleSaveFeedbackAction, + type PaperFeedbackAction, + type PaperPreferenceAction, + type PaperFeedbackRequestAction, +} from "@/lib/paper-feedback" import { cn, safeHref } from "@/lib/utils" import { ReasoningBlock, ToolActionsGroup } from "@/components/ai-elements" import { Badge } from "@/components/ui/badge" @@ -24,6 +32,9 @@ export type Paper = { evidence_quotes?: Array<{ text: string; source_url?: string; page_hint?: string }> } is_saved?: boolean + is_liked?: boolean + is_disliked?: boolean + feedback_action?: PaperFeedbackAction | null retrieval_sources?: string[] retrieval_score?: number source?: string @@ -39,30 +50,56 @@ interface PaperCardProps { paper: Paper rank?: number reasons?: string[] - onLike?: () => Promise | void - onSave?: () => Promise | void - onDislike?: () => Promise | void + onFeedbackAction?: ( + action: PaperFeedbackRequestAction + ) => Promise | PaperFeedbackAction | null | undefined isLoading?: boolean className?: string } +function derivePreferenceAction( + feedbackAction: Paper["feedback_action"], + isLiked: Paper["is_liked"], + isDisliked: Paper["is_disliked"] +): PaperPreferenceAction | null { + const explicitAction = normalizePaperPreferenceAction(feedbackAction) + if (explicitAction) { + return explicitAction + } + if (isLiked) { + return "like" + } + if (isDisliked) { + return "dislike" + } + return null +} + export function PaperCard({ paper, rank, reasons, - onLike, - onSave, - onDislike, + onFeedbackAction, isLoading = false, className, }: PaperCardProps) { const [isSaved, setIsSaved] = useState(Boolean(paper.is_saved)) + const derivedPreferenceAction = derivePreferenceAction( + paper.feedback_action, + paper.is_liked, + paper.is_disliked + ) + const [preferenceAction, setPreferenceAction] = useState( + derivedPreferenceAction + ) useEffect(() => { setIsSaved(Boolean(paper.is_saved)) - }, [paper.is_saved]) - const [isLiked, setIsLiked] = useState(false) - const [isDisliked, setIsDisliked] = useState(false) + setPreferenceAction(derivedPreferenceAction) + }, [derivedPreferenceAction, paper.is_saved]) + + const isLiked = preferenceAction === "like" + const isDisliked = preferenceAction === "dislike" const [actionLoading, setActionLoading] = useState(null) const [evidenceOpen, setEvidenceOpen] = useState(false) const [cardOpen, setCardOpen] = useState(false) @@ -77,54 +114,40 @@ export function PaperCard({ const judgeRec = String(judge?.recommendation || "").replace(/_/g, " ") const evidenceQuotes = judge?.evidence_quotes || [] - const handleSave = async () => { - if (!onSave) return - // Toggle local saved state; only call onSave when transitioning from unsaved -> saved. - if (isSaved) { - setIsSaved(false) - return - } + const runSaveAction = async () => { + if (!onFeedbackAction) return + const requestAction = toggleSaveFeedbackAction(isSaved) setActionLoading("save") try { - await onSave() - setIsSaved(true) + await onFeedbackAction(requestAction) + setIsSaved((prev) => !prev) } finally { setActionLoading(null) } } - const handleLike = async () => { - if (!onLike) return - // Toggle like; when turning off, only update local state. - if (isLiked) { - setIsLiked(false) - return - } - setActionLoading("like") + const runPreferenceAction = async (targetAction: PaperPreferenceAction) => { + if (!onFeedbackAction) return + const requestAction = togglePaperPreferenceAction(preferenceAction, targetAction) + setActionLoading(targetAction) try { - await onLike() - setIsLiked(true) - setIsDisliked(false) + await onFeedbackAction(requestAction) + setPreferenceAction(requestAction === targetAction ? targetAction : null) } finally { setActionLoading(null) } } + const handleSave = async () => { + await runSaveAction() + } + + const handleLike = async () => { + await runPreferenceAction("like") + } + const handleDislike = async () => { - if (!onDislike) return - // Toggle dislike; when turning off, only update local state. - if (isDisliked) { - setIsDisliked(false) - return - } - setActionLoading("dislike") - try { - await onDislike() - setIsDisliked(true) - setIsLiked(false) - } finally { - setActionLoading(null) - } + await runPreferenceAction("dislike") } const handleToggleCard = async () => { @@ -324,7 +347,7 @@ export function PaperCard({ className="pt-1" ariaLabel="Paper actions" actions={[ - ...(onSave + ...(onFeedbackAction ? [ { id: "save", @@ -352,7 +375,7 @@ export function PaperCard({ }, ] : []), - ...(onLike + ...(onFeedbackAction ? [ { id: "like", @@ -370,7 +393,7 @@ export function PaperCard({ }, ] : []), - ...(onDislike + ...(onFeedbackAction ? [ { id: "dislike", diff --git a/web/src/components/research/ResearchPageNew.tsx b/web/src/components/research/ResearchPageNew.tsx index 190e9584..99479db8 100644 --- a/web/src/components/research/ResearchPageNew.tsx +++ b/web/src/components/research/ResearchPageNew.tsx @@ -4,9 +4,14 @@ import { useEffect, useMemo, useState } from "react" import Link from "next/link" import { useSearchParams } from "next/navigation" -import { cn, mergeTracksStable } from "@/lib/utils" +import { + currentFeedbackFromRequestAction, + normalizePaperFeedbackAction, + type PaperFeedbackAction, + type PaperFeedbackRequestAction, +} from "@/lib/paper-feedback" +import { cn } from "@/lib/utils" import { fetchJson, getErrorMessage } from "@/lib/fetch" -import { showDiscoveryLink } from "@/config/features" import { ArrowRight, BookOpen } from "lucide-react" import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -129,9 +134,8 @@ export default function ResearchPageNew() { const data = await fetchJson<{ tracks: Track[] }>( `/api/research/tracks?user_id=${encodeURIComponent(userId)}` ) - const tracksFromApi = data.tracks || [] - setTracks((prev) => mergeTracksStable(prev, tracksFromApi)) - const active = tracksFromApi.find((t) => t.is_active) + setTracks(data.tracks || []) + const active = data.tracks.find((t) => t.is_active) const activeId = active?.id ?? null setActiveTrackId(activeId) return activeId @@ -359,10 +363,10 @@ export default function ResearchPageNew() { async function handleFeedback( paperId: string, - action: string, + action: PaperFeedbackRequestAction, rank?: number, paper?: Paper - ): Promise { + ): Promise { // Don't set global loading - PaperCard handles its own loading state setError(null) const body: Record = { @@ -395,11 +399,12 @@ export default function ResearchPageNew() { body.paper_source = paper.source || "semantic_scholar" } - await fetchJson(`/api/research/papers/feedback`, { + const payload = await fetchJson<{ current_action?: string | null }>(`/api/research/papers/feedback`, { method: "POST", body: JSON.stringify(body), headers: { "Content-Type": "application/json" }, }) + return normalizePaperFeedbackAction(payload.current_action) ?? currentFeedbackFromRequestAction(action) } const trackToClearName = tracks.find((t) => t.id === trackToClear)?.name || "this track" @@ -411,6 +416,19 @@ export default function ResearchPageNew() { return qs ? `/research/discovery?${qs}` : "/research/discovery" }, [query, activeTrackId]) + const communityRadarHref = useMemo(() => { + const params = new URLSearchParams() + if (activeTrackId) params.set("radar_track", String(activeTrackId)) + + const keywordSeed = query.trim() || activeTrack?.keywords?.[0] || "" + if (keywordSeed) { + params.set("radar_keyword", keywordSeed) + } + + const qs = params.toString() + return qs ? `/dashboard?${qs}` : "/dashboard" + }, [query, activeTrack, activeTrackId]) + return (
Sources: {searchSources.length} Results: {papers.length}
- {showDiscoveryLink() && ( +
+ - )} +
@@ -597,9 +621,9 @@ export default function ResearchPageNew() { hasSearched={hasSearched} selectedSources={searchSources} onToggleSource={toggleSearchSource} - onLike={(paperId, rank, paper) => handleFeedback(paperId, "like", rank, paper)} - onSave={(paperId, rank, paper) => handleFeedback(paperId, "save", rank, paper)} - onDislike={(paperId, rank, paper) => handleFeedback(paperId, "dislike", rank, paper)} + onFeedbackAction={(paperId, action, rank, paper) => + handleFeedback(paperId, action, rank, paper) + } /> )} diff --git a/web/src/components/research/SearchResults.tsx b/web/src/components/research/SearchResults.tsx index 44db4e4d..99b7879c 100644 --- a/web/src/components/research/SearchResults.tsx +++ b/web/src/components/research/SearchResults.tsx @@ -2,6 +2,10 @@ import { Loader2 } from "lucide-react" +import { + type PaperFeedbackAction, + type PaperFeedbackRequestAction, +} from "@/lib/paper-feedback" import { cn } from "@/lib/utils" import { Card, CardContent } from "@/components/ui/card" import { Skeleton } from "@/components/ui/skeleton" @@ -16,9 +20,12 @@ interface SearchResultsProps { className?: string selectedSources?: string[] onToggleSource?: (source: string) => void - onLike?: (paperId: string, rank: number, paper: Paper) => Promise | void - onSave?: (paperId: string, rank: number, paper: Paper) => Promise | void - onDislike?: (paperId: string, rank: number, paper: Paper) => Promise | void + onFeedbackAction?: ( + paperId: string, + action: PaperFeedbackRequestAction, + rank: number, + paper: Paper + ) => Promise | PaperFeedbackAction | null | undefined } const SOURCE_OPTIONS: Array<{ value: string; label: string }> = [ @@ -60,9 +67,7 @@ export function SearchResults({ className, selectedSources = ["semantic_scholar"], onToggleSource, - onLike, - onSave, - onDislike, + onFeedbackAction, }: SearchResultsProps) { // Not searched yet - show nothing if (!hasSearched) { @@ -143,9 +148,11 @@ export function SearchResults({ paper={paper} rank={idx} reasons={reasons?.[paper.paper_id]} - onLike={onLike ? () => onLike(paper.paper_id, idx, paper) : undefined} - onSave={onSave ? () => onSave(paper.paper_id, idx, paper) : undefined} - onDislike={onDislike ? () => onDislike(paper.paper_id, idx, paper) : undefined} + onFeedbackAction={ + onFeedbackAction + ? (action) => onFeedbackAction(paper.paper_id, action, idx, paper) + : undefined + } className={cn( "animate-in fade-in slide-in-from-bottom-2", // Staggered animation delay diff --git a/web/src/lib/paper-feedback.test.ts b/web/src/lib/paper-feedback.test.ts new file mode 100644 index 00000000..eb016b74 --- /dev/null +++ b/web/src/lib/paper-feedback.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest" + +import { + currentFeedbackFromRequestAction, + normalizePaperFeedbackAction, + normalizePaperPreferenceAction, + togglePaperPreferenceAction, + toggleSaveFeedbackAction, +} from "./paper-feedback" + +describe("paper-feedback", () => { + it("normalizes persisted feedback actions into active UI state", () => { + expect(normalizePaperFeedbackAction("save")).toBe("save") + expect(normalizePaperFeedbackAction("not_relevant")).toBe("dislike") + expect(normalizePaperFeedbackAction("unlike")).toBeNull() + expect(normalizePaperPreferenceAction("save")).toBeNull() + expect(normalizePaperPreferenceAction("not_relevant")).toBe("dislike") + }) + + it("derives save toggle request actions from the current state", () => { + expect(toggleSaveFeedbackAction(false)).toBe("save") + expect(toggleSaveFeedbackAction(true)).toBe("unsave") + }) + + it("derives preference toggle request actions from the current state", () => { + expect(togglePaperPreferenceAction(null, "like")).toBe("like") + expect(togglePaperPreferenceAction("like", "like")).toBe("unlike") + expect(togglePaperPreferenceAction("dislike", "dislike")).toBe("undislike") + }) + + it("maps request actions back into active state after a successful mutation", () => { + expect(currentFeedbackFromRequestAction("save")).toBe("save") + expect(currentFeedbackFromRequestAction("unsave")).toBeNull() + expect(currentFeedbackFromRequestAction("undislike")).toBeNull() + }) +}) diff --git a/web/src/lib/paper-feedback.ts b/web/src/lib/paper-feedback.ts new file mode 100644 index 00000000..a1e5563d --- /dev/null +++ b/web/src/lib/paper-feedback.ts @@ -0,0 +1,56 @@ +export type PaperPreferenceAction = "like" | "dislike" + +export type PaperFeedbackAction = "save" | PaperPreferenceAction + +export type PaperFeedbackRequestAction = + | PaperFeedbackAction + | "unsave" + | "unlike" + | "undislike" + +function normalizeFeedbackKey(action: string | null | undefined): string { + return String(action || "").trim().toLowerCase().replace(/\s+/g, "_") +} + +export function normalizePaperFeedbackAction( + action: string | null | undefined, +): PaperFeedbackAction | null { + const normalized = normalizeFeedbackKey(action) + if (normalized === "not_relevant" || normalized === "not-related") { + return "dislike" + } + if (normalized === "save" || normalized === "like" || normalized === "dislike") { + return normalized + } + return null +} + +export function normalizePaperPreferenceAction( + action: string | null | undefined, +): PaperPreferenceAction | null { + const normalized = normalizePaperFeedbackAction(action) + if (normalized === "like" || normalized === "dislike") { + return normalized + } + return null +} + +export function currentFeedbackFromRequestAction( + action: PaperFeedbackRequestAction, +): PaperFeedbackAction | null { + return normalizePaperFeedbackAction(action) +} + +export function toggleSaveFeedbackAction(isSaved: boolean): PaperFeedbackRequestAction { + return isSaved ? "unsave" : "save" +} + +export function togglePaperPreferenceAction( + currentAction: PaperPreferenceAction | null, + targetAction: PaperPreferenceAction, +): PaperFeedbackRequestAction { + if (currentAction !== targetAction) { + return targetAction + } + return targetAction === "like" ? "unlike" : "undislike" +} From eb8f614d3be8d1e5097a4f61ce4cd38fe0afd85d Mon Sep 17 00:00:00 2001 From: jerry609 <1772030600@qq.com> Date: Wed, 11 Mar 2026 11:25:08 +0800 Subject: [PATCH 6/7] fix(web): add missing @radix-ui/react-popover dependency Research page crashed with "Module not found: Can't resolve '@radix-ui/react-popover'" because SearchBox.tsx imports popover.tsx which depends on this package. Co-Authored-By: Claude Opus 4.6 --- web/package-lock.json | 1177 +++-------------------------------------- web/package.json | 4 +- 2 files changed, 63 insertions(+), 1118 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 3283fe32..19c4bc5a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -20,6 +20,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", @@ -48,8 +49,7 @@ "undici": "^6.23.0", "xterm": "^5.3.0", "xterm-addon-fit": "^0.8.0", - "zustand": "^5.0.9", - "@radix-ui/react-popover": "^1.1.15" + "zustand": "^5.0.9" }, "devDependencies": { "@playwright/test": "^1.58.2", @@ -438,39 +438,6 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", - "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", - "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -1118,162 +1085,6 @@ "node": ">=18" } }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", - "cpu": [ - "ppc64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", - "cpu": [ - "riscv64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", - "cpu": [ - "s390x" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/@img/sharp-libvips-linux-x64": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", @@ -1290,22 +1101,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", @@ -1322,12 +1117,12 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-linux-arm": { + "node_modules/@img/sharp-linux-x64": { "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "cpu": [ - "arm" + "x64" ], "license": "Apache-2.0", "optional": true, @@ -1341,15 +1136,15 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.2.4" } }, - "node_modules/@img/sharp-linux-arm64": { + "node_modules/@img/sharp-linuxmusl-x64": { "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "cpu": [ - "arm64" + "x64" ], "license": "Apache-2.0", "optional": true, @@ -1363,247 +1158,39 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" } }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", - "cpu": [ - "ppc64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", - "cpu": [ - "riscv64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" + "node": ">=6.0.0" } }, "node_modules/@jridgewell/sourcemap-codec": { @@ -1708,19 +1295,6 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" - } - }, "node_modules/@next/env": { "version": "16.1.0", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.0.tgz", @@ -1744,7 +1318,6 @@ "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "darwin" @@ -1760,7 +1333,6 @@ "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "darwin" @@ -1776,7 +1348,6 @@ "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -1792,7 +1363,6 @@ "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -1840,7 +1410,6 @@ "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "win32" @@ -1856,7 +1425,6 @@ "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "win32" @@ -3699,6 +3267,7 @@ "version": "1.1.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", @@ -5790,125 +5359,6 @@ "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", - "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", - "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", - "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", - "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", - "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", - "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", - "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", @@ -5943,70 +5393,6 @@ "node": ">= 10" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", - "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.0", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", - "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", - "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@tailwindcss/postcss": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz", @@ -6021,17 +5407,6 @@ "tailwindcss": "4.1.18" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -6501,193 +5876,11 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "license": "ISC" - }, - "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", - "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", - "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", - "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", - "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", - "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", - "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", - "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", - "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", - "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", - "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", - "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", - "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", - "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" }, "node_modules/@unrs/resolver-binding-linux-x64-gnu": { "version": "1.11.1", @@ -6717,65 +5910,6 @@ "linux" ] }, - "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", - "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", - "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", - "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", - "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@vercel/oidc": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.0.5.tgz", @@ -9311,9 +8445,9 @@ } }, "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -10517,153 +9651,6 @@ "lightningcss-win32-x64-msvc": "1.30.2" } }, - "node_modules/lightningcss-android-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/lightningcss-linux-x64-gnu": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", @@ -10706,48 +9693,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -12350,6 +11295,21 @@ "node": ">=18" } }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -14613,21 +13573,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/vite/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/vitest": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", diff --git a/web/package.json b/web/package.json index afb6f1dc..a486bc60 100644 --- a/web/package.json +++ b/web/package.json @@ -26,6 +26,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", @@ -54,8 +55,7 @@ "undici": "^6.23.0", "xterm": "^5.3.0", "xterm-addon-fit": "^0.8.0", - "zustand": "^5.0.9", - "@radix-ui/react-popover": "^1.1.15" + "zustand": "^5.0.9" }, "devDependencies": { "@playwright/test": "^1.58.2", From 213d85f223e899fa565a31b860ec403e063ebb02 Mon Sep 17 00:00:00 2001 From: jerry609 <1772030600@qq.com> Date: Wed, 11 Mar 2026 17:27:38 +0800 Subject: [PATCH 7/7] fix(research): restore feedback track ux on current dev --- .../infrastructure/stores/research_store.py | 2 +- web/package-lock.json | 1200 ++++++++++++++++- web/package.json | 5 +- web/vitest.config.ts | 7 + 4 files changed, 1137 insertions(+), 77 deletions(-) diff --git a/src/paperbot/infrastructure/stores/research_store.py b/src/paperbot/infrastructure/stores/research_store.py index e6579e94..5d278f7b 100644 --- a/src/paperbot/infrastructure/stores/research_store.py +++ b/src/paperbot/infrastructure/stores/research_store.py @@ -124,7 +124,7 @@ def __init__(self, db_url: Optional[str] = None, *, auto_create_schema: bool = T self.db_url = db_url or get_db_url() self._provider = SessionProvider(self.db_url) if auto_create_schema: - self._provider.ensure_tables(Base.metadata) + Base.metadata.create_all(self._provider.engine) self._identity_resolver = IdentityResolver(db_url=self.db_url) @staticmethod diff --git a/web/package-lock.json b/web/package-lock.json index 19c4bc5a..2790539b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -20,7 +20,6 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", - "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", @@ -49,11 +48,11 @@ "undici": "^6.23.0", "xterm": "^5.3.0", "xterm-addon-fit": "^0.8.0", - "zustand": "^5.0.9" + "zustand": "^5.0.9", + "@radix-ui/react-popover": "^1.1.15" }, "devDependencies": { "@playwright/test": "^1.58.2", - "@radix-ui/react-popover": "^1.1.15", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", @@ -438,6 +437,39 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/core": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", + "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", + "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -1085,6 +1117,162 @@ "node": ">=18" } }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-linux-x64": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", @@ -1101,6 +1289,22 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", @@ -1117,12 +1321,12 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-linux-x64": { + "node_modules/@img/sharp-linux-arm": { "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "cpu": [ - "x64" + "arm" ], "license": "Apache-2.0", "optional": true, @@ -1136,15 +1340,15 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.2.4" } }, - "node_modules/@img/sharp-linuxmusl-x64": { + "node_modules/@img/sharp-linux-arm64": { "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "cpu": [ - "x64" + "arm64" ], "license": "Apache-2.0", "optional": true, @@ -1158,52 +1362,260 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.2.4" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.0.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "dependencies": { @@ -1295,6 +1707,19 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, "node_modules/@next/env": { "version": "16.1.0", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.0.tgz", @@ -1318,6 +1743,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -1333,6 +1759,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -1348,6 +1775,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -1363,6 +1791,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -1410,6 +1839,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -1425,6 +1855,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -3267,7 +3698,6 @@ "version": "1.1.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", - "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", @@ -5359,6 +5789,125 @@ "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", @@ -5393,6 +5942,70 @@ "node": ">= 10" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@tailwindcss/postcss": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz", @@ -5407,6 +6020,17 @@ "tailwindcss": "4.1.18" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -5876,11 +6500,193 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "license": "ISC" + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, "node_modules/@unrs/resolver-binding-linux-x64-gnu": { "version": "1.11.1", @@ -5910,6 +6716,65 @@ "linux" ] }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@vercel/oidc": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.0.5.tgz", @@ -8445,9 +9310,9 @@ } }, "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -9651,6 +10516,153 @@ "lightningcss-win32-x64-msvc": "1.30.2" } }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lightningcss-linux-x64-gnu": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", @@ -9693,6 +10705,48 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -11295,21 +12349,6 @@ "node": ">=18" } }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -13573,6 +14612,21 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/vitest": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", diff --git a/web/package.json b/web/package.json index a486bc60..d8f77989 100644 --- a/web/package.json +++ b/web/package.json @@ -26,7 +26,6 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", - "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", @@ -55,11 +54,11 @@ "undici": "^6.23.0", "xterm": "^5.3.0", "xterm-addon-fit": "^0.8.0", - "zustand": "^5.0.9" + "zustand": "^5.0.9", + "@radix-ui/react-popover": "^1.1.15" }, "devDependencies": { "@playwright/test": "^1.58.2", - "@radix-ui/react-popover": "^1.1.15", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", diff --git a/web/vitest.config.ts b/web/vitest.config.ts index ab76a35a..02949a02 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -1,6 +1,13 @@ +import path from "node:path" + import { defineConfig } from "vitest/config" export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, test: { environment: "node", },