Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .tmp/proof/document-search-desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .tmp/proof/document-search-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 7 additions & 2 deletions docs/local-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,8 +299,13 @@ curl -sS --get http://localhost:8000/api/documents \
Search and source filters combine before counting and pagination. The response
keeps the existing `items`, `total`, `limit` and `offset` fields and newest-first
ordering. A search with no matches returns an empty page with `total` set to zero. This searches
saved PostgreSQL records and does not call Slack, embeddings or the model. The
dashboard does not expose the search input yet.
saved PostgreSQL records and does not call Slack, embeddings or the model.

In the dashboard, open **Documents**, enter text in **Search documents** and
submit **Search**. Results are paginated in groups of 20; **Previous** and
**Next** keep the submitted search. **Clear search** returns to the first page
of all saved documents. Typing does not send a request until the form is
submitted. Selecting a result displays its full saved content.

## Next Steps

Expand Down
162 changes: 4 additions & 158 deletions frontend/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,79 +1,16 @@
'use client'

import { useEffect, useState } from 'react'
import { useState } from 'react'
import ConversationsPanel from '@/components/conversations-panel'
import RetrievalPanel from '@/components/retrieval-panel'
import {
askQuestion,
getDocument,
listDocuments,
type Document,
type DocumentDetail,
} from '@/lib/api'

