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,11 +81,10 @@ export function ThreadLayout({
|
|||
)}
|
||||
|
||||
<div
|
||||
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]'
|
||||
: ''
|
||||
}`}
|
||||
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]'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<SiteHeader
|
||||
threadId={threadId}
|
||||
|
@ -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);
|
||||
|
@ -361,9 +365,9 @@ export default function ThreadPage({
|
|||
|
||||
const projectData = threadData?.project_id
|
||||
? await getProject(threadData.project_id).catch((err) => {
|
||||
console.warn('[SHARE] Could not load project data:', err);
|
||||
return null;
|
||||
})
|
||||
console.warn('[SHARE] Could not load project data:', err);
|
||||
return null;
|
||||
})
|
||||
: null;
|
||||
|
||||
if (isMounted) {
|
||||
|
@ -423,7 +427,7 @@ export default function ThreadPage({
|
|||
return assistantMsg.content;
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
// Try to extract tool name from content
|
||||
const xmlMatch = assistantContent.match(
|
||||
/<([a-zA-Z\-_]+)(?:\s+[^>]*)?>|<([a-zA-Z\-_]+)(?:\s+[^>]*)?\/>/,
|
||||
|
@ -460,7 +464,7 @@ export default function ThreadPage({
|
|||
return resultMessage.content;
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
// Check for ToolResult pattern first
|
||||
if (toolResultContent && typeof toolResultContent === 'string') {
|
||||
// Look for ToolResult(success=True/False) pattern
|
||||
|
@ -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 {
|
||||
|
@ -721,7 +725,7 @@ export default function ThreadPage({
|
|||
(m) => m.message_id === assistantId && m.type === 'assistant'
|
||||
);
|
||||
if (!assistantMessage) return false;
|
||||
|
||||
|
||||
// Check if this tool call matches
|
||||
return tc.assistantCall?.content === assistantMessage.content;
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
@ -282,7 +282,7 @@ export const ThreadContent: React.FC<ThreadContentProps> = ({
|
|||
const groupedMessages: MessageGroup[] = [];
|
||||
let currentGroup: MessageGroup | null = null;
|
||||
let assistantGroupCounter = 0; // Counter for assistant groups
|
||||
|
||||
|
||||
displayMessages.forEach((message, index) => {
|
||||
const messageType = message.type;
|
||||
const key = message.message_id || `msg-${index}`;
|
||||
|
@ -306,10 +306,10 @@ export const ThreadContent: React.FC<ThreadContentProps> = ({
|
|||
}
|
||||
// Create a new assistant group with a group-level key
|
||||
assistantGroupCounter++;
|
||||
currentGroup = {
|
||||
type: 'assistant_group',
|
||||
messages: [message],
|
||||
key: `assistant-group-${assistantGroupCounter}`
|
||||
currentGroup = {
|
||||
type: 'assistant_group',
|
||||
messages: [message],
|
||||
key: `assistant-group-${assistantGroupCounter}`
|
||||
};
|
||||
}
|
||||
} else if (messageType !== 'status') {
|
||||
|
@ -320,20 +320,20 @@ export const ThreadContent: React.FC<ThreadContentProps> = ({
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Finalize any remaining group
|
||||
if (currentGroup) {
|
||||
groupedMessages.push(currentGroup);
|
||||
}
|
||||
|
||||
|
||||
// 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({
|
||||
type: 'assistant_group',
|
||||
type: 'assistant_group',
|
||||
messages: [{
|
||||
content: streamingTextContent,
|
||||
type: 'assistant',
|
||||
|
@ -344,7 +344,7 @@ export const ThreadContent: React.FC<ThreadContentProps> = ({
|
|||
is_llm_message: true,
|
||||
thread_id: 'streamingTextContent',
|
||||
sequence: Infinity,
|
||||
}],
|
||||
}],
|
||||
key: `assistant-group-${assistantGroupCounter}-streaming`
|
||||
});
|
||||
} else if (lastGroup.type === 'assistant_group') {
|
||||
|
|
|
@ -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 */}
|
||||
|
|
|
@ -57,7 +57,7 @@ export function AskToolView({
|
|||
if (assistantContent) {
|
||||
try {
|
||||
const contentStr = normalizeContentToString(assistantContent);
|
||||
|
||||
|
||||
// Extract attachments if present
|
||||
const attachmentsMatch = contentStr.match(/attachments=["']([^"']*)["']/i);
|
||||
if (attachmentsMatch) {
|
||||
|
@ -92,13 +92,13 @@ export function AskToolView({
|
|||
</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{!isStreaming && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={
|
||||
isSuccess
|
||||
? "bg-gradient-to-b from-emerald-200 to-emerald-100 text-emerald-700 dark:from-emerald-800/50 dark:to-emerald-900/60 dark:text-emerald-300"
|
||||
isSuccess
|
||||
? "bg-gradient-to-b from-emerald-200 to-emerald-100 text-emerald-700 dark:from-emerald-800/50 dark:to-emerald-900/60 dark:text-emerald-300"
|
||||
: "bg-gradient-to-b from-rose-200 to-rose-100 text-rose-700 dark:from-rose-800/50 dark:to-rose-900/60 dark:text-rose-300"
|
||||
}
|
||||
>
|
||||
|
@ -129,12 +129,12 @@ export function AskToolView({
|
|||
<Paperclip className="h-4 w-4" />
|
||||
Files ({askData.attachments.length})
|
||||
</div>
|
||||
|
||||
|
||||
<div className={cn(
|
||||
"grid gap-3",
|
||||
askData.attachments.length === 1 ? "grid-cols-1" :
|
||||
askData.attachments.length > 4 ? "grid-cols-1 sm:grid-cols-2 md:grid-cols-3" :
|
||||
"grid-cols-1 sm:grid-cols-2"
|
||||
askData.attachments.length > 4 ? "grid-cols-1 sm:grid-cols-2 md:grid-cols-3" :
|
||||
"grid-cols-1 sm:grid-cols-2"
|
||||
)}>
|
||||
{askData.attachments
|
||||
.sort((a, b) => {
|
||||
|
@ -142,7 +142,7 @@ export function AskToolView({
|
|||
const bIsImage = isImageFile(b);
|
||||
const aIsPreviewable = isPreviewableFile(a);
|
||||
const bIsPreviewable = isPreviewableFile(b);
|
||||
|
||||
|
||||
if (aIsImage && !bIsImage) return -1;
|
||||
if (!aIsImage && bIsImage) return 1;
|
||||
if (aIsPreviewable && !bIsPreviewable) return -1;
|
||||
|
@ -152,10 +152,10 @@ export function AskToolView({
|
|||
.map((attachment, index) => {
|
||||
const isImage = isImageFile(attachment);
|
||||
const isPreviewable = isPreviewableFile(attachment);
|
||||
const shouldSpanFull = (askData.attachments!.length % 2 === 1 &&
|
||||
askData.attachments!.length > 1 &&
|
||||
index === askData.attachments!.length - 1);
|
||||
|
||||
const shouldSpanFull = (askData.attachments!.length % 2 === 1 &&
|
||||
askData.attachments!.length > 1 &&
|
||||
index === askData.attachments!.length - 1);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
|
@ -174,7 +174,7 @@ export function AskToolView({
|
|||
className={cn(
|
||||
"w-full",
|
||||
isImage ? "h-auto min-h-[54px]" :
|
||||
isPreviewable ? "min-h-[240px] max-h-[400px] overflow-auto" : "h-[54px]"
|
||||
isPreviewable ? "min-h-[240px] max-h-[400px] overflow-auto" : "h-[54px]"
|
||||
)}
|
||||
customStyle={
|
||||
isImage ? {
|
||||
|
@ -182,14 +182,14 @@ export function AskToolView({
|
|||
height: 'auto',
|
||||
'--attachment-height': shouldSpanFull ? '240px' : '180px'
|
||||
} as React.CSSProperties :
|
||||
isPreviewable ? {
|
||||
gridColumn: '1 / -1'
|
||||
} :
|
||||
shouldSpanFull ? {
|
||||
gridColumn: '1 / -1'
|
||||
} : {
|
||||
width: '100%'
|
||||
}
|
||||
isPreviewable ? {
|
||||
gridColumn: '1 / -1'
|
||||
} :
|
||||
shouldSpanFull ? {
|
||||
gridColumn: '1 / -1'
|
||||
} : {
|
||||
width: '100%'
|
||||
}
|
||||
}
|
||||
collapsed={false}
|
||||
project={project}
|
||||
|
@ -198,7 +198,7 @@ export function AskToolView({
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
{assistantTimestamp && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
|
@ -230,7 +230,7 @@ export function AskToolView({
|
|||
User Interaction
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{assistantTimestamp ? formatTimestamp(assistantTimestamp) : ''}
|
||||
</div>
|
||||
|
|
|
@ -54,7 +54,7 @@ export function CompleteToolView({
|
|||
if (assistantContent) {
|
||||
try {
|
||||
const contentStr = normalizeContentToString(assistantContent);
|
||||
|
||||
|
||||
// Try to extract content from <complete> tag
|
||||
const completeMatch = contentStr.match(/<complete[^>]*>([^<]*)<\/complete>/);
|
||||
if (completeMatch) {
|
||||
|
@ -88,7 +88,7 @@ export function CompleteToolView({
|
|||
if (toolContent && !isStreaming) {
|
||||
try {
|
||||
const contentStr = normalizeContentToString(toolContent);
|
||||
|
||||
|
||||
// Try to extract from ToolResult pattern
|
||||
const toolResultMatch = contentStr.match(/ToolResult\([^)]*output=['"]([^'"]+)['"]/);
|
||||
if (toolResultMatch) {
|
||||
|
@ -143,13 +143,13 @@ export function CompleteToolView({
|
|||
</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{!isStreaming && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={
|
||||
isSuccess
|
||||
? "bg-gradient-to-b from-emerald-200 to-emerald-100 text-emerald-700 dark:from-emerald-800/50 dark:to-emerald-900/60 dark:text-emerald-300"
|
||||
isSuccess
|
||||
? "bg-gradient-to-b from-emerald-200 to-emerald-100 text-emerald-700 dark:from-emerald-800/50 dark:to-emerald-900/60 dark:text-emerald-300"
|
||||
: "bg-gradient-to-b from-rose-200 to-rose-100 text-rose-700 dark:from-rose-800/50 dark:to-rose-900/60 dark:text-rose-300"
|
||||
}
|
||||
>
|
||||
|
@ -211,7 +211,7 @@ export function CompleteToolView({
|
|||
const { icon: FileIcon, color, bgColor } = getFileIconAndColor(attachment);
|
||||
const fileName = attachment.split('/').pop() || attachment;
|
||||
const filePath = attachment.includes('/') ? attachment.substring(0, attachment.lastIndexOf('/')) : '';
|
||||
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
|
@ -255,7 +255,7 @@ export function CompleteToolView({
|
|||
</div>
|
||||
<div className="space-y-2">
|
||||
{completeData.tasksCompleted.map((task, index) => (
|
||||
<div
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start gap-3 p-3 bg-muted/30 rounded-lg border border-border/50"
|
||||
>
|
||||
|
@ -314,7 +314,7 @@ export function CompleteToolView({
|
|||
Task Completion
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{toolTimestamp && !isStreaming
|
||||
? formatTimestamp(toolTimestamp)
|
||||
|
|
|
@ -35,9 +35,9 @@ import { cn } from '@/lib/utils';
|
|||
import { useTheme } from 'next-themes';
|
||||
import { CodeBlockCode } from '@/components/ui/code-block';
|
||||
import { constructHtmlPreviewUrl } from '@/lib/utils/url';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
|
@ -168,16 +168,16 @@ export function FileOperationToolView({
|
|||
};
|
||||
|
||||
const operation = getOperationType();
|
||||
|
||||
|
||||
let filePath: string | null = null;
|
||||
let fileContent: string | null = null;
|
||||
|
||||
|
||||
// console.log('[FileOperationToolView] Debug:', {
|
||||
// isStreaming,
|
||||
// assistantContent,
|
||||
// assistantContentType: typeof assistantContent,
|
||||
// });
|
||||
|
||||
|
||||
if (assistantContent) {
|
||||
try {
|
||||
const parsed = typeof assistantContent === 'string' ? JSON.parse(assistantContent) : assistantContent;
|
||||
|
@ -190,7 +190,7 @@ export function FileOperationToolView({
|
|||
/file_path=["']([^"']*?)["']/i,
|
||||
/<(?:create-file|delete-file|full-file-rewrite)\s+file_path=["']([^"']*)/i,
|
||||
];
|
||||
|
||||
|
||||
for (const pattern of filePathPatterns) {
|
||||
const match = messageContent.match(pattern);
|
||||
if (match && match[1]) {
|
||||
|
@ -199,7 +199,7 @@ export function FileOperationToolView({
|
|||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (operation !== 'delete' && !fileContent) {
|
||||
const tagName = operation === 'create' ? 'create-file' : 'full-file-rewrite';
|
||||
const openTagMatch = messageContent.match(new RegExp(`<${tagName}[^>]*>`, 'i'));
|
||||
|
@ -207,7 +207,7 @@ export function FileOperationToolView({
|
|||
const tagEndIndex = messageContent.indexOf(openTagMatch[0]) + openTagMatch[0].length;
|
||||
const afterTag = messageContent.substring(tagEndIndex);
|
||||
const closeTagMatch = afterTag.match(new RegExp(`<\\/${tagName}>`, 'i'));
|
||||
fileContent = closeTagMatch
|
||||
fileContent = closeTagMatch
|
||||
? afterTag.substring(0, closeTagMatch.index)
|
||||
: afterTag;
|
||||
console.log('[FileOperationToolView] Extracted file content length:', fileContent?.length);
|
||||
|
@ -223,7 +223,7 @@ export function FileOperationToolView({
|
|||
if ('content' in parsed && operation !== 'delete') {
|
||||
fileContent = parsed.content;
|
||||
}
|
||||
|
||||
|
||||
if ('arguments' in parsed && parsed.arguments) {
|
||||
const args = typeof parsed.arguments === 'string' ? JSON.parse(parsed.arguments) : parsed.arguments;
|
||||
if (args.file_path) {
|
||||
|
@ -241,7 +241,7 @@ export function FileOperationToolView({
|
|||
/<(?:create-file|delete-file|full-file-rewrite)\s+file_path=["']([^"']*)/i,
|
||||
/<file_path>([^<]*)/i,
|
||||
];
|
||||
|
||||
|
||||
for (const pattern of filePathPatterns) {
|
||||
const match = assistantContent.match(pattern);
|
||||
if (match && match[1]) {
|
||||
|
@ -250,7 +250,7 @@ export function FileOperationToolView({
|
|||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (operation !== 'delete' && !fileContent) {
|
||||
const tagName = operation === 'create' ? 'create-file' : 'full-file-rewrite';
|
||||
const contentAfterTag = assistantContent.split(`<${tagName}`)[1];
|
||||
|
@ -259,7 +259,7 @@ export function FileOperationToolView({
|
|||
if (tagEndIndex !== -1) {
|
||||
const potentialContent = contentAfterTag.substring(tagEndIndex + 1);
|
||||
const closingTagIndex = potentialContent.indexOf(`</${tagName}>`);
|
||||
fileContent = closingTagIndex !== -1
|
||||
fileContent = closingTagIndex !== -1
|
||||
? potentialContent.substring(0, closingTagIndex)
|
||||
: potentialContent;
|
||||
}
|
||||
|
@ -268,27 +268,27 @@ export function FileOperationToolView({
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// console.log('[FileOperationToolView] After initial extraction:', { filePath, hasFileContent: !!fileContent });
|
||||
|
||||
|
||||
if (!filePath) {
|
||||
filePath = extractFilePath(assistantContent);
|
||||
// console.log('[FileOperationToolView] After extractFilePath utility:', filePath);
|
||||
}
|
||||
|
||||
|
||||
if (!fileContent && operation !== 'delete') {
|
||||
fileContent = isStreaming
|
||||
? extractStreamingFileContent(
|
||||
assistantContent,
|
||||
operation === 'create' ? 'create-file' : 'full-file-rewrite',
|
||||
) || ''
|
||||
assistantContent,
|
||||
operation === 'create' ? 'create-file' : 'full-file-rewrite',
|
||||
) || ''
|
||||
: extractFileContent(
|
||||
assistantContent,
|
||||
operation === 'create' ? 'create-file' : 'full-file-rewrite',
|
||||
);
|
||||
assistantContent,
|
||||
operation === 'create' ? 'create-file' : 'full-file-rewrite',
|
||||
);
|
||||
// console.log('[FileOperationToolView] After content extraction utilities:', { hasFileContent: !!fileContent, contentLength: fileContent?.length });
|
||||
}
|
||||
|
||||
|
||||
const toolTitle = getToolTitle(name || `file-${operation}`);
|
||||
|
||||
const processedFilePath = filePath
|
||||
|
@ -303,14 +303,14 @@ export function FileOperationToolView({
|
|||
const isMarkdown = fileExtension === 'md';
|
||||
const isHtml = fileExtension === 'html' || fileExtension === 'htm';
|
||||
const isCsv = fileExtension === 'csv';
|
||||
|
||||
|
||||
const language = getLanguageFromFileName(fileName);
|
||||
const hasHighlighting = language !== 'text';
|
||||
|
||||
|
||||
const contentLines = fileContent
|
||||
? fileContent.replace(/\\n/g, '\n').split('\n')
|
||||
: [];
|
||||
|
||||
|
||||
const htmlPreviewUrl =
|
||||
isHtml && project?.sandbox?.sandbox_url && processedFilePath
|
||||
? constructHtmlPreviewUrl(project.sandbox.sandbox_url, processedFilePath)
|
||||
|
@ -364,7 +364,7 @@ export function FileOperationToolView({
|
|||
if (fileName.endsWith('.html')) return FileCode;
|
||||
return File;
|
||||
};
|
||||
|
||||
|
||||
const FileIcon = getFileIcon();
|
||||
|
||||
if (!isStreaming && !processedFilePath && !fileContent) {
|
||||
|
@ -395,17 +395,17 @@ export function FileOperationToolView({
|
|||
|
||||
if (isHtml && htmlPreviewUrl) {
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-16rem)]">
|
||||
<iframe
|
||||
<div className="flex flex-col h-[calc(100vh-16rem)]">
|
||||
<iframe
|
||||
src={htmlPreviewUrl}
|
||||
title={`HTML Preview of ${fileName}`}
|
||||
className="flex-grow border-0"
|
||||
className="flex-grow border-0"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (isMarkdown) {
|
||||
return (
|
||||
<div className="p-1 py-0 prose dark:prose-invert prose-zinc max-w-none">
|
||||
|
@ -415,7 +415,7 @@ export function FileOperationToolView({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (isCsv) {
|
||||
return (
|
||||
<div className="h-full w-full p-4">
|
||||
|
@ -429,9 +429,9 @@ export function FileOperationToolView({
|
|||
return (
|
||||
<div className="p-4">
|
||||
<div className='w-full h-full bg-muted/20 border rounded-xl px-4 py-2 pb-6'>
|
||||
<pre className="text-sm font-mono text-zinc-800 dark:text-zinc-300 whitespace-pre-wrap break-words">
|
||||
{processUnicodeContent(fileContent)}
|
||||
</pre>
|
||||
<pre className="text-sm font-mono text-zinc-800 dark:text-zinc-300 whitespace-pre-wrap break-words">
|
||||
{processUnicodeContent(fileContent)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -479,7 +479,7 @@ export function FileOperationToolView({
|
|||
<TabsContent value="code" className="flex-1 h-full mt-0 p-0 overflow-hidden">
|
||||
<ScrollArea className="h-screen w-full min-h-0">
|
||||
{isStreaming && !fileContent ? (
|
||||
<LoadingState
|
||||
<LoadingState
|
||||
icon={Icon}
|
||||
iconColor={config.color}
|
||||
bgColor={config.bgColor}
|
||||
|
@ -551,11 +551,11 @@ export function FileOperationToolView({
|
|||
)}
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
<TabsContent value="preview" className="w-full flex-1 h-full mt-0 p-0 overflow-hidden">
|
||||
<ScrollArea className="h-full w-full min-h-0">
|
||||
{isStreaming && !fileContent ? (
|
||||
<LoadingState
|
||||
<LoadingState
|
||||
icon={Icon}
|
||||
iconColor={config.color}
|
||||
bgColor={config.bgColor}
|
||||
|
@ -584,7 +584,7 @@ export function FileOperationToolView({
|
|||
) : (
|
||||
renderFilePreview()
|
||||
)}
|
||||
|
||||
|
||||
{/* Streaming indicator overlay */}
|
||||
{isStreaming && fileContent && (
|
||||
<div className="sticky bottom-4 right-4 float-right mr-4 mb-4">
|
||||
|
@ -597,7 +597,7 @@ export function FileOperationToolView({
|
|||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</CardContent>
|
||||
|
||||
|
||||
<div className="px-4 py-2 h-10 bg-gradient-to-r from-zinc-50/90 to-zinc-100/90 dark:from-zinc-900/90 dark:to-zinc-800/90 backdrop-blur-sm border-t border-zinc-200 dark:border-zinc-800 flex justify-between items-center gap-4">
|
||||
<div className="h-full flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
<Badge variant="outline" className="py-0.5 h-6">
|
||||
|
@ -605,7 +605,7 @@ export function FileOperationToolView({
|
|||
{hasHighlighting ? language.toUpperCase() : fileExtension.toUpperCase() || 'TEXT'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{toolTimestamp && !isStreaming
|
||||
? formatTimestamp(toolTimestamp)
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
@ -73,7 +74,7 @@ export function WebSearchToolView({
|
|||
} else {
|
||||
parsedContent = {};
|
||||
}
|
||||
|
||||
|
||||
// Check if it's the response format with answer
|
||||
if (parsedContent.answer && typeof parsedContent.answer === 'string') {
|
||||
setAnswer(parsedContent.answer);
|
||||
|
@ -100,7 +101,7 @@ export function WebSearchToolView({
|
|||
|
||||
const getResultType = (result: any) => {
|
||||
const { url, title } = result;
|
||||
|
||||
|
||||
if (url.includes('news') || url.includes('article') || title.includes('News')) {
|
||||
return { icon: FileText, label: 'Article' };
|
||||
} else if (url.includes('wiki')) {
|
||||
|
@ -126,13 +127,13 @@ export function WebSearchToolView({
|
|||
</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{!isStreaming && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={
|
||||
isSuccess
|
||||
? "bg-gradient-to-b from-emerald-200 to-emerald-100 text-emerald-700 dark:from-emerald-800/50 dark:to-emerald-900/60 dark:text-emerald-300"
|
||||
isSuccess
|
||||
? "bg-gradient-to-b from-emerald-200 to-emerald-100 text-emerald-700 dark:from-emerald-800/50 dark:to-emerald-900/60 dark:text-emerald-300"
|
||||
: "bg-gradient-to-b from-rose-200 to-rose-100 text-rose-700 dark:from-rose-800/50 dark:to-rose-900/60 dark:text-rose-300"
|
||||
}
|
||||
>
|
||||
|
@ -149,7 +150,7 @@ export function WebSearchToolView({
|
|||
|
||||
<CardContent className="p-0 h-full flex-1 overflow-hidden relative">
|
||||
{isStreaming ? (
|
||||
<LoadingState
|
||||
<LoadingState
|
||||
icon={Search}
|
||||
iconColor="text-blue-500 dark:text-blue-400"
|
||||
bgColor="bg-gradient-to-b from-blue-100 to-blue-50 shadow-inner dark:from-blue-800/40 dark:to-blue-900/60 dark:shadow-blue-950/20"
|
||||
|
@ -226,24 +227,25 @@ 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);
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg shadow-sm hover:shadow transition-shadow overflow-hidden"
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start gap-3 mb-2">
|
||||
{favicon && (
|
||||
<img
|
||||
src={favicon}
|
||||
alt=""
|
||||
<img
|
||||
src={favicon}
|
||||
alt=""
|
||||
className="w-5 h-5 mt-1 rounded"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
|
@ -284,25 +286,36 @@ export function WebSearchToolView({
|
|||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
|
||||
{result.snippet && (
|
||||
<p className={cn(
|
||||
"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>
|
||||
|
||||
|
||||
{isExpanded && (
|
||||
<div className="bg-zinc-50 px-4 dark:bg-zinc-800/50 border-t border-zinc-200 dark:border-zinc-800 p-3 flex justify-between items-center">
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Source: {cleanUrl(result.url)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs bg-white dark:bg-zinc-900"
|
||||
asChild
|
||||
>
|
||||
|
@ -338,7 +351,7 @@ export function WebSearchToolView({
|
|||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
|
||||
<div className="px-4 py-2 h-10 bg-gradient-to-r from-zinc-50/90 to-zinc-100/90 dark:from-zinc-900/90 dark:to-zinc-800/90 backdrop-blur-sm border-t border-zinc-200 dark:border-zinc-800 flex justify-between items-center gap-4">
|
||||
<div className="h-full flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{!isStreaming && searchResults.length > 0 && (
|
||||
|
@ -348,7 +361,7 @@ export function WebSearchToolView({
|
|||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{toolTimestamp && !isStreaming
|
||||
? formatTimestamp(toolTimestamp)
|
||||
|
|
|
@ -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 {
|
||||
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