Merge pull request #1435 from KrishavRajSingh/feat/editor

HTML editor and export PDF
This commit is contained in:
Marko Kraemer 2025-08-23 17:47:37 -07:00 committed by GitHub
commit 8a55491775
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 115 additions and 28 deletions

View File

@ -20,7 +20,7 @@ You can modify the sandbox environment for development or to add new capabilitie
```
cd backend/sandbox/docker
docker compose build
docker push kortix/suna:0.1.3.9
docker push kortix/suna:0.1.3.11
```
3. Test your changes locally using docker-compose

View File

@ -6,7 +6,7 @@ services:
dockerfile: ${DOCKERFILE:-Dockerfile}
args:
TARGETPLATFORM: ${TARGETPLATFORM:-linux/amd64}
image: kortix/suna:0.1.3.9
image: kortix/suna:0.1.3.11
ports:
- "6080:6080" # noVNC web interface
- "5901:5901" # VNC port

View File

@ -74,11 +74,11 @@ class PresentationToPDFAPI:
for slide_num, slide_data in slides.items():
filename = slide_data.get('filename')
file_path = slide_data.get('file_path')
title = slide_data.get('title', f'Slide {slide_num}')
if filename:
# Treat filename as absolute path only
html_path = Path(filename)
if file_path:
html_path = Path(f"/workspace/{file_path}")
print(f"Using path: {html_path}")
# Verify the path exists

View File

@ -6,4 +6,5 @@ pydantic==2.6.1
pytesseract==0.3.13
pandas==2.3.0
playwright>=1.40.0
PyPDF2>=3.0.0
PyPDF2>=3.0.0
bs4==0.0.2

View File