function formatDate(value?: string | null) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return date.toLocaleDateString('en-AU', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
import DocumentsPanel from '@/components/documents-panel'
import { askQuestion } from '@/lib/api'

export default function Home() {
const [question, setQuestion] = useState('')
const [answer, setAnswer] = useState('')
const [loading, setLoading] = useState(false)
const [activeTab, setActiveTab] = useState<'ask' | 'documents' | 'conversations' | 'retrieval'>('ask')
const [documents, setDocuments] = useState<Document[]>([])
const [documentsTotal, setDocumentsTotal] = useState(0)
const [documentsLoading, setDocumentsLoading] = useState(false)
const [documentsError, setDocumentsError] = useState('')
const [selectedDocument, setSelectedDocument] = useState<DocumentDetail | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
const [detailError, setDetailError] = useState('')

useEffect(() => {
if (activeTab !== 'documents') return
let cancelled = false
setDocumentsLoading(true)
setDocumentsError('')
listDocuments()
.then((data) => {
if (cancelled) return
setDocuments(data.items)
setDocumentsTotal(data.total)
})
.catch(() => {
if (cancelled) return
setDocuments([])
setDocumentsTotal(0)
setDocumentsError('Could not load documents. Check the backend is running and try again.')
})
.finally(() => {
if (!cancelled) setDocumentsLoading(false)
})
return () => {
cancelled = true
}
}, [activeTab])

const handleSelectDocument = async (doc: Document) => {
setDetailLoading(true)
setDetailError('')
try {
const detail = await getDocument(doc.id)
setSelectedDocument(detail)
} catch {
setSelectedDocument(null)
setDetailError('Could not load this document. Try again.')
} finally {
setDetailLoading(false)
}
}

const handleAsk = async () => {
if (!question.trim()) return

Expand Down Expand Up @@ -206,98 +143,7 @@ export default function Home() {
</div>
)}

{/* Documents Tab */}
{activeTab === 'documents' && (
<div className="max-w-3xl mx-auto">
<div className="bg-white rounded-xl shadow-lg p-8">
<h2 className="text-2xl font-semibold text-gray-800 mb-2">
Knowledge Base Documents
</h2>
<p className="text-gray-600 mb-6">
Indexed documents used for retrieval-augmented generation. Select one to read its full content.
</p>

{documentsLoading && (
<p className="text-gray-600">Loading documents...</p>
)}

{!documentsLoading && documentsError && (
<div className="p-4 bg-red-50 rounded-lg">
<p className="text-red-700">{documentsError}</p>
</div>
)}

{!documentsLoading && !documentsError && documents.length === 0 && (
<p className="text-gray-600">
No documents yet. Add one with POST /api/documents and it will appear here.
</p>
)}

{!documentsLoading && !documentsError && documents.length > 0 && (
<>
<p className="text-sm text-gray-500 mb-4">
{documentsTotal} {documentsTotal === 1 ? 'document' : 'documents'}
</p>
<ul className="divide-y divide-gray-100">
{documents.map((doc) => (
<li key={doc.id}>
<button
onClick={() => handleSelectDocument(doc)}
className={`w-full text-left py-4 px-3 rounded-lg transition-colors ${
selectedDocument?.id === doc.id
? 'bg-blue-50'
: 'hover:bg-gray-50'
}`}
>
<span className="flex items-center justify-between gap-4">
<span className="font-medium text-gray-800">{doc.title}</span>
<span className="shrink-0 text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded-full">
{doc.source}
</span>
</span>
{formatDate(doc.created_at) && (
<span className="block text-sm text-gray-500 mt-1">
{formatDate(doc.created_at)}
</span>
)}
{doc.content_preview && (
<span className="block text-sm text-gray-600 mt-1 line-clamp-2">
{doc.content_preview}
</span>
)}
</button>
</li>
))}
</ul>
</>
)}

{detailLoading && (
<p className="text-gray-600 mt-6">Loading document...</p>
)}

{!detailLoading && detailError && (
<div className="mt-6 p-4 bg-red-50 rounded-lg">
<p className="text-red-700">{detailError}</p>
</div>
)}

{!detailLoading && !detailError && selectedDocument && (
<div className="mt-6 p-6 bg-blue-50 rounded-lg">
<h3 className="text-lg font-semibold text-gray-800 mb-1">
{selectedDocument.title}
</h3>
<p className="text-sm text-gray-500 mb-3">
{selectedDocument.source}
{formatDate(selectedDocument.created_at) &&
` - ${formatDate(selectedDocument.created_at)}`}
</p>
<p className="text-gray-700 whitespace-pre-wrap">{selectedDocument.content}</p>
</div>
)}
</div>
</div>
)}
{activeTab === 'documents' && <DocumentsPanel />}

{activeTab === 'conversations' && <ConversationsPanel />}
{activeTab === 'retrieval' && <RetrievalPanel />}
Expand Down
135 changes: 135 additions & 0 deletions frontend/components/documents-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
'use client'

import { useEffect, useState } from 'react'
import { getDocument, listDocuments, type DocumentDetail, type DocumentListResponse } from '@/lib/api'

const PAGE_SIZE = 20
const buttonClass = 'min-h-11 rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-40 disabled:cursor-not-allowed'

function formatDate(value?: string | null) {
if (!value || Number.isNaN(new Date(value).getTime())) return ''
return new Date(value).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' })
}

export default function DocumentsPanel() {
const [draft, setDraft] = useState('')
const [request, setRequest] = useState({ query: '', offset: 0 })
const [list, setList] = useState<DocumentListResponse | null>(null)
const [listError, setListError] = useState(false)
const [selection, setSelection] = useState<{ id: number } | null>(null)
const [detail, setDetail] = useState<DocumentDetail | null>(null)
const [detailError, setDetailError] = useState(false)

useEffect(() => {
let cancelled = false
const controller = new AbortController()
listDocuments(PAGE_SIZE, request.offset, undefined, request.query, controller.signal)
.then((data) => { if (!cancelled) setList(data) })
.catch(() => { if (!cancelled) setListError(true) })
return () => { cancelled = true; controller.abort() }
}, [request])

useEffect(() => {
if (!selection) return
let cancelled = false
const controller = new AbortController()
getDocument(selection.id, controller.signal)
.then((data) => { if (!cancelled) setDetail(data) })
.catch(() => { if (!cancelled) setDetailError(true) })
return () => { cancelled = true; controller.abort() }
}, [selection])

function load(query: string, offset = 0) {
setList(null)
setListError(false)
setSelection(null)
setDetail(null)
setDetailError(false)
setRequest({ query, offset })
}

function select(id: number) {
setDetail(null)
setDetailError(false)
setSelection({ id })
}

return (
<section aria-labelledby="documents-title" className="max-w-3xl mx-auto bg-white rounded-xl shadow-lg p-6 sm:p-8">
<h2 id="documents-title" className="text-2xl font-semibold text-gray-800 mb-2">Knowledge Base Documents</h2>
<p className="text-gray-600 mb-6">
Indexed documents used for retrieval-augmented generation. Select one to read its full content.
</p>
<form onSubmit={(event) => { event.preventDefault(); load(draft.trim()) }} className="mb-6">
<label htmlFor="document-search" className="block font-medium text-gray-800 mb-2">Search documents</label>
<p id="document-search-help" className="text-sm text-gray-600 mb-3">Search titles and full content for matching text.</p>
<div className="flex flex-wrap gap-2">
<input id="document-search" type="search" value={draft} onChange={(event) => setDraft(event.target.value)}
aria-describedby="document-search-help"
className="min-w-0 w-full sm:w-auto sm:flex-1 min-h-11 rounded-lg border border-gray-300 px-3 py-2 text-gray-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" />
<button type="submit" className={buttonClass}>Search</button>
<button type="button" className={buttonClass} disabled={!draft && !request.query}
onClick={() => { setDraft(''); load('') }}>Clear search</button>
</div>
</form>

{listError ? (
<div role="alert" className="p-4 bg-red-50 rounded-lg text-red-700">
<p>Could not load documents. Check the backend is running and try again.</p>
<button className={`${buttonClass} mt-3`} onClick={() => load(request.query, request.offset)}>Retry documents</button>
</div>
) : !list ? <p role="status" className="text-gray-600">Loading documents...</p> : (
<>
<p role="status" className="text-sm text-gray-600 mb-4 break-words">
{list.total} {request.query ? 'matching ' : ''}{list.total === 1 ? 'document' : 'documents'}
{request.query && <> for &ldquo;{request.query}&rdquo;</>}
</p>
{list.items.length === 0 && (
<p className="text-gray-600">{request.query ? 'No documents match this search. Try different text or clear the search.' : 'No saved documents on this page.'}</p>
)}
<ul className="divide-y divide-gray-100">
{list.items.map((doc) => (
<li key={doc.id}>
<button onClick={() => select(doc.id)} aria-pressed={selection?.id === doc.id}
className={`w-full text-left py-4 px-3 rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 ${selection?.id === doc.id ? 'bg-blue-50' : 'hover:bg-gray-50'}`}>
<span className="flex items-start justify-between gap-4">
<span className="min-w-0 break-words font-medium text-gray-800">{doc.title}</span>
<span className="max-w-[40%] break-words text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded-full">{doc.source}</span>
</span>
{formatDate(doc.created_at) && <span className="block text-sm text-gray-600 mt-1">{formatDate(doc.created_at)}</span>}
{doc.content_preview && <span className="block text-sm text-gray-600 mt-1 line-clamp-2 break-words">{doc.content_preview}</span>}
</button>
</li>
))}
</ul>
<nav aria-label="Document pages" className="mt-4 flex flex-wrap items-center justify-between gap-3">
<span className="text-sm text-gray-600 tabular-nums">{list.items.length ? request.offset + 1 : 0}-{request.offset + list.items.length} of {list.total}</span>
<div className="flex gap-2">
<button className={buttonClass} aria-label="Previous documents" disabled={request.offset === 0}
onClick={() => load(request.query, Math.max(0, request.offset - PAGE_SIZE))}>Previous</button>
<button className={buttonClass} aria-label="Next documents" disabled={request.offset + list.items.length >= list.total}
onClick={() => load(request.query, request.offset + PAGE_SIZE)}>Next</button>
</div>
</nav>
</>
)}

{selection && (
<section aria-label="Document content" className="mt-6">
{detailError ? (
<div role="alert" className="p-4 bg-red-50 rounded-lg text-red-700">
<p>Could not load this document. It may no longer be available.</p>
<button className={`${buttonClass} mt-3`} onClick={() => select(selection.id)}>Retry document</button>
</div>
) : !detail ? <p role="status" className="text-gray-600">Loading document...</p> : (
<div className="p-6 bg-blue-50 rounded-lg break-words">
<h3 className="text-lg font-semibold text-gray-800 mb-1">{detail.title}</h3>
<p className="text-sm text-gray-600 mb-3">{detail.source}{formatDate(detail.created_at) && ` - ${formatDate(detail.created_at)}`}</p>
<p className="text-gray-700 whitespace-pre-wrap">{detail.content}</p>
</div>
)}
</section>
)}
</section>
)
}
11 changes: 7 additions & 4 deletions frontend/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,15 +150,18 @@ export async function askQuestion(
export async function listDocuments(
limit?: number,
offset?: number,
source?: string
source?: string,
query?: string,
signal?: AbortSignal
): Promise<DocumentListResponse> {
const params = new URLSearchParams()
if (limit !== undefined) params.append('limit', limit.toString())
if (offset !== undefined) params.append('offset', offset.toString())
if (source) params.append('source', source)
if (query?.trim()) params.append('q', query.trim())

const url = `${API_BASE_URL}/api/documents${params.toString() ? `?${params}` : ''}`
const response = await fetch(url)
const response = await fetch(url, { signal })

if (!response.ok) {
throw new Error(`API error: ${response.status}`)
Expand All @@ -167,8 +170,8 @@ export async function listDocuments(
return response.json()
}

export async function getDocument(id: number): Promise<DocumentDetail> {
const response = await fetch(`${API_BASE_URL}/api/documents/${id}`)
export async function getDocument(id: number, signal?: AbortSignal): Promise<DocumentDetail> {
const response = await fetch(`${API_BASE_URL}/api/documents/${id}`, { signal })

if (!response.ok) {
throw new Error(`API error: ${response.status}`)
Expand Down
Loading
Loading