diff --git a/.gitignore b/.gitignore index ff2ae59633..e302d15a46 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,7 @@ bower_components/ .clineignore .cursor .aider* +.bg-shell/ # Floobits .floo @@ -129,6 +130,7 @@ helm/**/charts/ helm/**/.values.yaml !/client/src/@types/i18next.d.ts +!/client/src/@types/react.d.ts # SAML Idp cert *.cert @@ -143,7 +145,6 @@ helm/**/.values.yaml /.codeium *.local.md - # Removed Windows wrapper files per user request hive-mind-prompt-*.txt @@ -175,3 +176,4 @@ claude-flow # Removed Windows wrapper files per user request hive-mind-prompt-*.txt CLAUDE.md +.gsd diff --git a/api/server/services/Files/Citations/index.js b/api/server/services/Files/Citations/index.js index 008e21d7c4..a1d9322467 100644 --- a/api/server/services/Files/Citations/index.js +++ b/api/server/services/Files/Citations/index.js @@ -47,7 +47,10 @@ async function processFileCitations({ user, appConfig, toolArtifact, toolCallId, logger.error( `[processFileCitations] Permission check failed for FILE_CITATIONS: ${error.message}`, ); - logger.debug(`[processFileCitations] Proceeding with citations due to permission error`); + logger.warn( + '[processFileCitations] Returning null citations due to permission check error — citations will not be shown for this message', + ); + return null; } } @@ -145,6 +148,8 @@ async function enhanceSourcesWithMetadata(sources, appConfig) { metadata: { ...source.metadata, storageType: configuredStorageType, + fileType: fileRecord.type || undefined, + fileBytes: fileRecord.bytes || undefined, }, }; }); diff --git a/client/jest.config.cjs b/client/jest.config.cjs index 4d9087bff7..375e4418a7 100644 --- a/client/jest.config.cjs +++ b/client/jest.config.cjs @@ -41,7 +41,9 @@ module.exports = { '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 'jest-file-loader', }, - transformIgnorePatterns: ['node_modules/?!@zattoo/use-double-click'], + transformIgnorePatterns: [ + '/node_modules/(?!(@zattoo/use-double-click|@dicebear|@react-dnd|react-dnd.*|dnd-core|filenamify|filename-reserved-regex|heic-to|lowlight|highlight\\.js|fault|react-markdown|unified|bail|trough|devlop|is-.*|parse-entities|stringify-entities|character-.*|trim-lines|style-to-object|inline-style-parser|html-url-attributes|escape-string-regexp|longest-streak|zwitch|ccount|markdown-table|comma-separated-tokens|space-separated-tokens|web-namespaces|property-information|remark-.*|rehype-.*|recma-.*|hast.*|mdast-.*|unist-.*|vfile.*|micromark.*|estree-util-.*|decode-named-character-reference)/)/', + ], setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect', '/test/setupTests.js'], clearMocks: true, }; diff --git a/client/src/@types/react.d.ts b/client/src/@types/react.d.ts new file mode 100644 index 0000000000..edf0b7af3f --- /dev/null +++ b/client/src/@types/react.d.ts @@ -0,0 +1,8 @@ +import 'react'; + +declare module 'react' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface HTMLAttributes { + inert?: boolean | '' | undefined; + } +} diff --git a/client/src/components/Artifacts/Artifacts.tsx b/client/src/components/Artifacts/Artifacts.tsx index 776f689f08..e2a322b1ad 100644 --- a/client/src/components/Artifacts/Artifacts.tsx +++ b/client/src/components/Artifacts/Artifacts.tsx @@ -1,15 +1,16 @@ -import { useRef, useState, useEffect } from 'react'; +import { useRef, useState, useEffect, useCallback } from 'react'; +import copy from 'copy-to-clipboard'; import * as Tabs from '@radix-ui/react-tabs'; import { Code, Play, RefreshCw, X } from 'lucide-react'; import { useSetRecoilState, useResetRecoilState } from 'recoil'; import { Button, Spinner, useMediaQuery, Radio } from '@librechat/client'; import type { SandpackPreviewRef } from '@codesandbox/sandpack-react'; +import CopyButton from '~/components/Messages/Content/CopyButton'; import { useShareContext, useMutationState } from '~/Providers'; import useArtifacts from '~/hooks/Artifacts/useArtifacts'; import DownloadArtifact from './DownloadArtifact'; import ArtifactVersion from './ArtifactVersion'; import ArtifactTabs from './ArtifactTabs'; -import { CopyCodeButton } from './Code'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; import store from '~/store'; @@ -30,6 +31,7 @@ export default function Artifacts() { const [height, setHeight] = useState(90); const [isDragging, setIsDragging] = useState(false); const [blurAmount, setBlurAmount] = useState(0); + const [isCopied, setIsCopied] = useState(false); const dragStartY = useRef(0); const dragStartHeight = useRef(90); const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility); @@ -86,6 +88,16 @@ export default function Artifacts() { setCurrentArtifactId, } = useArtifacts(); + const handleCopyArtifact = useCallback(() => { + const content = currentArtifact?.content ?? ''; + if (!content) { + return; + } + copy(content, { format: 'text/plain' }); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 3000); + }, [currentArtifact?.content]); + const handleDragStart = (e: React.PointerEvent) => { setIsDragging(true); dragStartY.current = e.clientY; @@ -281,7 +293,7 @@ export default function Artifacts() { }} /> )} - + - ); -}; + useEffect(() => { + const scrollContainer = scrollRef.current; + if (!scrollContainer) { + return; + } + + const handleScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = scrollContainer; + const isNearBottom = scrollHeight - scrollTop - clientHeight < 50; + + if (!isNearBottom) { + setUserScrolled(true); + } else { + setUserScrolled(false); + } + }; + + scrollContainer.addEventListener('scroll', handleScroll); + + return () => { + scrollContainer.removeEventListener('scroll', handleScroll); + }; + }, []); + + useEffect(() => { + const scrollContainer = scrollRef.current; + if (!scrollContainer || !isSubmitting || userScrolled) { + return; + } + + scrollContainer.scrollTop = scrollContainer.scrollHeight; + }, [content, isSubmitting, userScrolled]); + + return ( +
+ + {content} + +
+ ); + }, +); diff --git a/client/src/components/Chat/Messages/Content/AgentHandoff.tsx b/client/src/components/Chat/Messages/Content/AgentHandoff.tsx index f5fa162ff2..5a5505ee60 100644 --- a/client/src/components/Chat/Messages/Content/AgentHandoff.tsx +++ b/client/src/components/Chat/Messages/Content/AgentHandoff.tsx @@ -1,24 +1,23 @@ import React, { useMemo, useState } from 'react'; -import { EModelEndpoint, Constants } from 'librechat-data-provider'; import { ChevronDown } from 'lucide-react'; +import { EModelEndpoint, Constants } from 'librechat-data-provider'; import type { TMessage } from 'librechat-data-provider'; import MessageIcon from '~/components/Share/MessageIcon'; +import { useLocalize, useExpandCollapse } from '~/hooks'; import { useAgentsMapContext } from '~/Providers'; -import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; interface AgentHandoffProps { name: string; args: string | Record; - output?: string | null; } const AgentHandoff: React.FC = ({ name, args: _args = '' }) => { const localize = useLocalize(); const agentsMap = useAgentsMapContext(); const [showInfo, setShowInfo] = useState(false); + const { style: expandStyle, ref: expandRef } = useExpandCollapse(showInfo); - /** Extracted agent ID from tool name (e.g., "lc_transfer_to_agent_gUV0wMb7zHt3y3Xjz-8_4" -> "agent_gUV0wMb7zHt3y3Xjz-8_4") */ const targetAgentId = useMemo(() => { if (typeof name !== 'string' || !name.startsWith(Constants.LC_TRANSFER_TO_)) { return null; @@ -44,19 +43,24 @@ const AgentHandoff: React.FC = ({ name, args: _args = '' }) = } }, [_args]) as string; - /** Requires more than 2 characters as can be an empty object: `{}` */ const hasInfo = useMemo(() => (args?.trim()?.length ?? 0) > 2, [args]); return ( -
-
+ +
+
+ {hasInfo && ( +
+
+ {localize('com_ui_handoff_instructions')}: +
+
{args}
+
+ )}
- )} +
); }; diff --git a/client/src/components/Chat/Messages/Content/CodeAnalyze.tsx b/client/src/components/Chat/Messages/Content/CodeAnalyze.tsx index 139496c621..3d4fdee1c9 100644 --- a/client/src/components/Chat/Messages/Content/CodeAnalyze.tsx +++ b/client/src/components/Chat/Messages/Content/CodeAnalyze.tsx @@ -1,8 +1,10 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useRecoilValue } from 'recoil'; +import { Terminal } from 'lucide-react'; import { useProgress, useLocalize } from '~/hooks'; import ProgressText from './ProgressText'; import MarkdownLite from './MarkdownLite'; +import { cn } from '~/utils'; import store from '~/store'; export default function CodeAnalyze({ @@ -16,8 +18,14 @@ export default function CodeAnalyze({ }) { const localize = useLocalize(); const progress = useProgress(initialProgress); - const showAnalysisCode = useRecoilValue(store.showCode); - const [showCode, setShowCode] = useState(showAnalysisCode); + const autoExpand = useRecoilValue(store.autoExpandTools); + const [showCode, setShowCode] = useState(autoExpand); + + useEffect(() => { + if (autoExpand) { + setShowCode(true); + } + }, [autoExpand]); const logs = outputs.reduce((acc, output) => { if (output['logs']) { @@ -28,7 +36,10 @@ export default function CodeAnalyze({ return ( <> -
+ + {progress < 1 ? localize('com_ui_analyzing') : localize('com_ui_analyzing_finished')} + +
setShowCode((prev) => !prev)} @@ -36,6 +47,12 @@ export default function CodeAnalyze({ finishedText={localize('com_ui_analyzing_finished')} hasInput={!!code.length} isExpanded={showCode} + icon={ +
{showCode && ( diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index 4b431d7a98..65ebc66908 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -6,12 +6,12 @@ import type { TAttachment, Agents, } from 'librechat-data-provider'; -import { MessageContext, SearchContext } from '~/Providers'; import { ParallelContentRenderer, type PartWithIndex } from './ParallelContent'; -import { mapAttachments } from '~/utils'; +import { mapAttachments, groupSequentialToolCalls } from '~/utils'; +import { MessageContext, SearchContext } from '~/Providers'; import { EditTextPart, EmptyText } from './Parts'; import MemoryArtifacts from './MemoryArtifacts'; -import Sources from '~/components/Web/Sources'; +import ToolCallGroup from './ToolCallGroup'; import Container from './Container'; import Part from './Part'; @@ -160,10 +160,10 @@ const ContentParts = memo(function ContentParts({ } const isTextPart = part?.type === ContentTypes.TEXT || - typeof (part as unknown as Agents.MessageContentText)?.text !== 'string'; + typeof (part as unknown as Agents.MessageContentText)?.text === 'string'; const isThinkPart = part?.type === ContentTypes.THINK || - typeof (part as unknown as Agents.ReasoningDeltaUpdate)?.think !== 'string'; + typeof (part as unknown as Agents.ReasoningDeltaUpdate)?.think === 'string'; if (!isTextPart && !isThinkPart) { return null; } @@ -216,17 +216,32 @@ const ContentParts = memo(function ContentParts({ sequentialParts.push({ part, idx }); } }); + const groupedParts = groupSequentialToolCalls(sequentialParts); return ( - {showEmptyCursor && ( )} - {sequentialParts.map(({ part, idx }) => renderPart(part, idx, idx === lastContentIdx))} + {groupedParts.map((group) => { + if (group.type === 'single') { + const { part, idx } = group.part; + return renderPart(part, idx, idx === lastContentIdx); + } + return ( + p.idx === lastContentIdx)} + renderPart={renderPart} + lastContentIdx={lastContentIdx} + /> + ); + })} ); }); diff --git a/client/src/components/Chat/Messages/Content/FilePreviewDialog.tsx b/client/src/components/Chat/Messages/Content/FilePreviewDialog.tsx new file mode 100644 index 0000000000..c02e2fee4b --- /dev/null +++ b/client/src/components/Chat/Messages/Content/FilePreviewDialog.tsx @@ -0,0 +1,344 @@ +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import copy from 'copy-to-clipboard'; +import { useRecoilValue } from 'recoil'; +import { Download } from 'lucide-react'; +import { OGDialog, OGDialogContent, OGDialogTitle, OGDialogDescription } from '@librechat/client'; +import CopyButton from '~/components/Messages/Content/CopyButton'; +import { logger, sortPagesByRelevance } from '~/utils'; +import { useFileDownload } from '~/data-provider'; +import { useLocalize } from '~/hooks'; +import store from '~/store'; + +interface FilePreviewDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + fileName: string; + fileId?: string; + relevance?: number; + pages?: number[]; + pageRelevance?: Record; + fileType?: string; + fileSize?: number; +} + +function getFileExtension(filename: string): string { + const dot = filename.lastIndexOf('.'); + return dot > 0 ? filename.slice(dot + 1).toLowerCase() : ''; +} + +function canPreviewByMime(mime?: string): 'pdf' | 'text' | false { + if (!mime) { + return false; + } + if (mime.includes('pdf')) { + return 'pdf'; + } + if ( + mime.startsWith('text/') || + mime.includes('json') || + mime.includes('xml') || + mime.includes('javascript') || + mime.includes('typescript') || + mime.includes('yaml') || + mime.includes('csv') + ) { + return 'text'; + } + return false; +} + +function canPreviewByExt(filename: string): 'pdf' | 'text' | false { + const ext = getFileExtension(filename); + if (ext === 'pdf') { + return 'pdf'; + } + const textExts = new Set([ + 'txt', + 'md', + 'csv', + 'json', + 'xml', + 'yaml', + 'yml', + 'html', + 'css', + 'js', + 'ts', + 'jsx', + 'tsx', + 'py', + 'rb', + 'java', + 'c', + 'cpp', + 'h', + 'go', + 'rs', + 'sh', + 'sql', + 'log', + ]); + return textExts.has(ext) ? 'text' : false; +} + +/** Formats bytes with unit suffix (differs from ~/utils/formatBytes which returns a raw number). */ +function formatBytes(bytes: number): string { + if (bytes >= 1048576) { + return `${(bytes / 1048576).toFixed(1)} MB`; + } + if (bytes >= 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${bytes} B`; +} + +function getDisplayType(fileType?: string, fileName?: string): string { + if (fileType) { + if (fileType.includes('pdf')) { + return 'PDF'; + } + if (fileType.includes('word') || fileType.includes('document')) { + return 'Document'; + } + if (fileType.includes('spreadsheet') || fileType.includes('excel')) { + return 'Spreadsheet'; + } + if (fileType.includes('presentation') || fileType.includes('powerpoint')) { + return 'Presentation'; + } + if (fileType.includes('image')) { + return 'Image'; + } + if (fileType.startsWith('text/')) { + return fileType.split('/')[1]?.toUpperCase() || 'Text'; + } + if (fileType.includes('json')) { + return 'JSON'; + } + if (fileType.includes('xml')) { + return 'XML'; + } + } + const ext = fileName ? getFileExtension(fileName) : ''; + return ext ? ext.toUpperCase() : 'File'; +} + +export default function FilePreviewDialog({ + open, + onOpenChange, + fileName, + fileId, + relevance, + pages, + pageRelevance, + fileType, + fileSize, +}: FilePreviewDialogProps) { + const localize = useLocalize(); + const user = useRecoilValue(store.user); + const { refetch: downloadFile } = useFileDownload(user?.id ?? '', fileId); + + const [fileContent, setFileContent] = useState(null); + const [fileBlobUrl, setFileBlobUrl] = useState(null); + const [loading, setLoading] = useState(false); + const [previewError, setPreviewError] = useState(false); + const [isCopied, setIsCopied] = useState(false); + const loadingRef = useRef(false); + + const previewKind = canPreviewByMime(fileType) || canPreviewByExt(fileName); + + const cancelledRef = useRef(false); + + const loadPreview = useCallback(async () => { + if (!fileId || !previewKind || loadingRef.current) { + return; + } + loadingRef.current = true; + cancelledRef.current = false; + setLoading(true); + setPreviewError(false); + + try { + const result = await downloadFile(); + if (cancelledRef.current || !result.data) { + if (!cancelledRef.current) { + setPreviewError(true); + } + return; + } + + const resp = await fetch(result.data); + const blob = await resp.blob(); + + if (cancelledRef.current) { + return; + } + + if (previewKind === 'text') { + setFileContent(await blob.text()); + } else { + const typed = new Blob([blob], { type: 'application/pdf' }); + setFileBlobUrl(URL.createObjectURL(typed)); + } + } catch { + if (!cancelledRef.current) { + setPreviewError(true); + } + } finally { + loadingRef.current = false; + if (!cancelledRef.current) { + setLoading(false); + } + } + }, [fileId, previewKind, downloadFile]); + + const handleDownload = useCallback(async () => { + if (!fileId) { + return; + } + try { + const result = await downloadFile(); + if (!result.data) { + return; + } + const a = document.createElement('a'); + a.href = result.data; + a.setAttribute('download', fileName); + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(result.data), 1000); + } catch (err) { + logger.error('[FilePreviewDialog] Download failed:', err); + } + }, [downloadFile, fileId, fileName]); + + useEffect(() => { + if (open && previewKind && !fileContent && !fileBlobUrl) { + loadPreview(); + } + }, [open, previewKind, fileContent, fileBlobUrl, loadPreview]); + + useEffect(() => { + return () => { + if (fileBlobUrl) { + URL.revokeObjectURL(fileBlobUrl); + } + }; + }, [fileBlobUrl]); + + useEffect(() => { + if (!open) { + cancelledRef.current = true; + setFileContent(null); + setFileBlobUrl(null); + setPreviewError(false); + setLoading(false); + setIsCopied(false); + } + }, [open]); + + const handleCopy = useCallback(() => { + if (!fileContent) { + return; + } + copy(fileContent, { format: 'text/plain' }); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 3000); + }, [fileContent]); + + const displayType = useMemo(() => getDisplayType(fileType, fileName), [fileType, fileName]); + const sortedPages = useMemo( + () => (pages && pageRelevance ? sortPagesByRelevance(pages, pageRelevance) : pages), + [pages, pageRelevance], + ); + + const metaParts: string[] = [displayType]; + if (relevance != null && relevance > 0) { + metaParts.push(`${localize('com_ui_relevance')}: ${Math.round(relevance * 100)}%`); + } + if (fileSize != null && fileSize > 0) { + metaParts.push(formatBytes(fileSize)); + } + if (sortedPages && sortedPages.length > 0) { + metaParts.push(localize('com_file_pages', { pages: sortedPages.join(', ') })); + } + + return ( + + +
+ {fileName} +
+ + {metaParts.join(' · ')} + + {fileId && ( + + )} +
+
+ +
+ {loading && ( +
+ + {localize('com_ui_loading')} + +
+ )} + {previewError && ( +
+ + {localize('com_ui_preview_unavailable')} + +
+ )} + {fileBlobUrl && ( +