mirror of https://github.com/kortix-ai/suna.git
Merge pull request #545 from kubet/fix/redundancy-fixes
Fix/redundancy fixes
This commit is contained in:
commit
53300e2ee6
|
@ -25,6 +25,7 @@ import { SubscriptionStatus } from '@/components/thread/chat-input/_use-model-se
|
|||
import { UnifiedMessage, ApiMessageType, ToolCallInput, Project } from '../_types';
|
||||
import { useThreadData, useToolCalls, useBilling, useKeyboardShortcuts } from '../_hooks';
|
||||
import { ThreadError, UpgradeDialog, ThreadLayout } from '../_components';
|
||||
import { useVncPreloader } from '@/hooks/useVncPreloader';
|
||||
|
||||
export default function ThreadPage({
|
||||
params,
|
||||
|
@ -44,6 +45,7 @@ export default function ThreadPage({
|
|||
const [isSending, setIsSending] = useState(false);
|
||||
const [fileViewerOpen, setFileViewerOpen] = useState(false);
|
||||
const [fileToView, setFileToView] = useState<string | null>(null);
|
||||
const [filePathList, setFilePathList] = useState<string[] | undefined>(undefined);
|
||||
const [showUpgradeDialog, setShowUpgradeDialog] = useState(false);
|
||||
const [debugMode, setDebugMode] = useState(false);
|
||||
const [initialPanelOpenAttempted, setInitialPanelOpenAttempted] = useState(false);
|
||||
|
@ -125,6 +127,9 @@ export default function ThreadPage({
|
|||
? 'active'
|
||||
: 'no_subscription';
|
||||
|
||||
useVncPreloader(project);
|
||||
|
||||
|
||||
const handleProjectRenamed = useCallback((newName: string) => {
|
||||
}, []);
|
||||
|
||||
|
@ -329,12 +334,13 @@ export default function ThreadPage({
|
|||
}
|
||||
}, [stopStreaming, agentRunId, stopAgentMutation, agentRunsQuery, setAgentStatus]);
|
||||
|
||||
const handleOpenFileViewer = useCallback((filePath?: string) => {
|
||||
const handleOpenFileViewer = useCallback((filePath?: string, filePathList?: string[]) => {
|
||||
if (filePath) {
|
||||
setFileToView(filePath);
|
||||
} else {
|
||||
setFileToView(null);
|
||||
}
|
||||
setFilePathList(filePathList);
|
||||
setFileViewerOpen(true);
|
||||
}, []);
|
||||
|
||||
|
@ -523,6 +529,7 @@ export default function ThreadPage({
|
|||
fileViewerOpen={fileViewerOpen}
|
||||
setFileViewerOpen={setFileViewerOpen}
|
||||
fileToView={fileToView}
|
||||
filePathList={filePathList}
|
||||
toolCalls={toolCalls}
|
||||
messages={messages as ApiMessageType[]}
|
||||
externalNavIndex={externalNavIndex}
|
||||
|
@ -564,6 +571,7 @@ export default function ThreadPage({
|
|||
fileViewerOpen={fileViewerOpen}
|
||||
setFileViewerOpen={setFileViewerOpen}
|
||||
fileToView={fileToView}
|
||||
filePathList={filePathList}
|
||||
toolCalls={toolCalls}
|
||||
messages={messages as ApiMessageType[]}
|
||||
externalNavIndex={externalNavIndex}
|
||||
|
|
|
@ -17,10 +17,11 @@ interface ThreadLayoutProps {
|
|||
isSidePanelOpen: boolean;
|
||||
onToggleSidePanel: () => void;
|
||||
onProjectRenamed?: (newName: string) => void;
|
||||
onViewFiles: (filePath?: string) => void;
|
||||
onViewFiles: (filePath?: string, filePathList?: string[]) => void;
|
||||
fileViewerOpen: boolean;
|
||||
setFileViewerOpen: (open: boolean) => void;
|
||||
fileToView: string | null;
|
||||
filePathList?: string[];
|
||||
toolCalls: ToolCallInput[];
|
||||
messages: ApiMessageType[];
|
||||
externalNavIndex?: number;
|
||||
|
@ -53,6 +54,7 @@ export function ThreadLayout({
|
|||
fileViewerOpen,
|
||||
setFileViewerOpen,
|
||||
fileToView,
|
||||
filePathList,
|
||||
toolCalls,
|
||||
messages,
|
||||
externalNavIndex,
|
||||
|
@ -79,8 +81,7 @@ export function ThreadLayout({
|
|||
)}
|
||||
|
||||
<div
|
||||
className={`flex flex-col flex-1 overflow-hidden transition-all duration-200 ease-in-out ${
|
||||
(!initialLoadCompleted || isSidePanelOpen)
|
||||
className={`flex flex-col flex-1 overflow-hidden transition-all duration-200 ease-in-out ${(!initialLoadCompleted || isSidePanelOpen)
|
||||
? 'mr-[90%] sm:mr-[450px] md:mr-[500px] lg:mr-[550px] xl:mr-[650px]'
|
||||
: ''
|
||||
}`}
|
||||
|
@ -122,6 +123,7 @@ export function ThreadLayout({
|
|||
sandboxId={sandboxId}
|
||||
initialFilePath={fileToView}
|
||||
project={project || undefined}
|
||||
filePathList={filePathList}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
@ -30,6 +30,7 @@ import { safeJsonParse } from '@/components/thread/utils';
|
|||
import { useAgentStream } from '@/hooks/useAgentStream';
|
||||
import { threadErrorCodeMessages } from '@/lib/constants/errorCodeMessages';
|
||||
import { ThreadSkeleton } from '@/components/thread/content/ThreadSkeleton';
|
||||
import { useVncPreloader } from '@/hooks/useVncPreloader';
|
||||
|
||||
// Extend the base Message type with the expected database fields
|
||||
interface ApiMessageType extends BaseApiMessageType {
|
||||
|
@ -101,6 +102,9 @@ export default function ThreadPage({
|
|||
|
||||
const userClosedPanelRef = useRef(false);
|
||||
|
||||
// Preload VNC iframe as soon as project data is available
|
||||
useVncPreloader(project);
|
||||
|
||||
useEffect(() => {
|
||||
userClosedPanelRef.current = true;
|
||||
setIsSidePanelOpen(false);
|
||||
|
@ -612,7 +616,7 @@ export default function ThreadPage({
|
|||
[messages, toolCalls],
|
||||
);
|
||||
|
||||
const handleOpenFileViewer = useCallback((filePath?: string) => {
|
||||
const handleOpenFileViewer = useCallback((filePath?: string, filePathList?: string[]) => {
|
||||
if (filePath) {
|
||||
setFileToView(filePath);
|
||||
} else {
|
||||
|
|
|
@ -29,7 +29,7 @@ interface AttachmentGroupProps {
|
|||
onRemove?: (index: number) => void;
|
||||
layout?: LayoutStyle;
|
||||
className?: string;
|
||||
onFileClick?: (path: string) => void;
|
||||
onFileClick?: (path: string, filePathList?: string[]) => void;
|
||||
showPreviews?: boolean;
|
||||
maxHeight?: string;
|
||||
gridImageHeight?: number; // New prop for grid image height
|
||||
|
@ -101,8 +101,10 @@ export function AttachmentGroup({
|
|||
// Ensure path has proper format when clicking
|
||||
const handleFileClick = (path: string) => {
|
||||
if (onFileClick) {
|
||||
// Just pass the path to the parent handler which will call getFileUrl
|
||||
onFileClick(path);
|
||||
// Create the file path list from all files in the group
|
||||
const filePathList = uniqueFiles.map(file => getFilePath(file));
|
||||
// Pass both the clicked path and the complete list
|
||||
onFileClick(path, filePathList);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -52,7 +52,7 @@ const HIDE_STREAMING_XML_TAGS = new Set([
|
|||
]);
|
||||
|
||||
// Helper function to render attachments (keeping original implementation for now)
|
||||
export function renderAttachments(attachments: string[], fileViewerHandler?: (filePath?: string) => void, sandboxId?: string, project?: Project) {
|
||||
export function renderAttachments(attachments: string[], fileViewerHandler?: (filePath?: string, filePathList?: string[]) => void, sandboxId?: string, project?: Project) {
|
||||
if (!attachments || attachments.length === 0) return null;
|
||||
|
||||
// Note: Preloading is now handled by React Query in the main ThreadContent component
|
||||
|
@ -72,7 +72,7 @@ export function renderMarkdownContent(
|
|||
content: string,
|
||||
handleToolClick: (assistantMessageId: string | null, toolName: string) => void,
|
||||
messageId: string | null,
|
||||
fileViewerHandler?: (filePath?: string) => void,
|
||||
fileViewerHandler?: (filePath?: string, filePathList?: string[]) => void,
|
||||
sandboxId?: string,
|
||||
project?: Project,
|
||||
debugMode?: boolean
|
||||
|
@ -165,7 +165,7 @@ export interface ThreadContentProps {
|
|||
streamingToolCall?: any;
|
||||
agentStatus: 'idle' | 'running' | 'connecting' | 'error';
|
||||
handleToolClick: (assistantMessageId: string | null, toolName: string) => void;
|
||||
handleOpenFileViewer: (filePath?: string) => void;
|
||||
handleOpenFileViewer: (filePath?: string, filePathList?: string[]) => void;
|
||||
readOnly?: boolean;
|
||||
visibleMessages?: UnifiedMessage[]; // For playback mode
|
||||
streamingText?: string; // For playback mode
|
||||
|
@ -327,9 +327,9 @@ export const ThreadContent: React.FC<ThreadContentProps> = ({
|
|||
}
|
||||
|
||||
// Handle streaming content
|
||||
if(streamingTextContent) {
|
||||
if (streamingTextContent) {
|
||||
const lastGroup = groupedMessages.at(-1);
|
||||
if(!lastGroup || lastGroup.type === 'user'){
|
||||
if (!lastGroup || lastGroup.type === 'user') {
|
||||
// Create new assistant group for streaming content
|
||||
assistantGroupCounter++;
|
||||
groupedMessages.push({
|
||||
|
|
|
@ -521,7 +521,7 @@ export function FileAttachment({
|
|||
|
||||
interface FileAttachmentGridProps {
|
||||
attachments: string[];
|
||||
onFileClick?: (path: string) => void;
|
||||
onFileClick?: (path: string, filePathList?: string[]) => void;
|
||||
className?: string;
|
||||
sandboxId?: string;
|
||||
showPreviews?: boolean;
|
||||
|
|
|
@ -59,6 +59,7 @@ interface FileViewerModalProps {
|
|||
sandboxId: string;
|
||||
initialFilePath?: string | null;
|
||||
project?: Project;
|
||||
filePathList?: string[];
|
||||
}
|
||||
|
||||
export function FileViewerModal({
|
||||
|
@ -67,6 +68,7 @@ export function FileViewerModal({
|
|||
sandboxId,
|
||||
initialFilePath,
|
||||
project,
|
||||
filePathList,
|
||||
}: FileViewerModalProps) {
|
||||
// Safely handle initialFilePath to ensure it's a string or null
|
||||
const safeInitialFilePath = typeof initialFilePath === 'string' ? initialFilePath : null;
|
||||
|
@ -78,6 +80,20 @@ export function FileViewerModal({
|
|||
const [currentPath, setCurrentPath] = useState('/workspace');
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||
|
||||
// Add navigation state for file list mode
|
||||
const [currentFileIndex, setCurrentFileIndex] = useState<number>(-1);
|
||||
const isFileListMode = Boolean(filePathList && filePathList.length > 0);
|
||||
|
||||
// Debug filePathList changes
|
||||
useEffect(() => {
|
||||
console.log('[FILE VIEWER DEBUG] filePathList changed:', {
|
||||
filePathList,
|
||||
length: filePathList?.length,
|
||||
isFileListMode,
|
||||
currentFileIndex
|
||||
});
|
||||
}, [filePathList, isFileListMode, currentFileIndex]);
|
||||
|
||||
// Use React Query for directory listing
|
||||
const {
|
||||
data: files = [],
|
||||
|
@ -176,12 +192,20 @@ export function FileViewerModal({
|
|||
|
||||
// Helper function to clear the selected file
|
||||
const clearSelectedFile = useCallback(() => {
|
||||
console.log(`[FILE VIEWER DEBUG] clearSelectedFile called, isFileListMode: ${isFileListMode}`);
|
||||
setSelectedFilePath(null);
|
||||
setRawContent(null);
|
||||
setTextContentForRenderer(null); // Clear derived text content
|
||||
setBlobUrlForRenderer(null); // Clear derived blob URL
|
||||
setContentError(null);
|
||||
}, []);
|
||||
// Only reset file list mode index when not in file list mode
|
||||
if (!isFileListMode) {
|
||||
console.log(`[FILE VIEWER DEBUG] Resetting currentFileIndex in clearSelectedFile`);
|
||||
setCurrentFileIndex(-1);
|
||||
} else {
|
||||
console.log(`[FILE VIEWER DEBUG] Keeping currentFileIndex in clearSelectedFile because in file list mode`);
|
||||
}
|
||||
}, [isFileListMode]);
|
||||
|
||||
// Core file opening function
|
||||
const openFile = useCallback(
|
||||
|
@ -227,6 +251,14 @@ export function FileViewerModal({
|
|||
clearSelectedFile();
|
||||
setSelectedFilePath(file.path);
|
||||
|
||||
// Only reset file index if we're NOT in file list mode or the file is not in the list
|
||||
if (!isFileListMode || !filePathList?.includes(file.path)) {
|
||||
console.log(`[FILE VIEWER DEBUG] Resetting currentFileIndex because not in file list mode or file not in list`);
|
||||
setCurrentFileIndex(-1);
|
||||
} else {
|
||||
console.log(`[FILE VIEWER DEBUG] Keeping currentFileIndex because file is in file list mode`);
|
||||
}
|
||||
|
||||
// The useFileContentQuery hook will automatically handle loading the content
|
||||
// No need to manually fetch here - React Query will handle it
|
||||
},
|
||||
|
@ -234,6 +266,8 @@ export function FileViewerModal({
|
|||
selectedFilePath,
|
||||
clearSelectedFile,
|
||||
normalizePath,
|
||||
isFileListMode,
|
||||
filePathList,
|
||||
],
|
||||
);
|
||||
|
||||
|
@ -376,6 +410,51 @@ export function FileViewerModal({
|
|||
[sandboxId],
|
||||
);
|
||||
|
||||
// Navigation functions for file list mode
|
||||
const navigateToFileByIndex = useCallback((index: number) => {
|
||||
console.log('[FILE VIEWER DEBUG] navigateToFileByIndex called:', {
|
||||
index,
|
||||
isFileListMode,
|
||||
filePathList,
|
||||
filePathListLength: filePathList?.length
|
||||
});
|
||||
|
||||
if (!isFileListMode || !filePathList || index < 0 || index >= filePathList.length) {
|
||||
console.log('[FILE VIEWER DEBUG] navigateToFileByIndex early return - invalid conditions');
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = filePathList[index];
|
||||
console.log('[FILE VIEWER DEBUG] Setting currentFileIndex to:', index, 'for file:', filePath);
|
||||
setCurrentFileIndex(index);
|
||||
|
||||
// Create a temporary FileInfo object for the file
|
||||
const fileName = filePath.split('/').pop() || '';
|
||||
const normalizedPath = normalizePath(filePath);
|
||||
|
||||
const fileInfo: FileInfo = {
|
||||
name: fileName,
|
||||
path: normalizedPath,
|
||||
is_dir: false,
|
||||
size: 0,
|
||||
mod_time: new Date().toISOString(),
|
||||
};
|
||||
|
||||
openFile(fileInfo);
|
||||
}, [isFileListMode, filePathList, normalizePath, openFile]);
|
||||
|
||||
const navigatePrevious = useCallback(() => {
|
||||
if (currentFileIndex > 0) {
|
||||
navigateToFileByIndex(currentFileIndex - 1);
|
||||
}
|
||||
}, [currentFileIndex, navigateToFileByIndex]);
|
||||
|
||||
const navigateNext = useCallback(() => {
|
||||
if (isFileListMode && filePathList && currentFileIndex < filePathList.length - 1) {
|
||||
navigateToFileByIndex(currentFileIndex + 1);
|
||||
}
|
||||
}, [currentFileIndex, isFileListMode, filePathList, navigateToFileByIndex]);
|
||||
|
||||
// Handle initial file path - Runs ONLY ONCE on open if initialFilePath is provided
|
||||
useEffect(() => {
|
||||
// Only run if modal is open, initial path is provided, AND it hasn't been processed yet
|
||||
|
@ -384,6 +463,32 @@ export function FileViewerModal({
|
|||
`[FILE VIEWER] useEffect[initialFilePath]: Processing initial path: ${safeInitialFilePath}`,
|
||||
);
|
||||
|
||||
// If we're in file list mode, find the index and navigate to it
|
||||
if (isFileListMode && filePathList) {
|
||||
console.log('[FILE VIEWER DEBUG] Initial file path - file list mode detected:', {
|
||||
isFileListMode,
|
||||
filePathList,
|
||||
safeInitialFilePath,
|
||||
filePathListLength: filePathList.length
|
||||
});
|
||||
|
||||
const normalizedInitialPath = normalizePath(safeInitialFilePath);
|
||||
const index = filePathList.findIndex(path => normalizePath(path) === normalizedInitialPath);
|
||||
|
||||
console.log('[FILE VIEWER DEBUG] Found index for initial file:', {
|
||||
normalizedInitialPath,
|
||||
index,
|
||||
foundPath: index !== -1 ? filePathList[index] : 'not found'
|
||||
});
|
||||
|
||||
if (index !== -1) {
|
||||
console.log(`[FILE VIEWER] File list mode: navigating to index ${index} for ${normalizedInitialPath}`);
|
||||
navigateToFileByIndex(index);
|
||||
setInitialPathProcessed(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize the initial path
|
||||
const fullPath = normalizePath(safeInitialFilePath);
|
||||
const lastSlashIndex = fullPath.lastIndexOf('/');
|
||||
|
@ -434,7 +539,7 @@ export function FileViewerModal({
|
|||
);
|
||||
setInitialPathProcessed(false);
|
||||
}
|
||||
}, [open, safeInitialFilePath, initialPathProcessed, normalizePath, currentPath, openFile]);
|
||||
}, [open, safeInitialFilePath, initialPathProcessed, normalizePath, currentPath, openFile, isFileListMode, filePathList, navigateToFileByIndex]);
|
||||
|
||||
// Effect to handle cached file content updates
|
||||
useEffect(() => {
|
||||
|
@ -505,6 +610,24 @@ export function FileViewerModal({
|
|||
};
|
||||
}, [blobUrlForRenderer, isDownloading]);
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
if (!open || !isFileListMode) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
navigatePrevious();
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
navigateNext();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, isFileListMode, navigatePrevious, navigateNext]);
|
||||
|
||||
// Handle modal close
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
|
@ -522,6 +645,7 @@ export function FileViewerModal({
|
|||
// React Query will handle clearing the files data
|
||||
setInitialPathProcessed(false);
|
||||
setIsInitialLoad(true);
|
||||
setCurrentFileIndex(-1); // Reset file index
|
||||
}
|
||||
onOpenChange(open);
|
||||
},
|
||||
|
@ -856,14 +980,63 @@ export function FileViewerModal({
|
|||
[currentPath, sandboxId, refetchFiles],
|
||||
);
|
||||
|
||||
// Reset file list mode when modal opens without filePathList
|
||||
useEffect(() => {
|
||||
if (open && !filePathList) {
|
||||
setCurrentFileIndex(-1);
|
||||
}
|
||||
}, [open, filePathList]);
|
||||
|
||||
// --- Render --- //
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[90vw] md:max-w-[1200px] w-[95vw] h-[90vh] max-h-[900px] flex flex-col p-0 gap-0 overflow-hidden">
|
||||
<DialogHeader className="px-4 py-2 border-b flex-shrink-0">
|
||||
<DialogHeader className="px-4 py-2 border-b flex-shrink-0 flex flex-row gap-4 items-center">
|
||||
<DialogTitle className="text-lg font-semibold">
|
||||
Workspace Files
|
||||
</DialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Navigation arrows for file list mode */}
|
||||
{(() => {
|
||||
// Debug logging
|
||||
console.log('[FILE VIEWER DEBUG] Navigation visibility check:', {
|
||||
isFileListMode,
|
||||
selectedFilePath,
|
||||
filePathList,
|
||||
filePathListLength: filePathList?.length,
|
||||
currentFileIndex,
|
||||
shouldShow: isFileListMode && selectedFilePath && filePathList && filePathList.length > 1 && currentFileIndex >= 0
|
||||
});
|
||||
|
||||
return isFileListMode && selectedFilePath && filePathList && filePathList.length > 1 && currentFileIndex >= 0;
|
||||
})() && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={navigatePrevious}
|
||||
disabled={currentFileIndex <= 0}
|
||||
className="h-8 w-8 p-0"
|
||||
title="Previous file (←)"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-xs text-muted-foreground px-1">
|
||||
{currentFileIndex + 1} / {filePathList.length}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={navigateNext}
|
||||
disabled={currentFileIndex >= filePathList.length - 1}
|
||||
className="h-8 w-8 p-0"
|
||||
title="Next file (→)"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Navigation Bar */}
|
||||
|
|
|
@ -46,6 +46,7 @@ export function WebSearchToolView({
|
|||
const [expandedResults, setExpandedResults] = useState<Record<number, boolean>>({});
|
||||
|
||||
const query = extractSearchQuery(assistantContent);
|
||||
console.log('toolContent', toolContent);
|
||||
const searchResults = extractSearchResults(toolContent);
|
||||
const toolTitle = getToolTitle(name);
|
||||
|
||||
|
@ -226,6 +227,7 @@ export function WebSearchToolView({
|
|||
<div className="space-y-4">
|
||||
{searchResults.map((result, idx) => {
|
||||
const { icon: ResultTypeIcon, label: resultTypeLabel } = getResultType(result);
|
||||
console.log('result', result);
|
||||
const isExpanded = expandedResults[idx] || false;
|
||||
const favicon = getFavicon(result.url);
|
||||
|
||||
|
@ -290,7 +292,18 @@ export function WebSearchToolView({
|
|||
"text-sm text-zinc-600 dark:text-zinc-400",
|
||||
isExpanded ? "" : "line-clamp-2"
|
||||
)}>
|
||||
{result.snippet}
|
||||
{result?.snippet
|
||||
?.replace(/\\\\\n/g, ' ')
|
||||
?.replace(/\\\\n/g, ' ')
|
||||
?.replace(/\\n/g, ' ')
|
||||
?.replace(/\\\\\t/g, ' ')
|
||||
?.replace(/\\\\t/g, ' ')
|
||||
?.replace(/\\t/g, ' ')
|
||||
?.replace(/\\\\\r/g, ' ')
|
||||
?.replace(/\\\\r/g, ' ')
|
||||
?.replace(/\\r/g, ' ')
|
||||
?.replace(/\s+/g, ' ')
|
||||
?.trim()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -517,6 +517,25 @@ export function extractSearchQuery(content: string | object | undefined | null):
|
|||
const contentStr = normalizeContentToString(content);
|
||||
if (!contentStr) return null;
|
||||
|
||||
// First, look for ToolResult pattern in the content string
|
||||
const toolResultMatch = contentStr.match(
|
||||
/ToolResult\(.*?output='([\s\S]*?)'.*?\)/,
|
||||
);
|
||||
|
||||
if (toolResultMatch) {
|
||||
try {
|
||||
// Parse the output JSON from ToolResult
|
||||
const outputJson = JSON.parse(toolResultMatch[1]);
|
||||
|
||||
// Check if this is the new Tavily response format with query field
|
||||
if (outputJson.query && typeof outputJson.query === 'string') {
|
||||
return outputJson.query;
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue with other extraction methods
|
||||
}
|
||||
}
|
||||
|
||||
let contentToSearch = contentStr; // Start with the normalized content
|
||||
|
||||
// Try parsing as JSON first
|
||||
|
@ -597,31 +616,38 @@ export function extractUrlsAndTitles(
|
|||
): Array<{ title: string; url: string; snippet?: string }> {
|
||||
const results: Array<{ title: string; url: string; snippet?: string }> = [];
|
||||
|
||||
// First try to handle the case where content contains fragments of JSON objects
|
||||
// This pattern matches both complete and partial result objects
|
||||
const jsonFragmentPattern =
|
||||
/"?title"?\s*:\s*"([^"]+)"[^}]*"?url"?\s*:\s*"?(https?:\/\/[^",\s]+)"?|https?:\/\/[^",\s]+[",\s]*"?title"?\s*:\s*"([^"]+)"/g;
|
||||
let fragmentMatch;
|
||||
|
||||
while ((fragmentMatch = jsonFragmentPattern.exec(content)) !== null) {
|
||||
// Extract title and URL from the matched groups
|
||||
// Groups can match in different orders depending on the fragment format
|
||||
const title = fragmentMatch[1] || fragmentMatch[3] || '';
|
||||
let url = fragmentMatch[2] || '';
|
||||
|
||||
// If no URL was found in the JSON fragment pattern but we have a title,
|
||||
// try to find a URL on its own line above the title
|
||||
if (!url && title) {
|
||||
// Look backwards from the match position
|
||||
const beforeText = content.substring(0, fragmentMatch.index);
|
||||
const urlMatch = beforeText.match(/https?:\/\/[^\s",]+\s*$/);
|
||||
if (urlMatch && urlMatch[0]) {
|
||||
url = urlMatch[0].trim();
|
||||
// Try to parse as JSON first to extract proper results
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map(result => ({
|
||||
title: result.title || '',
|
||||
url: result.url || '',
|
||||
snippet: result.content || result.snippet || '',
|
||||
}));
|
||||
}
|
||||
if (parsed.results && Array.isArray(parsed.results)) {
|
||||
return parsed.results.map(result => ({
|
||||
title: result.title || '',
|
||||
url: result.url || '',
|
||||
snippet: result.content || '',
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
// Not valid JSON, continue with regex extraction
|
||||
}
|
||||
|
||||
// Look for properly formatted JSON objects with title and url
|
||||
const jsonObjectPattern = /\{\s*"title"\s*:\s*"([^"]+)"\s*,\s*"url"\s*:\s*"(https?:\/\/[^"]+)"\s*(?:,\s*"content"\s*:\s*"([^"]*)")?\s*\}/g;
|
||||
let objectMatch;
|
||||
|
||||
while ((objectMatch = jsonObjectPattern.exec(content)) !== null) {
|
||||
const title = objectMatch[1];
|
||||
const url = objectMatch[2];
|
||||
const snippet = objectMatch[3] || '';
|
||||
|
||||
if (url && title && !results.some((r) => r.url === url)) {
|
||||
results.push({ title, url });
|
||||
results.push({ title, url, snippet });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1015,8 +1041,50 @@ export function extractSearchResults(
|
|||
const contentStr = normalizeContentToString(content);
|
||||
if (!contentStr) return [];
|
||||
|
||||
// First check if it's the new Tavily response format
|
||||
try {
|
||||
// Instead of trying to parse the complex ToolResult JSON,
|
||||
// let's look for the results array pattern directly in the content
|
||||
|
||||
// Look for the results array pattern within the content
|
||||
const resultsPattern = /"results":\s*\[([^\]]*(?:\[[^\]]*\][^\]]*)*)\]/;
|
||||
const resultsMatch = contentStr.match(resultsPattern);
|
||||
|
||||
if (resultsMatch) {
|
||||
try {
|
||||
// Extract just the results array and parse it
|
||||
const resultsArrayStr = '[' + resultsMatch[1] + ']';
|
||||
const results = JSON.parse(resultsArrayStr);
|
||||
|
||||
if (Array.isArray(results)) {
|
||||
return results.map(result => ({
|
||||
title: result.title || '',
|
||||
url: result.url || '',
|
||||
snippet: result.content || '',
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse results array:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Look for individual result objects
|
||||
const resultObjectPattern = /\{\s*"url":\s*"([^"]+)"\s*,\s*"title":\s*"([^"]+)"\s*,\s*"content":\s*"([^"]*)"[^}]*\}/g;
|
||||
const results = [];
|
||||
let match;
|
||||
|
||||
while ((match = resultObjectPattern.exec(contentStr)) !== null) {
|
||||
results.push({
|
||||
url: match[1],
|
||||
title: match[2],
|
||||
snippet: match[3],
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length > 0) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Try parsing the entire content as JSON (for direct Tavily responses)
|
||||
const parsedContent = JSON.parse(contentStr);
|
||||
|
||||
// Check if this is the new Tavily response format
|
||||
|
@ -1031,28 +1099,16 @@ export function extractSearchResults(
|
|||
// Continue with existing logic for backward compatibility
|
||||
if (parsedContent.content && typeof parsedContent.content === 'string') {
|
||||
// Look for a tool_result tag (with attributes)
|
||||
const toolResultMatch = parsedContent.content.match(
|
||||
const toolResultTagMatch = parsedContent.content.match(
|
||||
/<tool_result[^>]*>\s*<web-search[^>]*>([\s\S]*?)<\/web-search>\s*<\/tool_result>/,
|
||||
);
|
||||
if (toolResultMatch) {
|
||||
if (toolResultTagMatch) {
|
||||
// Try to parse the results array
|
||||
try {
|
||||
return JSON.parse(toolResultMatch[1]);
|
||||
return JSON.parse(toolResultTagMatch[1]);
|
||||
} catch (e) {
|
||||
// Fallback to regex extraction of URLs and titles
|
||||
return extractUrlsAndTitles(toolResultMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Look for ToolResult pattern
|
||||
const outputMatch = parsedContent.content.match(
|
||||
/ToolResult\(.*?output='([\s\S]*?)'.*?\)/,
|
||||
);
|
||||
if (outputMatch) {
|
||||
try {
|
||||
return JSON.parse(outputMatch[1]);
|
||||
} catch (e) {
|
||||
return extractUrlsAndTitles(outputMatch[1]);
|
||||
return extractUrlsAndTitles(toolResultTagMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -41,13 +41,26 @@ function CodeBlockCode({
|
|||
|
||||
useEffect(() => {
|
||||
async function highlight() {
|
||||
const html = await codeToHtml(code, { lang: language, theme });
|
||||
const html = await codeToHtml(code, {
|
||||
lang: language,
|
||||
theme,
|
||||
transformers: [
|
||||
{
|
||||
pre(node) {
|
||||
if (node.properties.style) {
|
||||
node.properties.style = (node.properties.style as string)
|
||||
.replace(/background-color:[^;]+;?/g, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
setHighlightedHtml(html);
|
||||
}
|
||||
highlight();
|
||||
}, [code, language, theme]);
|
||||
|
||||
const classNames = cn('', className);
|
||||
const classNames = cn('[&_pre]:!bg-background/95 [&_pre]:rounded-lg [&_pre]:p-4', className);
|
||||
|
||||
// SSR fallback: render plain code if not hydrated yet
|
||||
return highlightedHtml ? (
|
||||
|
|
|
@ -0,0 +1,140 @@
|
|||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { Project } from '@/lib/api';
|
||||
|
||||
export function useVncPreloader(project: Project | null) {
|
||||
const preloadedIframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const isPreloadedRef = useRef(false);
|
||||
const retryTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const maxRetriesRef = useRef(0);
|
||||
const isRetryingRef = useRef(false);
|
||||
|
||||
const startPreloading = useCallback((vncUrl: string) => {
|
||||
// Prevent multiple simultaneous preload attempts
|
||||
if (isRetryingRef.current || isPreloadedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
isRetryingRef.current = true;
|
||||
console.log(`[VNC PRELOADER] Attempt ${maxRetriesRef.current + 1}/10 - Starting VNC preload:`, vncUrl);
|
||||
|
||||
// Create hidden iframe for preloading
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = vncUrl;
|
||||
iframe.style.position = 'absolute';
|
||||
iframe.style.left = '-9999px';
|
||||
iframe.style.top = '-9999px';
|
||||
iframe.style.width = '1024px';
|
||||
iframe.style.height = '768px';
|
||||
iframe.style.border = '0';
|
||||
iframe.title = 'VNC Preloader';
|
||||
|
||||
// Set a timeout to detect if iframe fails to load (for 502 errors)
|
||||
const loadTimeout = setTimeout(() => {
|
||||
console.log('[VNC PRELOADER] Load timeout - VNC service likely not ready');
|
||||
|
||||
// Clean up current iframe
|
||||
if (iframe.parentNode) {
|
||||
iframe.parentNode.removeChild(iframe);
|
||||
}
|
||||
|
||||
// Retry if we haven't exceeded max retries
|
||||
if (maxRetriesRef.current < 10) {
|
||||
maxRetriesRef.current++;
|
||||
isRetryingRef.current = false;
|
||||
|
||||
// Exponential backoff: 2s, 3s, 4.5s, 6.75s, etc. (max 15s)
|
||||
const delay = Math.min(2000 * Math.pow(1.5, maxRetriesRef.current - 1), 15000);
|
||||
console.log(`[VNC PRELOADER] Retrying in ${delay}ms (attempt ${maxRetriesRef.current + 1}/10)`);
|
||||
|
||||
retryTimeoutRef.current = setTimeout(() => {
|
||||
startPreloading(vncUrl);
|
||||
}, delay);
|
||||
} else {
|
||||
console.log('[VNC PRELOADER] Max retries reached, giving up on preloading');
|
||||
isRetryingRef.current = false;
|
||||
}
|
||||
}, 5000); // 5 second timeout
|
||||
|
||||
// Handle successful iframe load
|
||||
iframe.onload = () => {
|
||||
clearTimeout(loadTimeout);
|
||||
console.log('[VNC PRELOADER] ✅ VNC iframe preloaded successfully!');
|
||||
isPreloadedRef.current = true;
|
||||
isRetryingRef.current = false;
|
||||
preloadedIframeRef.current = iframe;
|
||||
};
|
||||
|
||||
// Handle iframe load errors
|
||||
iframe.onerror = () => {
|
||||
clearTimeout(loadTimeout);
|
||||
console.log('[VNC PRELOADER] VNC iframe failed to load (onerror)');
|
||||
|
||||
// Clean up current iframe
|
||||
if (iframe.parentNode) {
|
||||
iframe.parentNode.removeChild(iframe);
|
||||
}
|
||||
|
||||
// Retry if we haven't exceeded max retries
|
||||
if (maxRetriesRef.current < 10) {
|
||||
maxRetriesRef.current++;
|
||||
isRetryingRef.current = false;
|
||||
|
||||
const delay = Math.min(2000 * Math.pow(1.5, maxRetriesRef.current - 1), 15000);
|
||||
console.log(`[VNC PRELOADER] Retrying in ${delay}ms (attempt ${maxRetriesRef.current + 1}/10)`);
|
||||
|
||||
retryTimeoutRef.current = setTimeout(() => {
|
||||
startPreloading(vncUrl);
|
||||
}, delay);
|
||||
} else {
|
||||
console.log('[VNC PRELOADER] Max retries reached, giving up on preloading');
|
||||
isRetryingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Add to DOM to start loading
|
||||
document.body.appendChild(iframe);
|
||||
console.log('[VNC PRELOADER] VNC iframe added to DOM, waiting for load...');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Only preload if we have project data with VNC info and haven't started preloading yet
|
||||
if (!project?.sandbox?.vnc_preview || !project?.sandbox?.pass || isPreloadedRef.current || isRetryingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const vncUrl = `${project.sandbox.vnc_preview}/vnc_lite.html?password=${project.sandbox.pass}&autoconnect=true&scale=local&width=1024&height=768`;
|
||||
|
||||
// Reset retry counter for new project
|
||||
maxRetriesRef.current = 0;
|
||||
isRetryingRef.current = false;
|
||||
|
||||
// Start the preloading process with a small delay to let the sandbox initialize
|
||||
const initialDelay = setTimeout(() => {
|
||||
startPreloading(vncUrl);
|
||||
}, 1000); // 1 second initial delay
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
clearTimeout(initialDelay);
|
||||
|
||||
if (retryTimeoutRef.current) {
|
||||
clearTimeout(retryTimeoutRef.current);
|
||||
retryTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (preloadedIframeRef.current && preloadedIframeRef.current.parentNode) {
|
||||
preloadedIframeRef.current.parentNode.removeChild(preloadedIframeRef.current);
|
||||
preloadedIframeRef.current = null;
|
||||
}
|
||||
|
||||
isPreloadedRef.current = false;
|
||||
isRetryingRef.current = false;
|
||||
maxRetriesRef.current = 0;
|
||||
};
|
||||
}, [project?.sandbox?.vnc_preview, project?.sandbox?.pass, startPreloading]);
|
||||
|
||||
return {
|
||||
isPreloaded: isPreloadedRef.current,
|
||||
preloadedIframe: preloadedIframeRef.current
|
||||
};
|
||||
}
|
Loading…
Reference in New Issue