Merge pull request #325 from lfnovo/feat/ui-improv
feat: UI improvements and bug fixes
This commit is contained in:
commit
040e7ccf42
20 changed files with 821 additions and 376 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
16
frontend/package-lock.json
generated
16
frontend/package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
22
scripts/wait-for-api.sh
Executable 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in a new issue