diff --git a/lightrag_webui/eslint.config.js b/lightrag_webui/eslint.config.js index 67b81a3d..7f42e8b1 100644 --- a/lightrag_webui/eslint.config.js +++ b/lightrag_webui/eslint.config.js @@ -10,7 +10,7 @@ import react from 'eslint-plugin-react' export default tseslint.config( { ignores: ['dist'] }, { - extends: [js.configs.recommended, ...tseslint.configs.recommended], + extends: [js.configs.recommended, ...tseslint.configs.recommended, prettier], files: ['**/*.{ts,tsx,js,jsx}'], languageOptions: { ecmaVersion: 2020, @@ -31,7 +31,6 @@ export default tseslint.config( '@stylistic/js/indent': ['error', 2], '@stylistic/js/quotes': ['error', 'single'], '@typescript-eslint/no-explicit-any': ['off'] - }, - prettier + } } ) diff --git a/lightrag_webui/src/App.tsx b/lightrag_webui/src/App.tsx index 91e02da7..30767c07 100644 --- a/lightrag_webui/src/App.tsx +++ b/lightrag_webui/src/App.tsx @@ -1,3 +1,4 @@ +import { useState, useCallback } from 'react' import ThemeProvider from '@/components/ThemeProvider' import MessageAlert from '@/components/MessageAlert' import StatusIndicator from '@/components/StatusIndicator' @@ -10,14 +11,16 @@ import SiteHeader from '@/features/SiteHeader' import GraphViewer from '@/features/GraphViewer' import DocumentManager from '@/features/DocumentManager' +import RetrievalTesting from '@/features/RetrievalTesting' import { Tabs, TabsContent } from '@/components/ui/Tabs' function App() { const message = useBackendState.use.message() const enableHealthCheck = useSettingsStore.use.enableHealthCheck() + const [currentTab] = useState(() => useSettingsStore.getState().currentTab) - // health check + // Health check useEffect(() => { if (!enableHealthCheck) return @@ -30,25 +33,36 @@ function App() { return () => clearInterval(interval) }, [enableHealthCheck]) + const handleTabChange = useCallback( + (tab: string) => useSettingsStore.getState().setCurrentTab(tab as any), + [] + ) + return ( -
- +
+ - - - - - - - -

Settings

-
+
+ + + + + + + + + +
-
- {enableHealthCheck && } - {message !== null && } - + {enableHealthCheck && } + {message !== null && } + +
) } diff --git a/lightrag_webui/src/api/lightrag.ts b/lightrag_webui/src/api/lightrag.ts index 85caa3bc..1a41c765 100644 --- a/lightrag_webui/src/api/lightrag.ts +++ b/lightrag_webui/src/api/lightrag.ts @@ -151,32 +151,64 @@ export const queryText = async (request: QueryRequest): Promise = return response.data } -export const queryTextStream = async (request: QueryRequest, onChunk: (chunk: string) => void) => { - const response = await axiosInstance.post('/query/stream', request, { - responseType: 'stream' - }) +export const queryTextStream = async ( + request: QueryRequest, + onChunk: (chunk: string) => void, + onError?: (error: string) => void +) => { + try { + let buffer = '' + await axiosInstance.post('/query/stream', request, { + responseType: 'text', + headers: { + Accept: 'application/x-ndjson' + }, + transformResponse: [ + (data: string) => { + // Accumulate the data and process complete lines + buffer += data + const lines = buffer.split('\n') + // Keep the last potentially incomplete line in the buffer + buffer = lines.pop() || '' - const reader = response.data.getReader() - const decoder = new TextDecoder() - - while (true) { - const { done, value } = await reader.read() - if (done) break - - const chunk = decoder.decode(value) - const lines = chunk.split('\n') - for (const line of lines) { - if (line) { - try { - const data = JSON.parse(line) - if (data.response) { - onChunk(data.response) + for (const line of lines) { + if (line.trim()) { + try { + const parsed = JSON.parse(line) + if (parsed.response) { + onChunk(parsed.response) + } else if (parsed.error && onError) { + onError(parsed.error) + } + } catch (e) { + console.error('Error parsing stream chunk:', e) + if (onError) onError('Error parsing server response') + } + } } - } catch (e) { - console.error('Error parsing stream chunk:', e) + return data } + ] + }) + + // Process any remaining data in the buffer + if (buffer.trim()) { + try { + const parsed = JSON.parse(buffer) + if (parsed.response) { + onChunk(parsed.response) + } else if (parsed.error && onError) { + onError(parsed.error) + } + } catch (e) { + console.error('Error parsing final chunk:', e) + if (onError) onError('Error parsing server response') } } + } catch (error) { + const message = errorMessage(error) + console.error('Stream request failed:', message) + if (onError) onError(message) } } @@ -199,6 +231,7 @@ export const uploadDocument = async ( headers: { 'Content-Type': 'multipart/form-data' }, + // prettier-ignore onUploadProgress: onUploadProgress !== undefined ? (progressEvent) => { diff --git a/lightrag_webui/src/components/ThemeToggle.tsx b/lightrag_webui/src/components/ThemeToggle.tsx index 6b66b2b3..8e92d862 100644 --- a/lightrag_webui/src/components/ThemeToggle.tsx +++ b/lightrag_webui/src/components/ThemeToggle.tsx @@ -19,6 +19,7 @@ export default function ThemeToggle() { variant={controlButtonVariant} tooltip="Switch to light theme" size="icon" + side="bottom" > @@ -30,6 +31,7 @@ export default function ThemeToggle() { variant={controlButtonVariant} tooltip="Switch to dark theme" size="icon" + side="bottom" > diff --git a/lightrag_webui/src/components/document/ClearDocumentsDialog.tsx b/lightrag_webui/src/components/document/ClearDocumentsDialog.tsx index b7613f24..12231df4 100644 --- a/lightrag_webui/src/components/document/ClearDocumentsDialog.tsx +++ b/lightrag_webui/src/components/document/ClearDocumentsDialog.tsx @@ -15,7 +15,7 @@ import { clearDocuments } from '@/api/lightrag' import { EraserIcon } from 'lucide-react' export default function ClearDocumentsDialog() { - const [open, setOpen] = useState(false) // 添加状态控制 + const [open, setOpen] = useState(false) const handleClear = useCallback(async () => { try { @@ -34,8 +34,8 @@ export default function ClearDocumentsDialog() { return ( - e.preventDefault()}> diff --git a/lightrag_webui/src/components/document/UploadDocumentsDialog.tsx b/lightrag_webui/src/components/document/UploadDocumentsDialog.tsx index 59174979..48edfd31 100644 --- a/lightrag_webui/src/components/document/UploadDocumentsDialog.tsx +++ b/lightrag_webui/src/components/document/UploadDocumentsDialog.tsx @@ -66,8 +66,8 @@ export default function UploadDocumentsDialog() { }} > - e.preventDefault()}> diff --git a/lightrag_webui/src/components/ui/Table.tsx b/lightrag_webui/src/components/ui/Table.tsx index a072bbdf..d0b7cb31 100644 --- a/lightrag_webui/src/components/ui/Table.tsx +++ b/lightrag_webui/src/components/ui/Table.tsx @@ -56,6 +56,7 @@ TableRow.displayName = 'TableRow' const TableHead = React.forwardRef< HTMLTableCellElement, React.ThHTMLAttributes +// eslint-disable-next-line react/prop-types >(({ className, ...props }, ref) => ( + // eslint-disable-next-line react/prop-types >(({ className, ...props }, ref) => ( ([]) const [indexedFiles, setIndexedFiles] = useState([]) - const [scanProgress, setScanProgress] = useState(null) + // const [scanProgress, setScanProgress] = useState(null) const fetchDocuments = useCallback(async () => { try { @@ -45,7 +45,7 @@ export default function DocumentManager() { useEffect(() => { fetchDocuments() - }, []) + }, []) // eslint-disable-line react-hooks/exhaustive-deps const scanDocuments = useCallback(async () => { try { @@ -54,26 +54,26 @@ export default function DocumentManager() { } catch (err) { toast.error('Failed to load documents\n' + errorMessage(err)) } - }, [setFiles]) + }, []) - useEffect(() => { - const interval = setInterval(async () => { - try { - if (!health) return - const progress = await getDocumentsScanProgress() - setScanProgress((pre) => { - if (pre?.is_scanning === progress.is_scanning && progress.is_scanning === false) { - return pre - } - return progress - }) - console.log(progress) - } catch (err) { - toast.error('Failed to get scan progress\n' + errorMessage(err)) - } - }, 2000) - return () => clearInterval(interval) - }, [health]) + // useEffect(() => { + // const interval = setInterval(async () => { + // try { + // if (!health) return + // const progress = await getDocumentsScanProgress() + // setScanProgress((pre) => { + // if (pre?.is_scanning === progress.is_scanning && progress.is_scanning === false) { + // return pre + // } + // return progress + // }) + // console.log(progress) + // } catch (err) { + // toast.error('Failed to get scan progress\n' + errorMessage(err)) + // } + // }, 2000) + // return () => clearInterval(interval) + // }, [health]) const handleDelete = async (fileName: string) => { console.log(`deleting ${fileName}`) @@ -88,19 +88,19 @@ export default function DocumentManager() {
- {scanProgress?.is_scanning && ( + {/* {scanProgress?.is_scanning && (
Indexing {scanProgress.current_file} @@ -108,7 +108,7 @@ export default function DocumentManager() {
- )} + )} */} diff --git a/lightrag_webui/src/features/RetrievalTesting.tsx b/lightrag_webui/src/features/RetrievalTesting.tsx new file mode 100644 index 00000000..2ed7c2a4 --- /dev/null +++ b/lightrag_webui/src/features/RetrievalTesting.tsx @@ -0,0 +1,170 @@ +import Input from '@/components/ui/Input' +import Button from '@/components/ui/Button' +import { useCallback, useEffect, useRef, useState } from 'react' +import { queryTextStream, QueryMode } from '@/api/lightrag' +import { errorMessage } from '@/lib/utils' +import { useSettingsStore } from '@/stores/settings' +import { useDebounce } from '@/hooks/useDebounce' +import { EraserIcon, SendIcon, LoaderIcon } from 'lucide-react' + +type Message = { + id: string + content: string + role: 'User' | 'LightRAG' +} + +export default function RetrievalTesting() { + const [messages, setMessages] = useState( + () => useSettingsStore.getState().retrievalHistory || [] + ) + const [inputValue, setInputValue] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [mode, setMode] = useState('mix') + const messagesEndRef = useRef(null) + + const scrollToBottom = useCallback(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, []) + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault() + if (!inputValue.trim() || isLoading) return + + const userMessage: Message = { + id: Date.now().toString(), + content: inputValue, + role: 'User' + } + + const assistantMessage: Message = { + id: (Date.now() + 1).toString(), + content: '', + role: 'LightRAG' + } + + setMessages((prev) => { + const newMessages = [...prev, userMessage, assistantMessage] + return newMessages + }) + + setInputValue('') + setIsLoading(true) + + // Create a function to update the assistant's message + const updateAssistantMessage = (chunk: string) => { + assistantMessage.content += chunk + setMessages((prev) => { + const newMessages = [...prev] + const lastMessage = newMessages[newMessages.length - 1] + if (lastMessage.role === 'LightRAG') { + lastMessage.content = assistantMessage.content + } + return newMessages + }) + } + + try { + await queryTextStream( + { + query: userMessage.content, + mode: mode, + stream: true + }, + updateAssistantMessage + ) + } catch (err) { + updateAssistantMessage(`Error: Failed to get response\n${errorMessage(err)}`) + } finally { + setIsLoading(false) + useSettingsStore + .getState() + .setRetrievalHistory([ + ...useSettingsStore.getState().retrievalHistory, + userMessage, + assistantMessage + ]) + } + }, + [inputValue, isLoading, mode, setMessages] + ) + + const debouncedMessages = useDebounce(messages, 100) + useEffect(() => scrollToBottom(), [debouncedMessages, scrollToBottom]) + + const clearMessages = useCallback(() => { + setMessages([]) + useSettingsStore.getState().setRetrievalHistory([]) + }, [setMessages]) + + return ( +
+
+
+
+ {messages.length === 0 ? ( +
+ Start a retrieval by typing your query below +
+ ) : ( + messages.map((message) => ( +
+
+
{message.content}
+ {message.content.length === 0 && ( + + )} +
+
+ )) + )} +
+
+
+
+ +
+ + + setInputValue(e.target.value)} + placeholder="Type your query..." + disabled={isLoading} + /> + +
+
+ ) +} diff --git a/lightrag_webui/src/features/SiteHeader.tsx b/lightrag_webui/src/features/SiteHeader.tsx index ef54882e..e1ade6cf 100644 --- a/lightrag_webui/src/features/SiteHeader.tsx +++ b/lightrag_webui/src/features/SiteHeader.tsx @@ -2,14 +2,18 @@ import Button from '@/components/ui/Button' import { SiteInfo } from '@/lib/constants' import ThemeToggle from '@/components/ThemeToggle' import { TabsList, TabsTrigger } from '@/components/ui/Tabs' +import { useSettingsStore } from '@/stores/settings' +import { cn } from '@/lib/utils' import { ZapIcon, GithubIcon } from 'lucide-react' export default function SiteHeader() { + const currentTab = useSettingsStore.use.currentTab() + return (
- @@ -18,28 +22,43 @@ export default function SiteHeader() { Documents Knowledge Graph - {/* - Settings - */} + Retrieval +