Compare commits

...

12 Commits

Author SHA1 Message Date
marko-kraemer 504ac13e18 fix 2025-08-23 18:39:40 -07:00
marko-kraemer 6e864680b0 attempt fix 2025-08-23 18:15:42 -07:00
Marko Kraemer 8a55491775
Merge pull request #1435 from KrishavRajSingh/feat/editor
HTML editor and export PDF
2025-08-23 17:47:37 -07:00
Krishav Raj Singh f8dc2f970c chore: update snapshot 2025-08-24 02:05:25 +05:30
Krishav Raj Singh 8a96bb2ed4 fix: increased zindex of save cancel control 2025-08-24 02:05:03 +05:30
Krishav daf36ee154
Merge branch 'kortix-ai:main' into feat/editor 2025-08-24 01:15:38 +05:30
Krishav Raj Singh 1abf7f0bfd chore: update snapshot 2025-08-24 01:14:13 +05:30
Krishav Raj Singh 67a5bfa431 ui: move slideIframe to center 2025-08-24 00:30:32 +05:30
Krishav Raj Singh 524758d1dd fix: refresh iframes when edited 2025-08-24 00:07:28 +05:30
Krishav Raj Singh bcba33cdb0 feat: download ppt as pdf 2025-08-23 22:28:56 +05:30
Krishav Raj Singh 0abe7a629f fix: ppt filepath 2025-08-23 21:36:56 +05:30
Krishav Raj Singh 0eafcf6c9f update ppt 2025-08-23 19:34:22 +05:30
12 changed files with 183 additions and 34 deletions

View File

@ -113,6 +113,53 @@ class ResponseProcessor:
return format_for_yield(message_obj)
return None
def _serialize_model_response(self, model_response) -> Dict[str, Any]:
"""Convert a LiteLLM ModelResponse object to a JSON-serializable dictionary.
Args:
model_response: The LiteLLM ModelResponse object
Returns:
A dictionary representation of the ModelResponse
"""
try:
# Try to use the model_dump method if available (Pydantic v2)
if hasattr(model_response, 'model_dump'):
return model_response.model_dump()
# Try to use the dict method if available (Pydantic v1)
elif hasattr(model_response, 'dict'):
return model_response.dict()
# Fallback: manually extract common attributes
else:
result = {}
# Common LiteLLM ModelResponse attributes
for attr in ['id', 'object', 'created', 'model', 'choices', 'usage', 'system_fingerprint']:
if hasattr(model_response, attr):
value = getattr(model_response, attr)
# Recursively handle nested objects
if hasattr(value, 'model_dump'):
result[attr] = value.model_dump()
elif hasattr(value, 'dict'):
result[attr] = value.dict()
elif isinstance(value, list):
result[attr] = [
item.model_dump() if hasattr(item, 'model_dump')
else item.dict() if hasattr(item, 'dict')
else item for item in value
]
else:
result[attr] = value
return result
except Exception as e:
logger.warning(f"Failed to serialize ModelResponse: {str(e)}, falling back to string representation")
# Ultimate fallback: convert to string
return {"raw_response": str(model_response), "serialization_error": str(e)}
async def _add_message_with_agent_info(
self,
thread_id: str,
@ -1009,11 +1056,14 @@ class ResponseProcessor:
# --- Save and Yield assistant_response_end ---
if assistant_message_object: # Only save if assistant message was saved
try:
# Save the full LiteLLM response object directly in content
# Convert LiteLLM ModelResponse to a JSON-serializable dictionary
response_dict = self._serialize_model_response(llm_response)
# Save the serialized response object in content
await self.add_message(
thread_id=thread_id,
type="assistant_response_end",
content=llm_response,
content=response_dict,
is_llm_message=False,
metadata={"thread_run_id": thread_run_id}
)

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

@ -116,11 +116,20 @@ def get_model_pricing(model: str) -> tuple[float, float] | None:
Returns:
Tuple of (input_cost_per_million_tokens, output_cost_per_million_tokens) or None if not found
"""
# Use the model manager to get pricing
pricing = model_manager.get_pricing(model)
if pricing:
return pricing.input_cost_per_million_tokens, pricing.output_cost_per_million_tokens
# First try to resolve the model ID to handle aliases
resolved_model = model_manager.resolve_model_id(model)
logger.debug(f"Resolving model '{model}' -> '{resolved_model}'")
# Try the resolved model first, then fallback to original
for model_to_try in [resolved_model, model]:
model_obj = model_manager.get_model(model_to_try)
if model_obj and model_obj.pricing:
logger.debug(f"Found pricing for model {model_to_try}: input=${model_obj.pricing.input_cost_per_million_tokens}/M, output=${model_obj.pricing.output_cost_per_million_tokens}/M")
return model_obj.pricing.input_cost_per_million_tokens, model_obj.pricing.output_cost_per_million_tokens
else:
logger.debug(f"No pricing for model_to_try='{model_to_try}' (model_obj: {model_obj is not None}, has_pricing: {model_obj.pricing is not None if model_obj else False})")
logger.warning(f"No pricing found for model '{model}' (resolved: '{resolved_model}')")
return None
@ -725,9 +734,12 @@ def calculate_token_cost(prompt_tokens: int, completion_tokens: int, model: str)
prompt_tokens = int(prompt_tokens) if prompt_tokens is not None else 0
completion_tokens = int(completion_tokens) if completion_tokens is not None else 0
logger.debug(f"Calculating token cost for model '{model}' with {prompt_tokens} input tokens and {completion_tokens} output tokens")
# Try to resolve the model name using new model manager first
from models import model_manager
resolved_model = model_manager.resolve_model_id(model)
logger.debug(f"Model '{model}' resolved to '{resolved_model}'")
# Check if we have hardcoded pricing for this model (try both original and resolved)
hardcoded_pricing = get_model_pricing(model) or get_model_pricing(resolved_model)

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}"
)