@ -535,6 +535,7 @@ def inject_editor_functionality(html_content: str, file_path: str) -> str:
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
display: flex;
gap: 4px;
z-index: 1000;
}
.save-btn, .cancel-btn {

View File

@ -306,8 +306,8 @@ class Configuration:
STRIPE_PRODUCT_ID_STAGING: str = 'prod_SCgIj3G7yPOAWY'
# Sandbox configuration
SANDBOX_IMAGE_NAME = "kortix/suna:0.1.3.9"
SANDBOX_SNAPSHOT_NAME = "kortix/suna:0.1.3.9"
SANDBOX_IMAGE_NAME = "kortix/suna:0.1.3.11"
SANDBOX_SNAPSHOT_NAME = "kortix/suna:0.1.3.11"
SANDBOX_ENTRYPOINT = "/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf"
# LangFuse configuration

View File

@ -127,8 +127,8 @@ As part of the setup, you'll need to:
1. Create a Daytona account
2. Generate an API key
3. Create a Snapshot:
- Name: `kortix/suna:0.1.3.9`
- Image name: `kortix/suna:0.1.3.9`
- Name: `kortix/suna:0.1.3.11`
- Image name: `kortix/suna:0.1.3.11`
- Entrypoint: `/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf`
## Manual Configuration

View File

@ -12,6 +12,7 @@ import {
SkipBack,
SkipForward,
Edit,
Loader2,
} from 'lucide-react';
import {
DropdownMenu,
@ -44,6 +45,7 @@ interface FullScreenPresentationViewerProps {
presentationName?: string;
sandboxUrl?: string;
initialSlide?: number;
onPDFDownload?: (setIsDownloadingPDF: (isDownloading: boolean) => void) => void;
}
export function FullScreenPresentationViewer({
@ -52,12 +54,15 @@ export function FullScreenPresentationViewer({
presentationName,
sandboxUrl,
initialSlide = 1,
onPDFDownload,
}: FullScreenPresentationViewerProps) {
const [metadata, setMetadata] = useState<PresentationMetadata | null>(null);
const [currentSlide, setCurrentSlide] = useState(initialSlide);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showControls, setShowControls] = useState(true);
const [showEditor, setShowEditor] = useState(false);
const [isDownloadingPDF, setIsDownloadingPDF] = useState(false);
// Create a stable refresh timestamp when metadata changes (like PresentationViewer)
const refreshTimestamp = useMemo(() => Date.now(), [metadata]);
@ -115,6 +120,24 @@ export function FullScreenPresentationViewer({
}
}, [isOpen, loadMetadata, initialSlide]);
// Reload metadata when exiting editor mode to refresh with latest changes
useEffect(() => {
let timeoutId: NodeJS.Timeout;
if (!showEditor) {
// Add a small delay to allow the editor to save changes
timeoutId = setTimeout(() => {
loadMetadata();
}, 300);
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [showEditor, loadMetadata]);
// Navigation functions
const goToNextSlide = useCallback(() => {
if (currentSlide < totalSlides) {
@ -133,6 +156,7 @@ export function FullScreenPresentationViewer({
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
// Prevent default for all our handled keys
const handledKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', ' ', 'Home', 'End', 'Escape'];
if (handledKeys.includes(e.key)) {
@ -157,7 +181,11 @@ export function FullScreenPresentationViewer({
setCurrentSlide(totalSlides);
break;
case 'Escape':
onClose();
if (showEditor) {
setShowEditor(false);
} else {
onClose();
}
break;
}
};
@ -165,7 +193,7 @@ export function FullScreenPresentationViewer({
// Add event listener to document with capture to ensure we get the events first
document.addEventListener('keydown', handleKeyDown, true);
return () => document.removeEventListener('keydown', handleKeyDown, true);
}, [isOpen, goToNextSlide, goToPreviousSlide, totalSlides, onClose]);
}, [isOpen, goToNextSlide, goToPreviousSlide, totalSlides, onClose, showEditor]);
@ -247,11 +275,11 @@ export function FullScreenPresentationViewer({
}}
>
<iframe
key={`slide-${slide.number}-${refreshTimestamp}`} // Key with stable timestamp ensures iframe refreshes when metadata changes
src={slideUrlWithCacheBust}
key={`slide-${slide.number}-${refreshTimestamp}-${showEditor}`} // Key with stable timestamp ensures iframe refreshes when metadata changes
src={showEditor ? `${sandboxUrl}/api/html/${slide.file_path}/editor` : slideUrlWithCacheBust}
title={`Slide ${slide.number}: ${slide.title}`}
className="border-0 rounded-xl"
sandbox="allow-same-origin allow-scripts"
sandbox="allow-same-origin allow-scripts allow-modals"
style={{
width: '1920px',
height: '1080px',
@ -261,7 +289,7 @@ export function FullScreenPresentationViewer({
transformOrigin: '0 0',
position: 'absolute',
top: 0,
left: 0,
left: `calc((100% - ${1920 * scale}px) / 2)`,
willChange: 'transform',
backfaceVisibility: 'hidden',
WebkitBackfaceVisibility: 'hidden'
@ -278,7 +306,7 @@ export function FullScreenPresentationViewer({
SlideIframeComponent.displayName = 'SlideIframeComponent';
return SlideIframeComponent;
}, [sandboxUrl, refreshTimestamp]);
}, [sandboxUrl, refreshTimestamp, showEditor]);
// Render slide iframe with proper scaling
const renderSlide = useMemo(() => {
@ -317,9 +345,10 @@ export function FullScreenPresentationViewer({
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
title="Edit presentation"
title={showEditor ? "Close editor" : "Edit presentation"}
onClick={() => setShowEditor(!showEditor)}
>
<Edit className="h-3.5 w-3.5" />
{showEditor ? <Presentation className="h-3.5 w-3.5" /> : <Edit className='h-3.5 w-3.5'/>}
</Button>
{/* Export dropdown */}
@ -331,11 +360,15 @@ export function FullScreenPresentationViewer({
className="h-8 w-8 p-0"
title="Export presentation"
>
<Download className="h-3.5 w-3.5" />
{isDownloadingPDF ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem className="cursor-pointer">
<DropdownMenuItem className="cursor-pointer" onClick={() => onPDFDownload(setIsDownloadingPDF)}>
<FileText className="h-4 w-4 mr-2" />
PDF
</DropdownMenuItem>

View File

@ -34,6 +34,7 @@ import { constructHtmlPreviewUrl } from '@/lib/utils/url';
import { CodeBlockCode } from '@/components/ui/code-block';
import { LoadingState } from '../shared/LoadingState';
import { FullScreenPresentationViewer } from './FullScreenPresentationViewer';
import { toast } from 'sonner';
interface SlideMetadata {
title: string;
@ -75,6 +76,7 @@ export function PresentationViewer({
const [visibleSlide, setVisibleSlide] = useState<number | null>(null);
const [isFullScreenOpen, setIsFullScreenOpen] = useState(false);
const [fullScreenInitialSlide, setFullScreenInitialSlide] = useState<number | null>(null);
const [isDownloadingPDF, setIsDownloadingPDF] = useState(false);
// Extract presentation info from tool data
const { toolResult } = extractToolData(toolContent);
@ -423,8 +425,52 @@ export function PresentationViewer({
return <SlideIframe slide={slide} />;
}, [SlideIframe]);
const downloadPresentationAsPDF = async (sandboxUrl: string, presentationName: string): Promise<void> => {
try {
const response = await fetch(`${sandboxUrl}/presentation/convert-to-pdf`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
presentation_path: `/workspace/presentations/${presentationName}`,
download: true
})
});
if (!response.ok) {
throw new Error('Failed to download PDF');
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${presentationName}.pdf`;
a.click();
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Error downloading PDF:', error);
// You could show a toast notification here
toast.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
const handlePDFDownload = useCallback(async (setIsDownloadingPDF: (isDownloading: boolean) => void) => {
if (!project?.sandbox?.sandbox_url || !extractedPresentationName) return;
setIsDownloadingPDF(true);
try{
await downloadPresentationAsPDF(project.sandbox.sandbox_url, extractedPresentationName);
} catch (error) {
console.error('Error downloading PDF:', error);
} finally {
setIsDownloadingPDF(false);
}
}, [project?.sandbox?.sandbox_url, extractedPresentationName]);
return (
<Card className="gap-0 flex border shadow-none border-t border-b-0 border-x-0 p-0 rounded-none flex-col h-full overflow-hidden bg-card">
<CardHeader className="h-14 bg-zinc-50/80 dark:bg-zinc-900/80 backdrop-blur-sm border-b p-2 px-4 space-y-2">
@ -465,15 +511,16 @@ export function PresentationViewer({
className="h-8 w-8 p-0"
title="Export presentation"
>
<Download className="h-3.5 w-3.5" />
{isDownloadingPDF ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem
onClick={() => {
// TODO: Implement PDF export
console.log('Export as PDF');
}}
onClick={() => handlePDFDownload(setIsDownloadingPDF)}
className="cursor-pointer"
>
<FileText className="h-4 w-4 mr-2" />
@ -647,10 +694,15 @@ export function PresentationViewer({
onClose={() => {
setIsFullScreenOpen(false);
setFullScreenInitialSlide(null);
// Reload metadata after closing full screen viewer in case edits were made
setTimeout(() => {
loadMetadata();
}, 300);
}}
presentationName={extractedPresentationName}
sandboxUrl={project?.sandbox?.sandbox_url}
initialSlide={fullScreenInitialSlide || visibleSlide || currentSlideNumber || slides[0]?.number || 1}
onPDFDownload={handlePDFDownload}
/>
</Card>
);

View File

@ -669,8 +669,8 @@ class SetupWizard:
f"Visit {Colors.GREEN}https://app.daytona.io/dashboard/snapshots{Colors.ENDC}{Colors.CYAN} to create a snapshot."
)
print_info("Create a snapshot with these exact settings:")
print_info(f" - Name:\t\t{Colors.GREEN}kortix/suna:0.1.3.9{Colors.ENDC}")
print_info(f" - Snapshot name:\t{Colors.GREEN}kortix/suna:0.1.3.9{Colors.ENDC}")
print_info(f" - Name:\t\t{Colors.GREEN}kortix/suna:0.1.3.11{Colors.ENDC}")
print_info(f" - Snapshot name:\t{Colors.GREEN}kortix/suna:0.1.3.11{Colors.ENDC}")
print_info(
f" - Entrypoint:\t{Colors.GREEN}/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf{Colors.ENDC}"
)