Merge pull request #325 from lfnovo/feat/ui-improv

feat: UI improvements and bug fixes
This commit is contained in:
Luis Novo 2025-12-14 22:24:15 -03:00 committed by GitHub
commit 040e7ccf42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 821 additions and 376 deletions

View file

@ -76,6 +76,10 @@ EXPOSE 8502 5055
RUN mkdir -p /app/data
# Copy and make executable the wait-for-api script
COPY scripts/wait-for-api.sh /app/scripts/wait-for-api.sh
RUN chmod +x /app/scripts/wait-for-api.sh
# Copy supervisord configuration
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf

View file

@ -77,6 +77,10 @@ COPY --from=builder /app/frontend/public /app/frontend/public
# Create directories for data persistence
RUN mkdir -p /app/data /mydata
# Copy and make executable the wait-for-api script
COPY scripts/wait-for-api.sh /app/scripts/wait-for-api.sh
RUN chmod +x /app/scripts/wait-for-api.sh
# Expose ports for Frontend and API
EXPOSE 8502 5055

View file

@ -135,7 +135,9 @@ class ChatService:
if model_override is not None:
data["model_override"] = model_override
async with httpx.AsyncClient(timeout=120.0) as client: # Longer timeout for chat
# Short connect timeout (10s), long read timeout (10 min) for Ollama/local LLMs
timeout = httpx.Timeout(connect=10.0, read=600.0, write=30.0, pool=10.0)
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(
f"{self.base_url}/api/chat/execute",
json=data,

View file

@ -41,6 +41,7 @@
"react-dom": "19.1.0",
"react-hook-form": "^7.60.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"use-debounce": "^10.0.6",
@ -2748,7 +2749,6 @@
"version": "19.1.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -2758,7 +2758,6 @@
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz",
"integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==",
"devOptional": true,
"peer": true,
"peerDependencies": {
"@types/react": "^19.0.0"
}
@ -2811,7 +2810,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.37.0.tgz",
"integrity": "sha512-kVIaQE9vrN9RLCQMQ3iyRlVJpTiDUY6woHGb30JDkfJErqrQEmtdWH3gV0PBAfGZgQXoqzXOO0T3K6ioApbbAA==",
"dev": true,
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.37.0",
"@typescript-eslint/types": "8.37.0",
@ -3393,7 +3391,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -4454,7 +4451,6 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz",
"integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@ -4622,7 +4618,6 @@
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@ -7935,7 +7930,6 @@
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -7944,7 +7938,6 @@
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@ -7956,7 +7949,6 @@
"version": "7.60.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.60.0.tgz",
"integrity": "sha512-SBrYOvMbDB7cV8ZfNpaiLcgjH/a1c7aK0lK+aNigpf4xWLO8q+o4tcvVurv3c4EOyzn/3dCsYt4GKD42VvJ/+A==",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@ -8327,6 +8319,7 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
"integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-gfm": "^3.0.0",
@ -8994,8 +8987,7 @@
"node_modules/tailwindcss": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz",
"integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==",
"peer": true
"integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="
},
"node_modules/tapable": {
"version": "2.2.2",
@ -9058,7 +9050,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"peer": true,
"engines": {
"node": ">=12"
},
@ -9225,7 +9216,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View file

@ -42,6 +42,7 @@
"react-dom": "19.1.0",
"react-hook-form": "^7.60.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"use-debounce": "^10.0.6",

View file

@ -8,7 +8,7 @@ import { SourcesColumn } from '../components/SourcesColumn'
import { NotesColumn } from '../components/NotesColumn'
import { ChatColumn } from '../components/ChatColumn'
import { useNotebook } from '@/lib/hooks/use-notebooks'
import { useSources } from '@/lib/hooks/use-sources'
import { useNotebookSources } from '@/lib/hooks/use-sources'
import { useNotes } from '@/lib/hooks/use-notes'
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { useNotebookColumnsStore } from '@/lib/stores/notebook-columns-store'
@ -31,7 +31,14 @@ export default function NotebookPage() {
const notebookId = decodeURIComponent(params.id as string)
const { data: notebook, isLoading: notebookLoading } = useNotebook(notebookId)
const { data: sources, isLoading: sourcesLoading, refetch: refetchSources } = useSources(notebookId)
const {
sources,
isLoading: sourcesLoading,
refetch: refetchSources,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useNotebookSources(notebookId)
const { data: notes, isLoading: notesLoading } = useNotes(notebookId)
// Get collapse states for dynamic layout
@ -153,6 +160,9 @@ export default function NotebookPage() {
onRefresh={refetchSources}
contextSelections={contextSelections.sources}
onContextModeChange={(sourceId, mode) => handleContextModeChange(sourceId, mode, 'source')}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
/>
)}
{mobileActiveTab === 'notes' && (
@ -192,6 +202,9 @@ export default function NotebookPage() {
onRefresh={refetchSources}
contextSelections={contextSelections.sources}
onContextModeChange={(sourceId, mode) => handleContextModeChange(sourceId, mode, 'source')}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
/>
</div>

View file

@ -1,6 +1,6 @@
'use client'
import { useState, useMemo } from 'react'
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
import { SourceListResponse } from '@/lib/types/api'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@ -10,7 +10,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Plus, FileText, Link2, ChevronDown } from 'lucide-react'
import { Plus, FileText, Link2, ChevronDown, Loader2 } from 'lucide-react'
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { EmptyState } from '@/components/common/EmptyState'
import { AddSourceDialog } from '@/components/sources/AddSourceDialog'
@ -31,6 +31,10 @@ interface SourcesColumnProps {
onRefresh?: () => void
contextSelections?: Record<string, ContextMode>
onContextModeChange?: (sourceId: string, mode: ContextMode) => void
// Pagination props
hasNextPage?: boolean
isFetchingNextPage?: boolean
fetchNextPage?: () => void
}
export function SourcesColumn({
@ -39,7 +43,10 @@ export function SourcesColumn({
notebookId,
onRefresh,
contextSelections,
onContextModeChange
onContextModeChange,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
}: SourcesColumnProps) {
const [dropdownOpen, setDropdownOpen] = useState(false)
const [addDialogOpen, setAddDialogOpen] = useState(false)
@ -60,6 +67,30 @@ export function SourcesColumn({
() => createCollapseButton(toggleSources, 'Sources'),
[toggleSources]
)
// Scroll container ref for infinite scroll
const scrollContainerRef = useRef<HTMLDivElement>(null)
// Handle scroll for infinite loading
const handleScroll = useCallback(() => {
const container = scrollContainerRef.current
if (!container || !hasNextPage || isFetchingNextPage || !fetchNextPage) return
const { scrollTop, scrollHeight, clientHeight } = container
// Load more when user scrolls within 200px of the bottom
if (scrollHeight - scrollTop - clientHeight < 200) {
fetchNextPage()
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
// Attach scroll listener
useEffect(() => {
const container = scrollContainerRef.current
if (!container) return
container.addEventListener('scroll', handleScroll)
return () => container.removeEventListener('scroll', handleScroll)
}, [handleScroll])
const handleDeleteClick = (sourceId: string) => {
setSourceToDelete(sourceId)
@ -149,7 +180,7 @@ export function SourcesColumn({
</div>
</CardHeader>
<CardContent className="flex-1 overflow-y-auto min-h-0">
<CardContent ref={scrollContainerRef} className="flex-1 overflow-y-auto min-h-0">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<LoadingSpinner />
@ -179,6 +210,12 @@ export function SourcesColumn({
}
/>
))}
{/* Loading indicator for infinite scroll */}
{isFetchingNextPage && (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
</CardContent>

View file

@ -12,6 +12,7 @@ import { Transformation } from '@/lib/types/transformations'
import { useExecuteTransformation } from '@/lib/hooks/use-transformations'
import { ModelSelector } from '@/components/common/ModelSelector'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
interface TransformationPlaygroundProps {
transformations: Transformation[] | undefined
@ -118,8 +119,24 @@ export function TransformationPlayground({ transformations, selectedTransformati
<Card>
<ScrollArea className="h-[400px]">
<CardContent className="pt-6">
<div className="prose prose-sm max-w-none">
<ReactMarkdown>{output}</ReactMarkdown>
<div className="prose prose-sm max-w-none dark:prose-invert">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
table: ({ children }) => (
<div className="my-4 overflow-x-auto">
<table className="min-w-full border-collapse border border-border">{children}</table>
</div>
),
thead: ({ children }) => <thead className="bg-muted">{children}</thead>,
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => <tr className="border-b border-border">{children}</tr>,
th: ({ children }) => <th className="border border-border px-3 py-2 text-left font-semibold">{children}</th>,
td: ({ children }) => <td className="border border-border px-3 py-2">{children}</td>,
}}
>
{output}
</ReactMarkdown>
</div>
</CardContent>
</ScrollArea>

View file

@ -7,6 +7,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
import { CheckCircle, Sparkles, Lightbulb, ChevronDown } from 'lucide-react'
import { useState } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { convertReferencesToMarkdownLinks, createReferenceLinkComponent } from '@/lib/utils/source-references'
import { useModalManager } from '@/lib/hooks/use-modal-manager'
import { toast } from 'sonner'
@ -172,8 +173,19 @@ function FinalAnswerContent({
return (
<div className="prose prose-sm max-w-none dark:prose-invert break-words prose-a:break-all prose-p:leading-relaxed prose-headings:mt-4 prose-headings:mb-2">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: LinkComponent
a: LinkComponent,
table: ({ children }) => (
<div className="my-4 overflow-x-auto">
<table className="min-w-full border-collapse border border-border">{children}</table>
</div>
),
thead: ({ children }) => <thead className="bg-muted">{children}</thead>,
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => <tr className="border-b border-border">{children}</tr>,
th: ({ children }) => <th className="border border-border px-3 py-2 text-left font-semibold">{children}</th>,
td: ({ children }) => <td className="border border-border px-3 py-2">{children}</td>,
}}
>
{markdownWithLinks}

View file

@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import { Bot, User, Send, Loader2, FileText, Lightbulb, StickyNote, Clock } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import {
SourceChatMessage,
SourceChatContextIndicator,
@ -335,6 +336,7 @@ function AIMessageContent({
return (
<div className="prose prose-sm prose-neutral dark:prose-invert max-w-none break-words prose-headings:font-semibold prose-a:text-blue-600 prose-a:break-all prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-p:mb-4 prose-p:leading-7 prose-li:mb-2">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: LinkComponent,
p: ({ children }) => <p className="mb-4">{children}</p>,
@ -347,6 +349,16 @@ function AIMessageContent({
li: ({ children }) => <li className="mb-1">{children}</li>,
ul: ({ children }) => <ul className="mb-4 space-y-1">{children}</ul>,
ol: ({ children }) => <ol className="mb-4 space-y-1">{children}</ol>,
table: ({ children }) => (
<div className="my-4 overflow-x-auto">
<table className="min-w-full border-collapse border border-border">{children}</table>
</div>
),
thead: ({ children }) => <thead className="bg-muted">{children}</thead>,
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => <tr className="border-b border-border">{children}</tr>,
th: ({ children }) => <th className="border border-border px-3 py-2 text-left font-semibold">{children}</th>,
td: ({ children }) => <td className="border border-border px-3 py-2">{children}</td>,
}}
>
{markdownWithCompactRefs}

View file

@ -3,6 +3,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { isAxiosError } from 'axios'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { sourcesApi } from '@/lib/api/sources'
import { insightsApi, SourceInsightResponse } from '@/lib/api/insights'
import { transformationsApi } from '@/lib/api/transformations'
@ -461,6 +462,7 @@ export function SourceDetailContent({
)}
<div className="prose prose-sm prose-neutral dark:prose-invert max-w-none prose-headings:font-semibold prose-a:text-blue-600 prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-p:mb-4 prose-p:leading-7 prose-li:mb-2">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ children }) => <p className="mb-4">{children}</p>,
h1: ({ children }) => <h1 className="text-2xl font-bold mt-6 mb-4">{children}</h1>,
@ -469,6 +471,16 @@ export function SourceDetailContent({
ul: ({ children }) => <ul className="mb-4 list-disc pl-6">{children}</ul>,
ol: ({ children }) => <ol className="mb-4 list-decimal pl-6">{children}</ol>,
li: ({ children }) => <li className="mb-1">{children}</li>,
table: ({ children }) => (
<div className="my-4 overflow-x-auto">
<table className="min-w-full border-collapse border border-border">{children}</table>
</div>
),
thead: ({ children }) => <thead className="bg-muted">{children}</thead>,
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => <tr className="border-b border-border">{children}</tr>,
th: ({ children }) => <th className="border border-border px-3 py-2 text-left font-semibold">{children}</th>,
td: ({ children }) => <td className="border border-border px-3 py-2">{children}</td>,
}}
>
{source.full_text || 'No content available'}

View file

@ -3,6 +3,7 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { useInsight } from '@/lib/hooks/use-insights'
interface SourceInsightDialogProps {
@ -48,7 +49,23 @@ export function SourceInsightDialog({ open, onOpenChange, insight }: SourceInsig
</div>
) : displayInsight ? (
<div className="prose prose-sm prose-neutral dark:prose-invert max-w-none">
<ReactMarkdown>{displayInsight.content}</ReactMarkdown>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
table: ({ children }) => (
<div className="my-4 overflow-x-auto">
<table className="min-w-full border-collapse border border-border">{children}</table>
</div>
),
thead: ({ children }) => <thead className="bg-muted">{children}</thead>,
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => <tr className="border-b border-border">{children}</tr>,
th: ({ children }) => <th className="border border-border px-3 py-2 text-left font-semibold">{children}</th>,
td: ({ children }) => <td className="border border-border px-3 py-2">{children}</td>,
}}
>
{displayInsight.content}
</ReactMarkdown>
</div>
) : (
<p className="text-sm text-muted-foreground">No insight selected.</p>

View file

@ -3,12 +3,12 @@ import { getApiUrl } from '@/lib/config'
// API client with runtime-configurable base URL
// The base URL is fetched from the API config endpoint on first request
// Timeout increased to 5 minutes (300000ms = 300s) to accommodate slow LLM operations
// (transformations, insights generation) especially on slower hardware (Ollama, LM Studio)
// Note: Frontend uses milliseconds (300000ms), backend uses seconds (300s) - both equal 5 minutes
// To configure: Set API_CLIENT_TIMEOUT=600 in .env for 10 minutes (600s = 600000ms)
// Timeout increased to 10 minutes (600000ms = 600s) to accommodate slow LLM operations
// (transformations, insights generation, chat) especially on slower hardware (Ollama, LM Studio)
// Note: Frontend uses milliseconds, backend uses seconds
// Local LLMs can take several minutes for complex questions with large contexts
export const apiClient = axios.create({
timeout: 300000, // 300 seconds = 5 minutes
timeout: 600000, // 600 seconds = 10 minutes
headers: {
'Content-Type': 'application/json',
},

View file

@ -20,6 +20,7 @@ export const QUERY_KEYS = {
notes: (notebookId?: string) => ['notes', notebookId] as const,
note: (id: string) => ['notes', id] as const,
sources: (notebookId?: string) => ['sources', notebookId] as const,
sourcesInfinite: (notebookId: string) => ['sources', 'infinite', notebookId] as const,
source: (id: string) => ['sources', id] as const,
settings: ['settings'] as const,
sourceChatSessions: (sourceId: string) => ['source-chat', sourceId, 'sessions'] as const,

View file

@ -1,14 +1,18 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query'
import { useCallback, useMemo } from 'react'
import { sourcesApi } from '@/lib/api/sources'
import { QUERY_KEYS } from '@/lib/api/query-client'
import { useToast } from '@/lib/hooks/use-toast'
import {
CreateSourceRequest,
UpdateSourceRequest,
import {
CreateSourceRequest,
UpdateSourceRequest,
SourceResponse,
SourceStatusResponse
SourceStatusResponse,
SourceListResponse
} from '@/lib/types/api'
const NOTEBOOK_SOURCES_PAGE_SIZE = 30
export function useSources(notebookId?: string) {
return useQuery({
queryKey: QUERY_KEYS.sources(notebookId),
@ -19,6 +23,57 @@ export function useSources(notebookId?: string) {
})
}
/**
* Hook for fetching notebook sources with infinite scroll pagination.
* Returns flattened sources array and pagination controls.
*/
export function useNotebookSources(notebookId: string) {
const queryClient = useQueryClient()
const query = useInfiniteQuery({
queryKey: QUERY_KEYS.sourcesInfinite(notebookId),
queryFn: async ({ pageParam = 0 }) => {
const data = await sourcesApi.list({
notebook_id: notebookId,
limit: NOTEBOOK_SOURCES_PAGE_SIZE,
offset: pageParam,
sort_by: 'updated',
sort_order: 'desc',
})
return {
sources: data,
nextOffset: data.length === NOTEBOOK_SOURCES_PAGE_SIZE ? pageParam + data.length : undefined,
}
},
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextOffset,
enabled: !!notebookId,
staleTime: 5 * 1000,
refetchOnWindowFocus: true,
})
// Flatten all pages into a single array (memoized to prevent infinite re-renders)
const sources: SourceListResponse[] = useMemo(
() => query.data?.pages.flatMap(page => page.sources) ?? [],
[query.data?.pages]
)
// Refetch function that resets to first page
const refetch = useCallback(() => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.sourcesInfinite(notebookId) })
}, [queryClient, notebookId])
return {
sources,
isLoading: query.isLoading,
isFetchingNextPage: query.isFetchingNextPage,
hasNextPage: query.hasNextPage,
fetchNextPage: query.fetchNextPage,
refetch,
error: query.error,
}
}
export function useSource(id: string) {
return useQuery({
queryKey: QUERY_KEYS.source(id),

View file

@ -1,6 +1,6 @@
[project]
name = "open-notebook"
version = "1.2.3"
version = "1.2.4"
description = "An open source implementation of a research assistant, inspired by Google Notebook LM"
authors = [
{name = "Luis Novo", email = "lfnovo@gmail.com"}

22
scripts/wait-for-api.sh Executable file
View file

@ -0,0 +1,22 @@
#!/bin/bash
# Wait for the API to be healthy before starting the frontend
# This prevents the "Unable to Connect to API Server" error during startup
API_URL="${INTERNAL_API_URL:-http://localhost:5055}"
MAX_RETRIES=60 # 60 retries * 5 seconds = 5 minutes max wait
RETRY_INTERVAL=5
echo "Waiting for API to be ready at ${API_URL}/health..."
for i in $(seq 1 $MAX_RETRIES); do
if curl -s -f "${API_URL}/health" > /dev/null 2>&1; then
echo "API is ready! Starting frontend..."
exit 0
fi
echo "Attempt $i/$MAX_RETRIES: API not ready yet, waiting ${RETRY_INTERVAL}s..."
sleep $RETRY_INTERVAL
done
echo "ERROR: API did not become ready within $((MAX_RETRIES * RETRY_INTERVAL)) seconds"
echo "Starting frontend anyway - users may see connection errors initially"
exit 0 # Exit 0 so frontend still starts (better than nothing)

View file

@ -26,7 +26,7 @@ autostart=true
startsecs=3
[program:frontend]
command=bash -c "sleep 5 && npm run start"
command=bash -c "/app/scripts/wait-for-api.sh && npm run start"
directory=/app/frontend
environment=NODE_ENV="production",PORT="8502"
passenv=API_URL,NEXT_PUBLIC_API_URL,INTERNAL_API_URL
@ -37,4 +37,4 @@ stderr_logfile_maxbytes=0
autorestart=true
priority=30
autostart=true
startsecs=5
startsecs=10

View file

@ -38,7 +38,7 @@ autostart=true
startsecs=3
[program:frontend]
command=bash -c "sleep 5 && npm run start"
command=bash -c "/app/scripts/wait-for-api.sh && npm run start"
directory=/app/frontend
environment=NODE_ENV="production",PORT="8502"
passenv=API_URL,NEXT_PUBLIC_API_URL,INTERNAL_API_URL
@ -49,4 +49,4 @@ stderr_logfile_maxbytes=0
autorestart=true
priority=30
autostart=true
startsecs=5
startsecs=10

920
uv.lock

File diff suppressed because it is too large Load diff