chore: init monorepo snapshot
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTabVisibility } from '@/contexts/useTabVisibility'
|
||||
import { backendBaseUrl } from '@/lib/constants'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function ApiSite() {
|
||||
const { t } = useTranslation()
|
||||
const { isTabVisible } = useTabVisibility()
|
||||
const isApiTabVisible = isTabVisible('api')
|
||||
const [iframeLoaded, setIframeLoaded] = useState(false)
|
||||
|
||||
// Load the iframe once on component mount
|
||||
useEffect(() => {
|
||||
if (!iframeLoaded) {
|
||||
setIframeLoaded(true)
|
||||
}
|
||||
}, [iframeLoaded])
|
||||
|
||||
// Use CSS to hide content when tab is not visible
|
||||
return (
|
||||
<div className={`size-full ${isApiTabVisible ? '' : 'hidden'}`}>
|
||||
{iframeLoaded ? (
|
||||
<iframe
|
||||
src={backendBaseUrl + '/docs'}
|
||||
className="size-full w-full h-full"
|
||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||
// Use key to ensure iframe doesn't reload
|
||||
key="api-docs-iframe"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
<p>{t('apiSite.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,260 @@
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
|
||||
// import { MiniMap } from '@react-sigma/minimap'
|
||||
import { SigmaContainer, useRegisterEvents, useSigma } from '@react-sigma/core'
|
||||
import { Settings as SigmaSettings } from 'sigma/settings'
|
||||
import { GraphSearchOption, OptionItem } from '@react-sigma/graph-search'
|
||||
import { EdgeArrowProgram, NodePointProgram, NodeCircleProgram } from 'sigma/rendering'
|
||||
import { NodeBorderProgram } from '@sigma/node-border'
|
||||
import { EdgeCurvedArrowProgram, createEdgeCurveProgram } from '@sigma/edge-curve'
|
||||
|
||||
import FocusOnNode from '@/components/graph/FocusOnNode'
|
||||
import LayoutsControl from '@/components/graph/LayoutsControl'
|
||||
import GraphControl from '@/components/graph/GraphControl'
|
||||
// import ThemeToggle from '@/components/ThemeToggle'
|
||||
import ZoomControl from '@/components/graph/ZoomControl'
|
||||
import FullScreenControl from '@/components/graph/FullScreenControl'
|
||||
import Settings from '@/components/graph/Settings'
|
||||
import GraphSearch from '@/components/graph/GraphSearch'
|
||||
import GraphLabels from '@/components/graph/GraphLabels'
|
||||
import PropertiesView from '@/components/graph/PropertiesView'
|
||||
import SettingsDisplay from '@/components/graph/SettingsDisplay'
|
||||
import Legend from '@/components/graph/Legend'
|
||||
import LegendButton from '@/components/graph/LegendButton'
|
||||
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import { labelColorDarkTheme, labelColorLightTheme } from '@/lib/constants'
|
||||
|
||||
import '@react-sigma/core/lib/style.css'
|
||||
import '@react-sigma/graph-search/lib/style.css'
|
||||
|
||||
// Function to create sigma settings based on theme
|
||||
const createSigmaSettings = (isDarkTheme: boolean): Partial<SigmaSettings> => ({
|
||||
allowInvalidContainer: true,
|
||||
defaultNodeType: 'default',
|
||||
defaultEdgeType: 'curvedNoArrow',
|
||||
renderEdgeLabels: false,
|
||||
edgeProgramClasses: {
|
||||
arrow: EdgeArrowProgram,
|
||||
curvedArrow: EdgeCurvedArrowProgram,
|
||||
curvedNoArrow: createEdgeCurveProgram()
|
||||
},
|
||||
nodeProgramClasses: {
|
||||
default: NodeBorderProgram,
|
||||
circel: NodeCircleProgram,
|
||||
point: NodePointProgram
|
||||
},
|
||||
labelGridCellSize: 60,
|
||||
labelRenderedSizeThreshold: 12,
|
||||
enableEdgeEvents: true,
|
||||
labelColor: {
|
||||
color: isDarkTheme ? labelColorDarkTheme : labelColorLightTheme,
|
||||
attribute: 'labelColor'
|
||||
},
|
||||
edgeLabelColor: {
|
||||
color: isDarkTheme ? labelColorDarkTheme : labelColorLightTheme,
|
||||
attribute: 'labelColor'
|
||||
},
|
||||
edgeLabelSize: 8,
|
||||
labelSize: 12
|
||||
// minEdgeThickness: 2
|
||||
// labelFont: 'Lato, sans-serif'
|
||||
})
|
||||
|
||||
const GraphEvents = () => {
|
||||
const registerEvents = useRegisterEvents()
|
||||
const sigma = useSigma()
|
||||
const [draggedNode, setDraggedNode] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Register the events
|
||||
registerEvents({
|
||||
downNode: (e) => {
|
||||
setDraggedNode(e.node)
|
||||
sigma.getGraph().setNodeAttribute(e.node, 'highlighted', true)
|
||||
},
|
||||
// On mouse move, if the drag mode is enabled, we change the position of the draggedNode
|
||||
mousemovebody: (e) => {
|
||||
if (!draggedNode) return
|
||||
// Get new position of node
|
||||
const pos = sigma.viewportToGraph(e)
|
||||
sigma.getGraph().setNodeAttribute(draggedNode, 'x', pos.x)
|
||||
sigma.getGraph().setNodeAttribute(draggedNode, 'y', pos.y)
|
||||
|
||||
// Prevent sigma to move camera:
|
||||
e.preventSigmaDefault()
|
||||
e.original.preventDefault()
|
||||
e.original.stopPropagation()
|
||||
},
|
||||
// On mouse up, we reset the autoscale and the dragging mode
|
||||
mouseup: () => {
|
||||
if (draggedNode) {
|
||||
setDraggedNode(null)
|
||||
sigma.getGraph().removeNodeAttribute(draggedNode, 'highlighted')
|
||||
}
|
||||
},
|
||||
// Disable the autoscale at the first down interaction
|
||||
mousedown: (e) => {
|
||||
// Only set custom BBox if it's a drag operation (mouse button is pressed)
|
||||
const mouseEvent = e.original as MouseEvent;
|
||||
if (mouseEvent.buttons !== 0 && !sigma.getCustomBBox()) {
|
||||
sigma.setCustomBBox(sigma.getBBox())
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [registerEvents, sigma, draggedNode])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const GraphViewer = () => {
|
||||
const [isThemeSwitching, setIsThemeSwitching] = useState(false)
|
||||
const sigmaRef = useRef<any>(null)
|
||||
const prevTheme = useRef<string>('')
|
||||
|
||||
const selectedNode = useGraphStore.use.selectedNode()
|
||||
const focusedNode = useGraphStore.use.focusedNode()
|
||||
const moveToSelectedNode = useGraphStore.use.moveToSelectedNode()
|
||||
const isFetching = useGraphStore.use.isFetching()
|
||||
|
||||
const showPropertyPanel = useSettingsStore.use.showPropertyPanel()
|
||||
const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar()
|
||||
const enableNodeDrag = useSettingsStore.use.enableNodeDrag()
|
||||
const showLegend = useSettingsStore.use.showLegend()
|
||||
const theme = useSettingsStore.use.theme()
|
||||
|
||||
// Memoize sigma settings to prevent unnecessary re-creation
|
||||
const memoizedSigmaSettings = useMemo(() => {
|
||||
const isDarkTheme = theme === 'dark'
|
||||
return createSigmaSettings(isDarkTheme)
|
||||
}, [theme])
|
||||
|
||||
// Initialize sigma settings based on theme with theme switching protection
|
||||
useEffect(() => {
|
||||
// Detect theme change
|
||||
const isThemeChange = prevTheme.current && prevTheme.current !== theme
|
||||
if (isThemeChange) {
|
||||
setIsThemeSwitching(true)
|
||||
console.log('Theme switching detected:', prevTheme.current, '->', theme)
|
||||
|
||||
// Reset theme switching state after a short delay
|
||||
const timer = setTimeout(() => {
|
||||
setIsThemeSwitching(false)
|
||||
console.log('Theme switching completed')
|
||||
}, 150)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
prevTheme.current = theme
|
||||
console.log('Initialized sigma settings for theme:', theme)
|
||||
}, [theme])
|
||||
|
||||
// Clean up sigma instance when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// TAB is mount twice in vite dev mode, this is a workaround
|
||||
|
||||
const sigma = useGraphStore.getState().sigmaInstance;
|
||||
if (sigma) {
|
||||
try {
|
||||
// Destroy sigma,and clear WebGL context
|
||||
sigma.kill();
|
||||
useGraphStore.getState().setSigmaInstance(null);
|
||||
console.log('Cleared sigma instance on Graphviewer unmount');
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up sigma instance:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Note: There was a useLayoutEffect hook here to set up the sigma instance and graph data,
|
||||
// but testing showed it wasn't executing or having any effect, while the backup mechanism
|
||||
// in GraphControl was sufficient. This code was removed to simplify implementation
|
||||
|
||||
const onSearchFocus = useCallback((value: GraphSearchOption | null) => {
|
||||
if (value === null) useGraphStore.getState().setFocusedNode(null)
|
||||
else if (value.type === 'nodes') useGraphStore.getState().setFocusedNode(value.id)
|
||||
}, [])
|
||||
|
||||
const onSearchSelect = useCallback((value: GraphSearchOption | null) => {
|
||||
if (value === null) {
|
||||
useGraphStore.getState().setSelectedNode(null)
|
||||
} else if (value.type === 'nodes') {
|
||||
useGraphStore.getState().setSelectedNode(value.id, true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const autoFocusedNode = useMemo(() => focusedNode ?? selectedNode, [focusedNode, selectedNode])
|
||||
const searchInitSelectedNode = useMemo(
|
||||
(): OptionItem | null => (selectedNode ? { type: 'nodes', id: selectedNode } : null),
|
||||
[selectedNode]
|
||||
)
|
||||
|
||||
// Always render SigmaContainer but control its visibility with CSS
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
<SigmaContainer
|
||||
settings={memoizedSigmaSettings}
|
||||
className="!bg-background !size-full overflow-hidden"
|
||||
ref={sigmaRef}
|
||||
>
|
||||
<GraphControl />
|
||||
|
||||
{enableNodeDrag && <GraphEvents />}
|
||||
|
||||
<FocusOnNode node={autoFocusedNode} move={moveToSelectedNode} />
|
||||
|
||||
<div className="absolute top-2 left-2 flex items-start gap-2">
|
||||
<GraphLabels />
|
||||
{showNodeSearchBar && !isThemeSwitching && (
|
||||
<GraphSearch
|
||||
value={searchInitSelectedNode}
|
||||
onFocus={onSearchFocus}
|
||||
onChange={onSearchSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-background/60 absolute bottom-2 left-2 flex flex-col rounded-xl border-2 backdrop-blur-lg">
|
||||
<LayoutsControl />
|
||||
<ZoomControl />
|
||||
<FullScreenControl />
|
||||
<LegendButton />
|
||||
<Settings />
|
||||
{/* <ThemeToggle /> */}
|
||||
</div>
|
||||
|
||||
{showPropertyPanel && (
|
||||
<div className="absolute top-2 right-2 z-10">
|
||||
<PropertiesView />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showLegend && (
|
||||
<div className="absolute bottom-10 right-2 z-0">
|
||||
<Legend className="bg-background/60 backdrop-blur-lg" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* <div className="absolute bottom-2 right-2 flex flex-col rounded-xl border-2">
|
||||
<MiniMap width="100px" height="100px" />
|
||||
</div> */}
|
||||
|
||||
<SettingsDisplay />
|
||||
</SigmaContainer>
|
||||
|
||||
{/* Loading overlay - shown when data is loading or theme is switching */}
|
||||
{(isFetching || isThemeSwitching) && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent mx-auto"></div>
|
||||
<p>{isThemeSwitching ? 'Switching Theme...' : 'Loading Graph Data...'}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GraphViewer
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/stores/state'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { loginToServer, getAuthStatus } from '@/api/lightrag'
|
||||
import { toast } from 'sonner'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/Card'
|
||||
import Input from '@/components/ui/Input'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { ZapIcon } from 'lucide-react'
|
||||
import AppSettings from '@/components/AppSettings'
|
||||
|
||||
const LoginPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const { login, isAuthenticated } = useAuthStore()
|
||||
const { t } = useTranslation()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [checkingAuth, setCheckingAuth] = useState(true)
|
||||
const authCheckRef = useRef(false); // Prevent duplicate calls in Vite dev mode
|
||||
|
||||
useEffect(() => {
|
||||
console.log('LoginPage mounted')
|
||||
}, []);
|
||||
|
||||
// Check if authentication is configured, skip login if not
|
||||
useEffect(() => {
|
||||
|
||||
const checkAuthConfig = async () => {
|
||||
// Prevent duplicate calls in Vite dev mode
|
||||
if (authCheckRef.current) {
|
||||
return;
|
||||
}
|
||||
authCheckRef.current = true;
|
||||
|
||||
try {
|
||||
// If already authenticated, redirect to home
|
||||
if (isAuthenticated) {
|
||||
navigate('/')
|
||||
return
|
||||
}
|
||||
|
||||
// Check auth status
|
||||
const status = await getAuthStatus()
|
||||
|
||||
// Set session flag for version check to avoid duplicate checks in App component
|
||||
if (status.core_version || status.api_version) {
|
||||
sessionStorage.setItem('VERSION_CHECKED_FROM_LOGIN', 'true');
|
||||
}
|
||||
|
||||
if (!status.auth_configured && status.access_token) {
|
||||
// If auth is not configured, use the guest token and redirect
|
||||
login(status.access_token, true, status.core_version, status.api_version, status.webui_title || null, status.webui_description || null)
|
||||
if (status.message) {
|
||||
toast.info(status.message)
|
||||
}
|
||||
navigate('/')
|
||||
return
|
||||
}
|
||||
|
||||
// Only set checkingAuth to false if we need to show the login page
|
||||
setCheckingAuth(false);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to check auth configuration:', error)
|
||||
// Also set checkingAuth to false in case of error
|
||||
setCheckingAuth(false);
|
||||
}
|
||||
// Removed finally block as we're setting checkingAuth earlier
|
||||
}
|
||||
|
||||
// Execute immediately
|
||||
checkAuthConfig()
|
||||
|
||||
// Cleanup function to prevent state updates after unmount
|
||||
return () => {
|
||||
}
|
||||
}, [isAuthenticated, login, navigate])
|
||||
|
||||
// Don't render anything while checking auth
|
||||
if (checkingAuth) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
if (!username || !password) {
|
||||
toast.error(t('login.errorEmptyFields'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await loginToServer(username, password)
|
||||
|
||||
// Get previous username from localStorage
|
||||
const previousUsername = localStorage.getItem('LIGHTRAG-PREVIOUS-USER')
|
||||
|
||||
// Check if it's the same user logging in again
|
||||
const isSameUser = previousUsername === username
|
||||
|
||||
// If it's not the same user, clear chat history
|
||||
if (isSameUser) {
|
||||
console.log('Same user logging in, preserving chat history')
|
||||
} else {
|
||||
console.log('Different user logging in, clearing chat history')
|
||||
// Directly clear chat history instead of setting a flag
|
||||
useSettingsStore.getState().setRetrievalHistory([])
|
||||
}
|
||||
|
||||
// Update previous username
|
||||
localStorage.setItem('LIGHTRAG-PREVIOUS-USER', username)
|
||||
|
||||
// Check authentication mode
|
||||
const isGuestMode = response.auth_mode === 'disabled'
|
||||
login(response.access_token, isGuestMode, response.core_version, response.api_version, response.webui_title || null, response.webui_description || null)
|
||||
|
||||
// Set session flag for version check
|
||||
if (response.core_version || response.api_version) {
|
||||
sessionStorage.setItem('VERSION_CHECKED_FROM_LOGIN', 'true');
|
||||
}
|
||||
|
||||
if (isGuestMode) {
|
||||
// Show authentication disabled notification
|
||||
toast.info(response.message || t('login.authDisabled', 'Authentication is disabled. Using guest access.'))
|
||||
} else {
|
||||
toast.success(t('login.successMessage'))
|
||||
}
|
||||
|
||||
// Navigate to home page after successful login
|
||||
navigate('/')
|
||||
} catch (error) {
|
||||
console.error('Login failed...', error)
|
||||
toast.error(t('login.errorInvalidCredentials'))
|
||||
|
||||
// Clear any existing auth state
|
||||
useAuthStore.getState().logout()
|
||||
// Clear local storage
|
||||
localStorage.removeItem('LIGHTRAG-API-TOKEN')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-gradient-to-br from-emerald-50 to-teal-100 dark:from-gray-900 dark:to-gray-800">
|
||||
<div className="absolute top-4 right-4 flex items-center gap-2">
|
||||
<AppSettings className="bg-white/30 dark:bg-gray-800/30 backdrop-blur-sm rounded-md" />
|
||||
</div>
|
||||
<Card className="w-full max-w-[480px] shadow-lg mx-4">
|
||||
<CardHeader className="flex items-center justify-center space-y-2 pb-8 pt-6">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src="logo.svg" alt="LightRAG Logo" className="h-12 w-12" />
|
||||
<ZapIcon className="size-10 text-emerald-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">LightRAG</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('login.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-8 pb-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<label htmlFor="username-input" className="text-sm font-medium w-16 shrink-0">
|
||||
{t('login.username')}
|
||||
</label>
|
||||
<Input
|
||||
id="username-input"
|
||||
placeholder={t('login.usernamePlaceholder')}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
className="h-11 flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<label htmlFor="password-input" className="text-sm font-medium w-16 shrink-0">
|
||||
{t('login.password')}
|
||||
</label>
|
||||
<Input
|
||||
id="password-input"
|
||||
type="password"
|
||||
placeholder={t('login.passwordPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="h-11 flex-1"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-11 text-base font-medium mt-2"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? t('login.loggingIn') : t('login.loginButton')}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginPage
|
||||
@@ -0,0 +1,824 @@
|
||||
import Textarea from '@/components/ui/Textarea'
|
||||
import Input from '@/components/ui/Input'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { throttle } from '@/lib/utils'
|
||||
import { queryText, queryTextStream } from '@/api/lightrag'
|
||||
import { errorMessage } from '@/lib/utils'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useDebounce } from '@/hooks/useDebounce'
|
||||
import QuerySettings from '@/components/retrieval/QuerySettings'
|
||||
import { ChatMessage, MessageWithError } from '@/components/retrieval/ChatMessage'
|
||||
import { EraserIcon, SendIcon, CopyIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
import type { QueryMode } from '@/api/lightrag'
|
||||
|
||||
// Helper function to generate unique IDs with browser compatibility
|
||||
const generateUniqueId = () => {
|
||||
// Use crypto.randomUUID() if available
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// Fallback to timestamp + random string for browsers without crypto.randomUUID
|
||||
return `id-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
};
|
||||
|
||||
// LaTeX completeness detection function
|
||||
const detectLatexCompleteness = (content: string): boolean => {
|
||||
// Check for unclosed block-level LaTeX formulas ($$...$$)
|
||||
const blockLatexMatches = content.match(/\$\$/g) || []
|
||||
const hasUnclosedBlock = blockLatexMatches.length % 2 !== 0
|
||||
|
||||
// Check for unclosed inline LaTeX formulas ($...$, but not $$)
|
||||
// Remove all block formulas first to avoid interference
|
||||
const contentWithoutBlocks = content.replace(/\$\$[\s\S]*?\$\$/g, '')
|
||||
const inlineLatexMatches = contentWithoutBlocks.match(/(?<!\$)\$(?!\$)/g) || []
|
||||
const hasUnclosedInline = inlineLatexMatches.length % 2 !== 0
|
||||
|
||||
// LaTeX is complete if there are no unclosed formulas
|
||||
return !hasUnclosedBlock && !hasUnclosedInline
|
||||
}
|
||||
|
||||
// Robust COT parsing function to handle multiple think blocks and edge cases
|
||||
const parseCOTContent = (content: string) => {
|
||||
const thinkStartTag = '<think>'
|
||||
const thinkEndTag = '</think>'
|
||||
|
||||
// Find all <think> and </think> tag positions
|
||||
const startMatches: number[] = []
|
||||
const endMatches: number[] = []
|
||||
|
||||
let startIndex = 0
|
||||
while ((startIndex = content.indexOf(thinkStartTag, startIndex)) !== -1) {
|
||||
startMatches.push(startIndex)
|
||||
startIndex += thinkStartTag.length
|
||||
}
|
||||
|
||||
let endIndex = 0
|
||||
while ((endIndex = content.indexOf(thinkEndTag, endIndex)) !== -1) {
|
||||
endMatches.push(endIndex)
|
||||
endIndex += thinkEndTag.length
|
||||
}
|
||||
|
||||
// Analyze COT state
|
||||
const hasThinkStart = startMatches.length > 0
|
||||
const hasThinkEnd = endMatches.length > 0
|
||||
const isThinking = hasThinkStart && (startMatches.length > endMatches.length)
|
||||
|
||||
let thinkingContent = ''
|
||||
let displayContent = content
|
||||
|
||||
if (hasThinkStart) {
|
||||
if (hasThinkEnd && startMatches.length === endMatches.length) {
|
||||
// Complete thinking blocks: extract the last complete thinking content
|
||||
const lastStartIndex = startMatches[startMatches.length - 1]
|
||||
const lastEndIndex = endMatches[endMatches.length - 1]
|
||||
|
||||
if (lastEndIndex > lastStartIndex) {
|
||||
thinkingContent = content.substring(
|
||||
lastStartIndex + thinkStartTag.length,
|
||||
lastEndIndex
|
||||
).trim()
|
||||
|
||||
// Remove all thinking blocks, keep only the final display content
|
||||
displayContent = content.substring(lastEndIndex + thinkEndTag.length).trim()
|
||||
}
|
||||
} else if (isThinking) {
|
||||
// Currently thinking: extract current thinking content
|
||||
const lastStartIndex = startMatches[startMatches.length - 1]
|
||||
thinkingContent = content.substring(lastStartIndex + thinkStartTag.length)
|
||||
displayContent = ''
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isThinking,
|
||||
thinkingContent,
|
||||
displayContent,
|
||||
hasValidThinkBlock: hasThinkStart && hasThinkEnd && startMatches.length === endMatches.length
|
||||
}
|
||||
}
|
||||
|
||||
export default function RetrievalTesting() {
|
||||
const { t } = useTranslation()
|
||||
// Get current tab to determine if this tab is active (for performance optimization)
|
||||
const currentTab = useSettingsStore.use.currentTab()
|
||||
const isRetrievalTabActive = currentTab === 'retrieval'
|
||||
|
||||
const [messages, setMessages] = useState<MessageWithError[]>(() => {
|
||||
try {
|
||||
const history = useSettingsStore.getState().retrievalHistory || []
|
||||
// Ensure each message from history has a unique ID and mermaidRendered status
|
||||
return history.map((msg, index) => {
|
||||
try {
|
||||
const msgWithError = msg as MessageWithError // Cast to access potential properties
|
||||
return {
|
||||
...msg,
|
||||
id: msgWithError.id || `hist-${Date.now()}-${index}`, // Add ID if missing
|
||||
mermaidRendered: msgWithError.mermaidRendered ?? true, // Assume historical mermaid is rendered
|
||||
latexRendered: msgWithError.latexRendered ?? true // Assume historical LaTeX is rendered
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing message:', error)
|
||||
// Return a default message if there's an error
|
||||
return {
|
||||
role: 'system',
|
||||
content: 'Error loading message',
|
||||
id: `error-${Date.now()}-${index}`,
|
||||
isError: true,
|
||||
mermaidRendered: true
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error loading history:', error)
|
||||
return [] // Return an empty array if there's an error
|
||||
}
|
||||
})
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [inputError, setInputError] = useState('') // Error message for input
|
||||
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)
|
||||
|
||||
// Smart switching logic: use Input for single line, Textarea for multi-line
|
||||
const hasMultipleLines = inputValue.includes('\n')
|
||||
|
||||
// Enhanced event handlers for smart switching
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setInputValue(e.target.value)
|
||||
if (inputError) setInputError('')
|
||||
}, [inputError])
|
||||
|
||||
// Unified height adjustment function for textarea
|
||||
const adjustTextareaHeight = useCallback((element: HTMLTextAreaElement) => {
|
||||
requestAnimationFrame(() => {
|
||||
element.style.height = 'auto'
|
||||
element.style.height = Math.min(element.scrollHeight, 120) + 'px'
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Scroll to bottom function - restored smooth scrolling with better handling
|
||||
const scrollToBottom = useCallback(() => {
|
||||
// Set flag to indicate this is a programmatic scroll
|
||||
programmaticScrollRef.current = true
|
||||
// Use requestAnimationFrame for better performance
|
||||
requestAnimationFrame(() => {
|
||||
if (messagesEndRef.current) {
|
||||
// Use smooth scrolling for better user experience
|
||||
messagesEndRef.current.scrollIntoView({ behavior: 'auto' })
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!inputValue.trim() || isLoading) return
|
||||
|
||||
// Parse query mode prefix
|
||||
const allowedModes: QueryMode[] = ['naive', 'local', 'global', 'hybrid', 'mix', 'bypass']
|
||||
const prefixMatch = inputValue.match(/^\/(\w+)\s+([\s\S]+)/)
|
||||
let modeOverride: QueryMode | undefined = undefined
|
||||
let actualQuery = inputValue
|
||||
|
||||
// If input starts with a slash, but does not match the valid prefix pattern, treat as error
|
||||
if (/^\/\S+/.test(inputValue) && !prefixMatch) {
|
||||
setInputError(t('retrievePanel.retrieval.queryModePrefixInvalid'))
|
||||
return
|
||||
}
|
||||
|
||||
if (prefixMatch) {
|
||||
const mode = prefixMatch[1] as QueryMode
|
||||
const query = prefixMatch[2]
|
||||
if (!allowedModes.includes(mode)) {
|
||||
setInputError(
|
||||
t('retrievePanel.retrieval.queryModeError', {
|
||||
modes: 'naive, local, global, hybrid, mix, bypass',
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
modeOverride = mode
|
||||
actualQuery = query
|
||||
}
|
||||
|
||||
// Clear error message
|
||||
setInputError('')
|
||||
|
||||
// Reset thinking timer state for new query to prevent confusion
|
||||
thinkingStartTime.current = null
|
||||
thinkingProcessed.current = false
|
||||
|
||||
// Create messages
|
||||
// Save the original input (with prefix if any) in userMessage.content for display
|
||||
const userMessage: MessageWithError = {
|
||||
id: generateUniqueId(), // Use browser-compatible ID generation
|
||||
content: inputValue,
|
||||
role: 'user'
|
||||
}
|
||||
|
||||
const assistantMessage: MessageWithError = {
|
||||
id: generateUniqueId(), // Use browser-compatible ID generation
|
||||
content: '',
|
||||
role: 'assistant',
|
||||
mermaidRendered: false,
|
||||
latexRendered: false, // Explicitly initialize to false
|
||||
thinkingTime: null, // Explicitly initialize to null
|
||||
thinkingContent: undefined, // Explicitly initialize to undefined
|
||||
displayContent: undefined, // Explicitly initialize to undefined
|
||||
isThinking: false // Explicitly initialize to false
|
||||
}
|
||||
|
||||
const prevMessages = [...messages]
|
||||
|
||||
// Add messages to chatbox
|
||||
setMessages([...prevMessages, userMessage, assistantMessage])
|
||||
|
||||
// Reset scroll following state for new query
|
||||
shouldFollowScrollRef.current = true
|
||||
// Set flag to indicate we're receiving a response
|
||||
isReceivingResponseRef.current = true
|
||||
|
||||
// Force scroll to bottom after messages are rendered
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 0)
|
||||
|
||||
// Clear input and set loading
|
||||
setInputValue('')
|
||||
setIsLoading(true)
|
||||
|
||||
// Reset input height to minimum after clearing input
|
||||
if (inputRef.current) {
|
||||
if ('style' in inputRef.current) {
|
||||
inputRef.current.style.height = '40px'
|
||||
}
|
||||
}
|
||||
|
||||
// Create a function to update the assistant's message
|
||||
const updateAssistantMessage = (chunk: string, isError?: boolean) => {
|
||||
assistantMessage.content += chunk
|
||||
|
||||
// Start thinking timer on first sight of think tag
|
||||
if (assistantMessage.content.includes('<think>') && !thinkingStartTime.current) {
|
||||
thinkingStartTime.current = Date.now()
|
||||
}
|
||||
|
||||
// Use the new robust COT parsing function
|
||||
const cotResult = parseCOTContent(assistantMessage.content)
|
||||
|
||||
// Update thinking state
|
||||
assistantMessage.isThinking = cotResult.isThinking
|
||||
|
||||
// Only calculate time and extract thinking content once when thinking is complete
|
||||
if (cotResult.hasValidThinkBlock && !thinkingProcessed.current) {
|
||||
if (thinkingStartTime.current && !assistantMessage.thinkingTime) {
|
||||
const duration = (Date.now() - thinkingStartTime.current) / 1000
|
||||
assistantMessage.thinkingTime = parseFloat(duration.toFixed(2))
|
||||
}
|
||||
thinkingProcessed.current = true
|
||||
}
|
||||
|
||||
// Update content based on parsing results
|
||||
assistantMessage.thinkingContent = cotResult.thinkingContent
|
||||
// Only fallback to full content if not in a thinking state.
|
||||
if (cotResult.isThinking) {
|
||||
assistantMessage.displayContent = ''
|
||||
} else {
|
||||
assistantMessage.displayContent = cotResult.displayContent || assistantMessage.content
|
||||
}
|
||||
|
||||
// Detect if the assistant message contains a complete mermaid code block
|
||||
// Simple heuristic: look for ```mermaid ... ```
|
||||
const mermaidBlockRegex = /```mermaid\s+([\s\S]+?)```/g
|
||||
let mermaidRendered = false
|
||||
let match
|
||||
while ((match = mermaidBlockRegex.exec(assistantMessage.content)) !== null) {
|
||||
// If the block is not too short, consider it complete
|
||||
if (match[1] && match[1].trim().length > 10) {
|
||||
mermaidRendered = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assistantMessage.mermaidRendered = mermaidRendered
|
||||
|
||||
// Detect if the assistant message contains complete LaTeX formulas
|
||||
const latexRendered = detectLatexCompleteness(assistantMessage.content)
|
||||
assistantMessage.latexRendered = latexRendered
|
||||
|
||||
// Single unified update to avoid race conditions
|
||||
setMessages((prev) => {
|
||||
const newMessages = [...prev]
|
||||
const lastMessage = newMessages[newMessages.length - 1]
|
||||
if (lastMessage && lastMessage.id === assistantMessage.id) {
|
||||
// Update all properties at once to maintain consistency
|
||||
Object.assign(lastMessage, {
|
||||
content: assistantMessage.content,
|
||||
thinkingContent: assistantMessage.thinkingContent,
|
||||
displayContent: assistantMessage.displayContent,
|
||||
isThinking: assistantMessage.isThinking,
|
||||
isError: isError,
|
||||
mermaidRendered: assistantMessage.mermaidRendered,
|
||||
latexRendered: assistantMessage.latexRendered,
|
||||
thinkingTime: assistantMessage.thinkingTime
|
||||
})
|
||||
}
|
||||
return newMessages
|
||||
})
|
||||
|
||||
// After updating content, scroll to bottom if auto-scroll is enabled
|
||||
// Use a longer delay to ensure DOM has updated
|
||||
if (shouldFollowScrollRef.current) {
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 30)
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare query parameters
|
||||
const state = useSettingsStore.getState()
|
||||
|
||||
// Add user prompt to history if it exists and is not empty
|
||||
if (state.querySettings.user_prompt && state.querySettings.user_prompt.trim()) {
|
||||
state.addUserPromptToHistory(state.querySettings.user_prompt.trim())
|
||||
}
|
||||
|
||||
// Determine the effective mode
|
||||
const effectiveMode = modeOverride || state.querySettings.mode
|
||||
|
||||
// Determine effective history turns with bypass override
|
||||
const configuredHistoryTurns = state.querySettings.history_turns || 0
|
||||
const effectiveHistoryTurns = (effectiveMode === 'bypass' && configuredHistoryTurns === 0)
|
||||
? 3
|
||||
: configuredHistoryTurns
|
||||
|
||||
const queryParams = {
|
||||
...state.querySettings,
|
||||
query: actualQuery,
|
||||
response_type: 'Multiple Paragraphs',
|
||||
conversation_history: effectiveHistoryTurns > 0
|
||||
? prevMessages
|
||||
.filter((m) => m.isError !== true)
|
||||
.slice(-effectiveHistoryTurns * 2)
|
||||
.map((m) => ({ role: m.role, content: m.content }))
|
||||
: [],
|
||||
...(modeOverride ? { mode: modeOverride } : {})
|
||||
}
|
||||
|
||||
try {
|
||||
// Run query
|
||||
if (state.querySettings.stream) {
|
||||
let errorMessage = ''
|
||||
await queryTextStream(queryParams, updateAssistantMessage, (error) => {
|
||||
errorMessage += error
|
||||
})
|
||||
if (errorMessage) {
|
||||
if (assistantMessage.content) {
|
||||
errorMessage = assistantMessage.content + '\n' + errorMessage
|
||||
}
|
||||
updateAssistantMessage(errorMessage, true)
|
||||
}
|
||||
} else {
|
||||
const response = await queryText(queryParams)
|
||||
updateAssistantMessage(response.response)
|
||||
}
|
||||
} catch (err) {
|
||||
// Handle error
|
||||
updateAssistantMessage(`${t('retrievePanel.retrieval.error')}\n${errorMessage(err)}`, true)
|
||||
} finally {
|
||||
// Clear loading and add messages to state
|
||||
setIsLoading(false)
|
||||
isReceivingResponseRef.current = false
|
||||
|
||||
// Enhanced cleanup with error handling to prevent memory leaks
|
||||
try {
|
||||
// Final COT state validation and cleanup
|
||||
const finalCotResult = parseCOTContent(assistantMessage.content)
|
||||
|
||||
// Force set final state - stream ended so thinking must be false
|
||||
assistantMessage.isThinking = false
|
||||
|
||||
// If we have a complete thinking block but time wasn't calculated, do final calculation
|
||||
if (finalCotResult.hasValidThinkBlock && thinkingStartTime.current && !assistantMessage.thinkingTime) {
|
||||
const duration = (Date.now() - thinkingStartTime.current) / 1000
|
||||
assistantMessage.thinkingTime = parseFloat(duration.toFixed(2))
|
||||
}
|
||||
|
||||
// Ensure display content is correctly set based on final parsing
|
||||
if (finalCotResult.displayContent !== undefined) {
|
||||
assistantMessage.displayContent = finalCotResult.displayContent
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in final COT state validation:', error)
|
||||
// Force reset state on error
|
||||
assistantMessage.isThinking = false
|
||||
} finally {
|
||||
// Ensure cleanup happens regardless of errors
|
||||
thinkingStartTime.current = null
|
||||
}
|
||||
|
||||
// Save history with error handling
|
||||
try {
|
||||
useSettingsStore
|
||||
.getState()
|
||||
.setRetrievalHistory([...prevMessages, userMessage, assistantMessage])
|
||||
} catch (error) {
|
||||
console.error('Error saving retrieval history:', error)
|
||||
}
|
||||
}
|
||||
},
|
||||
[inputValue, isLoading, messages, setMessages, t, scrollToBottom]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && e.shiftKey) {
|
||||
// Shift+Enter: Insert newline
|
||||
e.preventDefault()
|
||||
const target = e.target as HTMLInputElement | HTMLTextAreaElement
|
||||
const start = target.selectionStart || 0
|
||||
const end = target.selectionEnd || 0
|
||||
const newValue = inputValue.slice(0, start) + '\n' + inputValue.slice(end)
|
||||
setInputValue(newValue)
|
||||
|
||||
// Set cursor position after the newline and adjust height if needed
|
||||
setTimeout(() => {
|
||||
if (target.setSelectionRange) {
|
||||
target.setSelectionRange(start + 1, start + 1)
|
||||
}
|
||||
|
||||
// Manually trigger height adjustment for textarea after component switch
|
||||
if (inputRef.current && inputRef.current.tagName === 'TEXTAREA') {
|
||||
adjustTextareaHeight(inputRef.current as HTMLTextAreaElement)
|
||||
}
|
||||
}, 0)
|
||||
} else if (e.key === 'Enter' && !e.shiftKey) {
|
||||
// Enter: Submit form
|
||||
e.preventDefault()
|
||||
handleSubmit(e as any)
|
||||
}
|
||||
}, [inputValue, handleSubmit, adjustTextareaHeight])
|
||||
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
// Get pasted text content
|
||||
const pastedText = e.clipboardData.getData('text')
|
||||
|
||||
// Check if it contains newlines
|
||||
if (pastedText.includes('\n')) {
|
||||
e.preventDefault() // Prevent default paste behavior
|
||||
|
||||
// Get current cursor position
|
||||
const target = e.target as HTMLInputElement | HTMLTextAreaElement
|
||||
const start = target.selectionStart || 0
|
||||
const end = target.selectionEnd || 0
|
||||
|
||||
// Build new value
|
||||
const newValue = inputValue.slice(0, start) + pastedText + inputValue.slice(end)
|
||||
|
||||
// Update state (this will trigger component switch to Textarea)
|
||||
setInputValue(newValue)
|
||||
|
||||
// Set cursor position to end of pasted content
|
||||
setTimeout(() => {
|
||||
if (inputRef.current && inputRef.current.setSelectionRange) {
|
||||
const newCursorPosition = start + pastedText.length
|
||||
inputRef.current.setSelectionRange(newCursorPosition, newCursorPosition)
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
// If no newlines, let default paste behavior continue
|
||||
}, [inputValue])
|
||||
|
||||
// Effect to handle component switching and maintain focus
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
// When component type changes, restore focus and cursor position
|
||||
const currentElement = inputRef.current
|
||||
const cursorPosition = currentElement.selectionStart || inputValue.length
|
||||
|
||||
// Use requestAnimationFrame to ensure DOM update is complete
|
||||
requestAnimationFrame(() => {
|
||||
currentElement.focus()
|
||||
if (currentElement.setSelectionRange) {
|
||||
currentElement.setSelectionRange(cursorPosition, cursorPosition)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [hasMultipleLines, inputValue.length]) // Include inputValue.length dependency
|
||||
|
||||
// Effect to adjust textarea height when switching to multi-line mode
|
||||
useEffect(() => {
|
||||
if (hasMultipleLines && inputRef.current && inputRef.current.tagName === 'TEXTAREA') {
|
||||
adjustTextareaHeight(inputRef.current as HTMLTextAreaElement)
|
||||
}
|
||||
}, [hasMultipleLines, inputValue, adjustTextareaHeight])
|
||||
|
||||
// Reference to track if we should follow scroll during streaming (using ref for synchronous updates)
|
||||
const shouldFollowScrollRef = useRef(true)
|
||||
const thinkingStartTime = useRef<number | null>(null)
|
||||
const thinkingProcessed = useRef(false)
|
||||
// Reference to track if user interaction is from the form area
|
||||
const isFormInteractionRef = useRef(false)
|
||||
// Reference to track if scroll was triggered programmatically
|
||||
const programmaticScrollRef = useRef(false)
|
||||
// Reference to track if we're currently receiving a streaming response
|
||||
const isReceivingResponseRef = useRef(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Add cleanup effect for memory leak prevention
|
||||
useEffect(() => {
|
||||
// Component cleanup - reset timer state to prevent memory leaks
|
||||
return () => {
|
||||
if (thinkingStartTime.current) {
|
||||
thinkingStartTime.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Add event listeners to detect when user manually interacts with the container
|
||||
useEffect(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// Handle significant mouse wheel events - only disable auto-scroll for deliberate scrolling
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
// Only consider significant wheel movements (more than 10px)
|
||||
if (Math.abs(e.deltaY) > 10 && !isFormInteractionRef.current) {
|
||||
shouldFollowScrollRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle scroll events - only disable auto-scroll if not programmatically triggered
|
||||
// and if it's a significant scroll
|
||||
const handleScroll = throttle(() => {
|
||||
// If this is a programmatic scroll, don't disable auto-scroll
|
||||
if (programmaticScrollRef.current) {
|
||||
programmaticScrollRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if scrolled to bottom or very close to bottom
|
||||
const container = messagesContainerRef.current;
|
||||
if (container) {
|
||||
const isAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 20;
|
||||
|
||||
// If at bottom, enable auto-scroll, otherwise disable it
|
||||
if (isAtBottom) {
|
||||
shouldFollowScrollRef.current = true;
|
||||
} else if (!isFormInteractionRef.current && !isReceivingResponseRef.current) {
|
||||
shouldFollowScrollRef.current = false;
|
||||
}
|
||||
}
|
||||
}, 30);
|
||||
|
||||
// Add event listeners - only listen for wheel and scroll events
|
||||
container.addEventListener('wheel', handleWheel as EventListener);
|
||||
container.addEventListener('scroll', handleScroll as EventListener);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('wheel', handleWheel as EventListener);
|
||||
container.removeEventListener('scroll', handleScroll as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Add event listeners to the form area to prevent disabling auto-scroll when interacting with form
|
||||
useEffect(() => {
|
||||
const form = document.querySelector('form');
|
||||
if (!form) return;
|
||||
|
||||
const handleFormMouseDown = () => {
|
||||
// Set flag to indicate form interaction
|
||||
isFormInteractionRef.current = true;
|
||||
|
||||
// Reset the flag after a short delay
|
||||
setTimeout(() => {
|
||||
isFormInteractionRef.current = false;
|
||||
}, 500); // Give enough time for the form interaction to complete
|
||||
};
|
||||
|
||||
form.addEventListener('mousedown', handleFormMouseDown);
|
||||
|
||||
return () => {
|
||||
form.removeEventListener('mousedown', handleFormMouseDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Use a longer debounce time for better performance with large message updates
|
||||
const debouncedMessages = useDebounce(messages, 150)
|
||||
useEffect(() => {
|
||||
// Only auto-scroll if enabled
|
||||
if (shouldFollowScrollRef.current) {
|
||||
// Force scroll to bottom when messages change
|
||||
scrollToBottom()
|
||||
}
|
||||
}, [debouncedMessages, scrollToBottom])
|
||||
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessages([])
|
||||
useSettingsStore.getState().setRetrievalHistory([])
|
||||
}, [setMessages])
|
||||
|
||||
// Handle copying message content with robust clipboard support
|
||||
const handleCopyMessage = useCallback(async (message: MessageWithError) => {
|
||||
let contentToCopy = '';
|
||||
|
||||
if (message.role === 'user') {
|
||||
// User messages: copy original content
|
||||
contentToCopy = message.content || '';
|
||||
} else {
|
||||
// Assistant messages: prefer processed display content, fallback to original content
|
||||
const finalDisplayContent = message.displayContent !== undefined
|
||||
? message.displayContent
|
||||
: (message.content || '');
|
||||
contentToCopy = finalDisplayContent;
|
||||
}
|
||||
|
||||
if (!contentToCopy.trim()) {
|
||||
toast.error(t('retrievePanel.chatMessage.copyEmpty', 'No content to copy'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await copyToClipboard(contentToCopy);
|
||||
|
||||
if (result.success) {
|
||||
// Show success message with method used
|
||||
const methodMessages: Record<string, string> = {
|
||||
'clipboard-api': t('retrievePanel.chatMessage.copySuccess', 'Content copied to clipboard'),
|
||||
'execCommand': t('retrievePanel.chatMessage.copySuccessLegacy', 'Content copied (legacy method)'),
|
||||
'manual-select': t('retrievePanel.chatMessage.copySuccessManual', 'Content copied (manual method)'),
|
||||
'fallback': t('retrievePanel.chatMessage.copySuccess', 'Content copied to clipboard')
|
||||
};
|
||||
|
||||
toast.success(methodMessages[result.method] || t('retrievePanel.chatMessage.copySuccess', 'Content copied to clipboard'));
|
||||
} else {
|
||||
// Show error with fallback instructions
|
||||
if (result.method === 'fallback') {
|
||||
toast.error(
|
||||
result.error || t('retrievePanel.chatMessage.copyFailed', 'Failed to copy content'),
|
||||
{
|
||||
description: t('retrievePanel.chatMessage.copyManualInstruction', 'Please select and copy the text manually')
|
||||
}
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
t('retrievePanel.chatMessage.copyFailed', 'Failed to copy content'),
|
||||
{
|
||||
description: result.error
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Clipboard operation failed:', err);
|
||||
toast.error(
|
||||
t('retrievePanel.chatMessage.copyError', 'Copy operation failed'),
|
||||
{
|
||||
description: err instanceof Error ? err.message : 'Unknown error occurred'
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<div className="flex size-full gap-2 px-2 pb-12 overflow-hidden">
|
||||
<div className="flex grow flex-col gap-4">
|
||||
<div className="relative grow">
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className="bg-primary-foreground/60 absolute inset-0 flex flex-col overflow-auto rounded-lg border p-2"
|
||||
onClick={() => {
|
||||
if (shouldFollowScrollRef.current) {
|
||||
shouldFollowScrollRef.current = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
{messages.length === 0 ? (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center text-lg">
|
||||
{t('retrievePanel.retrieval.startPrompt')}
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => { // Remove unused idx
|
||||
// isComplete logic is now handled internally based on message.mermaidRendered
|
||||
return (
|
||||
<div
|
||||
key={message.id} // Use stable ID for key
|
||||
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'} items-end gap-2`}
|
||||
>
|
||||
{message.role === 'user' && (
|
||||
<Button
|
||||
onClick={() => handleCopyMessage(message)}
|
||||
className="mb-2 size-6 rounded-md opacity-60 transition-opacity hover:opacity-100 shrink-0"
|
||||
tooltip={t('retrievePanel.chatMessage.copyTooltip')}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<CopyIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<ChatMessage message={message} isTabActive={isRetrievalTabActive} />
|
||||
{message.role === 'assistant' && (
|
||||
<Button
|
||||
onClick={() => handleCopyMessage(message)}
|
||||
className="mb-2 size-6 rounded-md opacity-60 transition-opacity hover:opacity-100 shrink-0"
|
||||
tooltip={t('retrievePanel.chatMessage.copyTooltip')}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<CopyIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<div ref={messagesEndRef} className="pb-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex shrink-0 items-center gap-2"
|
||||
autoComplete="on"
|
||||
method="post"
|
||||
action="#"
|
||||
role="search"
|
||||
>
|
||||
{/* Hidden submit button to ensure form meets HTML standards */}
|
||||
<input type="submit" style={{ display: 'none' }} tabIndex={-1} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={clearMessages}
|
||||
disabled={isLoading}
|
||||
size="sm"
|
||||
>
|
||||
<EraserIcon />
|
||||
{t('retrievePanel.retrieval.clear')}
|
||||
</Button>
|
||||
<div className="flex-1 relative">
|
||||
<label htmlFor="query-input" className="sr-only">
|
||||
{t('retrievePanel.retrieval.placeholder')}
|
||||
</label>
|
||||
{hasMultipleLines ? (
|
||||
<Textarea
|
||||
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
|
||||
id="query-input"
|
||||
autoComplete="on"
|
||||
className="w-full min-h-[40px] max-h-[120px] overflow-y-auto"
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder={t('retrievePanel.retrieval.placeholder')}
|
||||
disabled={isLoading}
|
||||
rows={1}
|
||||
style={{
|
||||
resize: 'none',
|
||||
height: 'auto',
|
||||
minHeight: '40px',
|
||||
maxHeight: '120px'
|
||||
}}
|
||||
onInput={(e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
requestAnimationFrame(() => {
|
||||
target.style.height = 'auto'
|
||||
target.style.height = Math.min(target.scrollHeight, 120) + 'px'
|
||||
})
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
ref={inputRef as React.RefObject<HTMLInputElement>}
|
||||
id="query-input"
|
||||
autoComplete="on"
|
||||
className="w-full"
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder={t('retrievePanel.retrieval.placeholder')}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
)}
|
||||
{/* Error message below input */}
|
||||
{inputError && (
|
||||
<div className="absolute left-0 top-full mt-1 text-xs text-red-500">{inputError}</div>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" variant="default" disabled={isLoading} size="sm">
|
||||
<SendIcon />
|
||||
{t('retrievePanel.retrieval.send')}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
<QuerySettings />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import Button from '@/components/ui/Button'
|
||||
import { SiteInfo, webuiPrefix } from '@/lib/constants'
|
||||
import AppSettings from '@/components/AppSettings'
|
||||
import { TabsList, TabsTrigger } from '@/components/ui/Tabs'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useAuthStore } from '@/stores/state'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { navigationService } from '@/services/navigation'
|
||||
import { ZapIcon, GithubIcon, LogOutIcon } from 'lucide-react'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/Tooltip'
|
||||
|
||||
interface NavigationTabProps {
|
||||
value: string
|
||||
currentTab: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function NavigationTab({ value, currentTab, children }: NavigationTabProps) {
|
||||
return (
|
||||
<TabsTrigger
|
||||
value={value}
|
||||
className={cn(
|
||||
'cursor-pointer px-2 py-1 transition-all',
|
||||
currentTab === value ? '!bg-emerald-400 !text-zinc-50' : 'hover:bg-background/60'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</TabsTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsNavigation() {
|
||||
const currentTab = useSettingsStore.use.currentTab()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex h-8 self-center">
|
||||
<TabsList className="h-full gap-2">
|
||||
<NavigationTab value="documents" currentTab={currentTab}>
|
||||
{t('header.documents')}
|
||||
</NavigationTab>
|
||||
<NavigationTab value="knowledge-graph" currentTab={currentTab}>
|
||||
{t('header.knowledgeGraph')}
|
||||
</NavigationTab>
|
||||
<NavigationTab value="retrieval" currentTab={currentTab}>
|
||||
{t('header.retrieval')}
|
||||
</NavigationTab>
|
||||
<NavigationTab value="api" currentTab={currentTab}>
|
||||
{t('header.api')}
|
||||
</NavigationTab>
|
||||
</TabsList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SiteHeader() {
|
||||
const { t } = useTranslation()
|
||||
const { isGuestMode, coreVersion, apiVersion, username, webuiTitle, webuiDescription } = useAuthStore()
|
||||
|
||||
const versionDisplay = (coreVersion && apiVersion)
|
||||
? `${coreVersion}/${apiVersion}`
|
||||
: null;
|
||||
|
||||
// Check if frontend needs rebuild (apiVersion ends with warning symbol)
|
||||
const hasWarning = apiVersion?.endsWith('⚠️');
|
||||
const versionTooltip = hasWarning
|
||||
? t('header.frontendNeedsRebuild')
|
||||
: versionDisplay ? `v${versionDisplay}` : '';
|
||||
|
||||
const handleLogout = () => {
|
||||
navigationService.navigateToLogin();
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="border-border/40 bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-10 w-full border-b px-4 backdrop-blur">
|
||||
<div className="min-w-[200px] w-auto flex items-center">
|
||||
<a href={webuiPrefix} className="flex items-center gap-2">
|
||||
<ZapIcon className="size-4 text-emerald-400" aria-hidden="true" />
|
||||
<span className="font-bold md:inline-block">{SiteInfo.name}</span>
|
||||
</a>
|
||||
{webuiTitle && (
|
||||
<div className="flex items-center">
|
||||
<span className="mx-1 text-xs text-gray-500 dark:text-gray-400">|</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="font-medium text-sm cursor-default">
|
||||
{webuiTitle}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{webuiDescription && (
|
||||
<TooltipContent side="bottom">
|
||||
{webuiDescription}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex h-10 flex-1 items-center justify-center">
|
||||
<TabsNavigation />
|
||||
{isGuestMode && (
|
||||
<div className="ml-2 self-center px-2 py-1 text-xs bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200 rounded-md">
|
||||
{t('login.guestMode', 'Guest Mode')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="w-[200px] flex items-center justify-end">
|
||||
<div className="flex items-center gap-2">
|
||||
{versionDisplay && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 mr-1 cursor-default">
|
||||
v{versionDisplay}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{versionTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" side="bottom" tooltip={t('header.projectRepository')}>
|
||||
<a href={SiteInfo.github} target="_blank" rel="noopener noreferrer">
|
||||
<GithubIcon className="size-4" aria-hidden="true" />
|
||||
</a>
|
||||
</Button>
|
||||
<AppSettings />
|
||||
{!isGuestMode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
side="bottom"
|
||||
tooltip={`${t('header.logout')} (${username})`}
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOutIcon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user